Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/task-lifecycle-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ The model maps its state to production fields as follows:
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `phase`, `index` | `Task.assistantMessageContent` partial flags, matching `tool_result` blocks in `userMessageContent`, and `currentStreamingContentIndex` |
| `stream` | `didCompleteReadingStream` |
| `lock`, `owner`, `pending` | `presentAssistantMessageLocked` and `presentAssistantMessageHasPendingUpdates`, owned by the `presentAssistantMessage` wrapper |
| `lock`, `owner`, `pending` | `dispatchState` (not `IDLE`) and `pendingWork` (not `null`), owned by the `presentAssistantMessage` wrapper |
| `latch` | `userMessageContentReady` |
| `epoch` | the per-request reset in the request loop (`Task.ts`) |
| `poll` and `post-wait` | the `pWaitFor` condition and the abort check that follows it |
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-tool-turn-continuation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ interface ModelState {
phase: Phase[] // Task.assistantMessageContent partial flag plus tool_result presence
stream: "open" | "complete" // Task.didCompleteReadingStream
index: number // Task.currentStreamingContentIndex
lock: boolean // Task.presentAssistantMessageLocked
lock: boolean // Task.dispatchState !== DispatchState.IDLE
owner: number
pending: boolean // Task.presentAssistantMessageHasPendingUpdates
pending: boolean // Task.pendingWork !== null
latch: boolean // Task.userMessageContentReady
abort: boolean
threw: boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// npx vitest run core/assistant-message/__tests__/presentAssistantMessage-auto-deny.spec.ts

import { DispatchState, type PendingWork } from "../../task/dispatchState"
import type { Anthropic } from "@anthropic-ai/sdk"
import { describe, it, expect, beforeEach, vi } from "vitest"
import { presentAssistantMessage } from "../presentAssistantMessage"
Expand Down Expand Up @@ -69,8 +70,8 @@ interface MockTask {
taskId: string
instanceId: string
abort: boolean
presentAssistantMessageLocked: boolean
presentAssistantMessageHasPendingUpdates: boolean
dispatchState: DispatchState
pendingWork: PendingWork | null
currentStreamingContentIndex: number
assistantMessageContent: unknown[]
userMessageContent: Anthropic.ToolResultBlockParam[]
Expand Down Expand Up @@ -102,8 +103,8 @@ function buildMockTask(): MockTask {
taskId: "test-task-id",
instanceId: "test-instance",
abort: false,
presentAssistantMessageLocked: false,
presentAssistantMessageHasPendingUpdates: false,
dispatchState: DispatchState.IDLE,
pendingWork: null,
currentStreamingContentIndex: 0,
assistantMessageContent: [],
userMessageContent: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts

import { DispatchState } from "../../task/dispatchState"
import { describe, it, expect, beforeEach, vi } from "vitest"
import { presentAssistantMessage } from "../presentAssistantMessage"
import { validateToolUse } from "../../tools/validateToolUse"
Expand Down Expand Up @@ -67,8 +68,8 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
taskId: "test-task-id",
instanceId: "test-instance",
abort: false,
presentAssistantMessageLocked: false,
presentAssistantMessageHasPendingUpdates: false,
dispatchState: DispatchState.IDLE,
pendingWork: null,
currentStreamingContentIndex: 0,
assistantMessageContent: [],
userMessageContent: [],
Expand Down Expand Up @@ -224,17 +225,17 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
})
})

describe("Presentation lock ownership", () => {
describe("Dispatch state ownership", () => {
it("drains an update queued during the helper-to-wrapper handoff", async () => {
mockTask.assistantMessageContent = [{ type: "text", content: "initial", partial: true }]
let queuedCall: Promise<void> | undefined
let lockedDuringHandoff = false
let busyDuringHandoff = false
mockTask.say.mockImplementationOnce(async () => {
// The first microtask precedes the helper's await continuation.
// The second follows that continuation but precedes the wrapper's.
queueMicrotask(() => {
queueMicrotask(() => {
lockedDuringHandoff = mockTask.presentAssistantMessageLocked
busyDuringHandoff = mockTask.dispatchState !== DispatchState.IDLE
mockTask.assistantMessageContent[0] = { type: "text", content: "final", partial: false }
mockTask.didCompleteReadingStream = true
queuedCall = presentAssistantMessage(mockTask, baseSnapshot)
Expand All @@ -245,15 +246,15 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
await presentAssistantMessage(mockTask, baseSnapshot)
await queuedCall

expect(lockedDuringHandoff).toBe(true)
expect(busyDuringHandoff).toBe(true)
expect(mockTask.say.mock.calls).toEqual([
["text", "initial", undefined, true],
["text", "final", undefined, false],
])
expect(mockTask.currentStreamingContentIndex).toBe(1)
expect(mockTask.userMessageContentReady).toBe(true)
expect(mockTask.presentAssistantMessageHasPendingUpdates).toBe(false)
expect(mockTask.presentAssistantMessageLocked).toBe(false)
expect(mockTask.pendingWork).toBeNull()
expect(mockTask.dispatchState).toBe(DispatchState.IDLE)
})

it("stops draining an update queued during the handoff once the task aborts", async () => {
Expand All @@ -279,7 +280,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
mockTask.assistantMessageContent[0] = { type: "text", content: "final", partial: false }
mockTask.didCompleteReadingStream = true
queuedCall = presentAssistantMessage(mockTask, baseSnapshot)
pendingDuringHandoff = mockTask.presentAssistantMessageHasPendingUpdates
pendingDuringHandoff = mockTask.pendingWork !== null
aborted = true
})
})
Expand All @@ -291,30 +292,30 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
expect(pendingDuringHandoff).toBe(true)
expect(mockTask.say).toHaveBeenCalledExactlyOnceWith("text", "initial", undefined, true)
expect(mockTask.currentStreamingContentIndex).toBe(0)
expect(mockTask.presentAssistantMessageLocked).toBe(false)
expect(mockTask.dispatchState).toBe(DispatchState.IDLE)
})

it("holds one lock across consecutive blocks and queues overlapping calls", async () => {
it("holds SERIAL across consecutive blocks and queues overlapping calls", async () => {
mockTask.assistantMessageContent = [
{ type: "text", content: "first", partial: false },
{ type: "text", content: "second", partial: false },
]
mockTask.didCompleteReadingStream = true
const lockChanges: boolean[] = []
let locked = false
Object.defineProperty(mockTask, "presentAssistantMessageLocked", {
const stateChanges: DispatchState[] = []
let state = DispatchState.IDLE
Object.defineProperty(mockTask, "dispatchState", {
configurable: true,
get: () => locked,
set: (value: boolean) => {
locked = value
lockChanges.push(value)
get: () => state,
set: (value: DispatchState) => {
state = value
stateChanges.push(value)
},
})
mockTask.say.mockImplementation(async (_type: string, content: string) => {
expect(mockTask.presentAssistantMessageLocked).toBe(true)
expect(mockTask.dispatchState).toBe(DispatchState.SERIAL)
if (content === "second") {
await presentAssistantMessage(mockTask, baseSnapshot)
expect(mockTask.presentAssistantMessageHasPendingUpdates).toBe(true)
expect(mockTask.pendingWork).toEqual({ mode: "serial" })
}
})

Expand All @@ -326,8 +327,8 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
])
expect(mockTask.currentStreamingContentIndex).toBe(2)
expect(mockTask.userMessageContentReady).toBe(true)
expect(mockTask.presentAssistantMessageHasPendingUpdates).toBe(false)
expect(lockChanges).toEqual([true, false])
expect(mockTask.pendingWork).toBeNull()
expect(stateChanges).toEqual([DispatchState.SERIAL, DispatchState.IDLE])
})

it("consumes a pending update for a partial block without repeating it indefinitely", async () => {
Expand All @@ -336,11 +337,11 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
.mockImplementationOnce(async () => {
mockTask.assistantMessageContent[0] = { type: "text", content: "updated", partial: true }
await presentAssistantMessage(mockTask, baseSnapshot)
expect(mockTask.presentAssistantMessageHasPendingUpdates).toBe(true)
expect(mockTask.pendingWork).toEqual({ mode: "serial" })
})
.mockImplementationOnce(async () => {
expect(mockTask.presentAssistantMessageLocked).toBe(true)
expect(mockTask.presentAssistantMessageHasPendingUpdates).toBe(false)
expect(mockTask.dispatchState).toBe(DispatchState.SERIAL)
expect(mockTask.pendingWork).toBeNull()
})

await presentAssistantMessage(mockTask, baseSnapshot)
Expand All @@ -350,8 +351,8 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
["text", "updated", undefined, true],
])
expect(mockTask.currentStreamingContentIndex).toBe(0)
expect(mockTask.presentAssistantMessageHasPendingUpdates).toBe(false)
expect(mockTask.presentAssistantMessageLocked).toBe(false)
expect(mockTask.pendingWork).toBeNull()
expect(mockTask.dispatchState).toBe(DispatchState.IDLE)
})

it("stops internal continuation when the task aborts between blocks", async () => {
Expand All @@ -367,10 +368,10 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {

expect(mockTask.say).toHaveBeenCalledExactlyOnceWith("text", "first", undefined, false)
expect(mockTask.currentStreamingContentIndex).toBe(1)
expect(mockTask.presentAssistantMessageLocked).toBe(false)
expect(mockTask.dispatchState).toBe(DispatchState.IDLE)
})

it("releases the presentation lock when a later block throws", async () => {
it("returns to IDLE when a later block throws", async () => {
mockTask.assistantMessageContent = [
{ type: "text", content: "first", partial: false },
{ type: "text", content: "second", partial: false },
Expand All @@ -381,12 +382,12 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {

expect(mockTask.say).toHaveBeenCalledTimes(2)
expect(mockTask.currentStreamingContentIndex).toBe(1)
expect(mockTask.presentAssistantMessageLocked).toBe(false)
expect(mockTask.dispatchState).toBe(DispatchState.IDLE)
})
})

describe("Custom tool error recording", () => {
it("releases the presentation lock when dispatch throws", async () => {
it("returns to IDLE when dispatch throws", async () => {
mockTask.assistantMessageContent = [
{
type: "tool_use",
Expand All @@ -404,7 +405,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {

try {
await expect(presentAssistantMessage(mockTask, baseSnapshot)).rejects.toThrow("dispatch failed")
expect(mockTask.presentAssistantMessageLocked).toBe(false)
expect(mockTask.dispatchState).toBe(DispatchState.IDLE)
} finally {
handleSpy.mockRestore()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-images.spec.ts

import { DispatchState } from "../../task/dispatchState"
import { describe, it, expect, beforeEach, vi } from "vitest"
import { presentAssistantMessage } from "../presentAssistantMessage"
import { validateToolUse } from "../../tools/validateToolUse"
Expand Down Expand Up @@ -40,8 +41,8 @@ describe("presentAssistantMessage - Image Handling in Native Tool Calling", () =
taskId: "test-task-id",
instanceId: "test-instance",
abort: false,
presentAssistantMessageLocked: false,
presentAssistantMessageHasPendingUpdates: false,
dispatchState: DispatchState.IDLE,
pendingWork: null,
currentStreamingContentIndex: 0,
assistantMessageContent: [],
userMessageContent: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-tool-repetition.spec.ts

import { DispatchState } from "../../task/dispatchState"
import { providerIdentifiers } from "@roo-code/types"

import type { ToolUse } from "../../../shared/tools"
Expand Down Expand Up @@ -153,8 +154,8 @@ describe("presentAssistantMessage - Tool Repetition Detection", () => {
taskId: "test-task-id",
instanceId: "test-instance",
abort: false,
presentAssistantMessageLocked: false,
presentAssistantMessageHasPendingUpdates: false,
dispatchState: DispatchState.IDLE,
pendingWork: null,
currentStreamingContentIndex: 0,
assistantMessageContent: [],
userMessageContent: [],
Expand Down Expand Up @@ -217,8 +218,8 @@ describe("presentAssistantMessage - Tool Repetition Detection", () => {
*/
function startNextTurn(blocks: ToolUse[]) {
mockTask.currentStreamingContentIndex = 0
mockTask.presentAssistantMessageLocked = false
mockTask.presentAssistantMessageHasPendingUpdates = false
mockTask.dispatchState = DispatchState.IDLE
mockTask.pendingWork = null
mockTask.didAlreadyUseTool = false
mockTask.didRejectTool = false
mockTask.userMessageContent = []
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts

import { DispatchState, type PendingWork } from "../../task/dispatchState"
import type { Anthropic } from "@anthropic-ai/sdk"
import { TelemetryEventName } from "@roo-code/types"
import { describe, it, expect, beforeEach, vi, type Mock } from "vitest"
Expand Down Expand Up @@ -60,8 +61,8 @@ interface MockTask {
taskId: string
instanceId: string
abort: boolean
presentAssistantMessageLocked: boolean
presentAssistantMessageHasPendingUpdates: boolean
dispatchState: DispatchState
pendingWork: PendingWork | null
currentStreamingContentIndex: number
assistantMessageContent: unknown[]
userMessageContent: Anthropic.ToolResultBlockParam[]
Expand Down Expand Up @@ -100,8 +101,8 @@ describe("presentAssistantMessage - tool usage attribution", () => {
taskId: "test-task-id",
instanceId: "test-instance",
abort: false,
presentAssistantMessageLocked: false,
presentAssistantMessageHasPendingUpdates: false,
dispatchState: DispatchState.IDLE,
pendingWork: null,
currentStreamingContentIndex: 0,
assistantMessageContent: [],
userMessageContent: [],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts

import { DispatchState } from "../../task/dispatchState"
import { describe, it, expect, beforeEach, vi } from "vitest"
import { presentAssistantMessage } from "../presentAssistantMessage"
import { isValidToolName, validateToolUse } from "../../tools/validateToolUse"
Expand Down Expand Up @@ -42,8 +43,8 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
taskId: "test-task-id",
instanceId: "test-instance",
abort: false,
presentAssistantMessageLocked: false,
presentAssistantMessageHasPendingUpdates: false,
dispatchState: DispatchState.IDLE,
pendingWork: null,
currentStreamingContentIndex: 0,
assistantMessageContent: [],
userMessageContent: [],
Expand Down Expand Up @@ -243,7 +244,7 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => {
expect(mockTask.say).toHaveBeenCalledTimes(2)
expect(mockTask.say).toHaveBeenNthCalledWith(1, "text", "streaming", undefined, true)
expect(mockTask.say).toHaveBeenNthCalledWith(2, "text", "updated", undefined, true)
expect(mockTask.presentAssistantMessageHasPendingUpdates).toBe(false)
expect(mockTask.pendingWork).toBeNull()
expect(mockTask.currentStreamingContentIndex).toBe(0)
})

Expand Down
Loading
Loading