Skip to content
Merged
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
23 changes: 23 additions & 0 deletions apps/vscode-e2e/src/suite/restart-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,18 @@ async function runCreate(api: RooCodeAPI): Promise<void> {
}

async function runVerify(api: RooCodeAPI): Promise<void> {
const taskMessages: Array<{ type: string; ask?: string }> = []
const messageHandler = ({ taskId, message }: { taskId: string; message: (typeof taskMessages)[number] }) => {
if (taskId === verifiedTaskId) taskMessages.push(message)
}
let verifiedTaskId: string | undefined
try {
const createResult = await readPhaseResult(getResultsDir(), "create")
assert.strictEqual(createResult.status, "passed")
const taskId = createResult.values?.taskId
assert.ok(taskId, "Create phase should record a task ID")
verifiedTaskId = taskId
api.on(RooCodeEventName.Message, messageHandler)

await waitFor(() => api.isReady())
assert.strictEqual(await api.isTaskInHistory(taskId), true, "Task should be present after restart")
Expand All @@ -87,6 +94,20 @@ async function runVerify(api: RooCodeAPI): Promise<void> {
const conversationLength = await api.getTaskApiConversationHistoryLength(taskId)
assert.ok(conversationLength > 0, "API conversation history should be available after restart")

await api.resumeTask(taskId)
await waitFor(() => taskMessages.some(({ type, ask }) => type === "ask" && ask === "resume_completed_task"))
assert.strictEqual(await api.isTaskInHistory(taskId), true, "Reopened task should remain in history")
const reopenedHistoryItem = await api.getTaskHistoryItem(taskId)
assert.ok(reopenedHistoryItem, "Reopened task should retain its history item")
assert.ok(
reopenedHistoryItem.task.includes("RESTART_PERSISTENCE_SMOKE"),
"Reopened task should retain its persisted history title",
)
assert.ok(
(await api.getTaskApiConversationHistoryLength(taskId)) >= conversationLength,
"Reopened task should retain its persisted API conversation history",
)

await writePhaseResult(getResultsDir(), {
version: PHASE_RESULT_VERSION,
phase: "verify",
Expand All @@ -102,6 +123,8 @@ async function runVerify(api: RooCodeAPI): Promise<void> {
error: serializePhaseError(error),
})
throw error
} finally {
api.off(RooCodeEventName.Message, messageHandler)
}
}

Expand Down
20 changes: 10 additions & 10 deletions docs/architecture/task-lifecycle-model.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export type ContextTruncation = z.infer<typeof contextTruncationSchema>
* Note: These fields are mutually exclusive - a message will have at most one of them.
*/
export const clineMessageSchema = z.object({
messageId: z.string().optional(),
ts: z.number(),
type: z.union([z.literal("ask"), z.literal("say")]),
ask: clineAskSchema.optional(),
Expand Down
163 changes: 159 additions & 4 deletions src/__tests__/history-resume-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import { describe, it, expect, vi, beforeEach } from "vitest"
import { RooCodeEventName } from "@roo-code/types"
import type { HistoryItem } from "@roo-code/types"
import type { ClineMessage, HistoryItem } from "@roo-code/types"

import type { ApiMessage } from "../core/task-persistence"

/* vscode mock for Task/Provider imports */
vi.mock("vscode", () => {
Expand Down Expand Up @@ -44,8 +46,8 @@ vi.mock("../core/task-persistence", async (importOriginal) => {
return {
...real,
readApiMessages: vi.fn().mockResolvedValue([]),
saveApiMessages: vi.fn().mockResolvedValue(undefined),
saveTaskMessages: vi.fn().mockResolvedValue(undefined),
saveApiMessages: vi.fn(async ({ messages }: { messages: unknown[] }) => messages),
saveTaskMessages: vi.fn(async ({ messages }: { messages: unknown[] }) => messages),
}
})

Expand Down Expand Up @@ -237,7 +239,7 @@ describe("History resume delegation - parent metadata transitions", () => {
removeClineFromStack,
createTaskWithHistoryItem,
taskHistoryStore,
} as any)
} as unknown as ClineProvider)

vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockResolvedValue([])
Expand Down Expand Up @@ -347,13 +349,15 @@ describe("History resume delegation - parent metadata transitions", () => {
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
messageId: expect.any(String),
type: "say",
say: "subtask_result",
text: "Subtask completed successfully",
}),
]),
taskId: "p1",
globalStoragePath: "/storage",
merge: true,
}),
)

Expand All @@ -362,6 +366,7 @@ describe("History resume delegation - parent metadata transitions", () => {
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
messageId: expect.any(String),
role: "user",
content: expect.arrayContaining([
expect.objectContaining({
Expand All @@ -373,6 +378,7 @@ describe("History resume delegation - parent metadata transitions", () => {
]),
taskId: "p1",
globalStoragePath: "/storage",
merge: true,
}),
)

Expand All @@ -384,6 +390,155 @@ describe("History resume delegation - parent metadata transitions", () => {
expect(apiCall.messages).toHaveLength(2) // 1 original + 1 injected
})

it("hydrates the reopened parent from locked merge results without authoritative rewrites", async () => {
const parentItem = {
id: "parent-merge",
status: "delegated",
awaitingChildId: "child-merge",
childIds: ["child-merge"],
ts: 100,
task: "Parent",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
const overwriteClineMessages = vi.fn()
const overwriteApiConversationHistory = vi.fn()
const taskHistoryStore = makeTaskHistoryStoreStub({ id: "child-merge", status: "active" }, parentItem)
const provider = makeProviderStub({
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }),
emit: vi.fn(),
getCurrentTask: vi.fn(() => ({ taskId: "child-merge" })),
removeClineFromStack: vi.fn().mockResolvedValue(undefined),
createTaskWithHistoryItem: vi.fn().mockResolvedValue({
overwriteClineMessages,
overwriteApiConversationHistory,
resumeAfterDelegation: vi.fn().mockResolvedValue(undefined),
}),
taskHistoryStore,
})

vi.mocked(readTaskMessages).mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "initial UI" }])
vi.mocked(readApiMessages).mockResolvedValue([{ ts: 1, role: "user", content: "initial API" }])
vi.mocked(saveTaskMessages).mockImplementationOnce(
async ({ messages }) =>
[
{ ts: 1, type: "say", say: "text", text: "initial UI" },
{ ts: 2, type: "say", say: "text", text: "concurrent UI" },
messages.at(-1)!, // injected subtask_result
] as ClineMessage[],
)
vi.mocked(saveApiMessages).mockImplementationOnce(
async ({ messages }) =>
[
{ ts: 1, role: "user", content: "initial API" },
{ ts: 2, role: "assistant", content: "concurrent API" },
messages.at(-1)!, // injected tool_result / fallback
] as ApiMessage[],
)

await ClineProvider.prototype.reopenParentFromDelegation.call(provider, {
parentTaskId: "parent-merge",
childTaskId: "child-merge",
completionResultSummary: "Done",
})

expect(overwriteClineMessages).toHaveBeenCalledWith(
expect.arrayContaining([
{ ts: 1, type: "say", say: "text", text: "initial UI" },
{ ts: 2, type: "say", say: "text", text: "concurrent UI" },
expect.objectContaining({ type: "say", say: "subtask_result", text: "Done" }),
]),
false,
)
expect(overwriteApiConversationHistory).toHaveBeenCalledWith(
expect.arrayContaining([
{ ts: 1, role: "user", content: "initial API" },
{ ts: 2, role: "assistant", content: "concurrent API" },
expect.objectContaining({ role: "user" }),
]),
false,
)
})

it("does not reopen or overwrite a parent when its UI history cannot be read", async () => {
const parentItem = {
id: "parent-read-failure",
status: "delegated",
awaitingChildId: "child-read-failure",
childIds: ["child-read-failure"],
ts: 100,
task: "Parent",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
const log = vi.fn()
const taskHistoryStore = makeTaskHistoryStoreStub({ id: "child-read-failure", status: "active" }, parentItem)
const provider = makeProviderStub({
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }),
getCurrentTask: vi.fn(() => ({ taskId: "child-read-failure" })),
taskHistoryStore,
log,
})
vi.mocked(readTaskMessages).mockRejectedValue(new Error("history unavailable"))

const result = await ClineProvider.prototype.reopenParentFromDelegation.call(provider, {
parentTaskId: "parent-read-failure",
childTaskId: "child-read-failure",
completionResultSummary: "Child done",
})

expect(result).toBe(false)
expect(log).toHaveBeenCalledWith(expect.stringContaining("history unavailable"))
expect(readApiMessages).not.toHaveBeenCalled()
expect(saveTaskMessages).not.toHaveBeenCalled()
expect(saveApiMessages).not.toHaveBeenCalled()
expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled()
})

it("does not reopen or overwrite a parent when its API history cannot be read", async () => {
const parentItem = {
id: "parent-api-read-failure",
status: "delegated",
awaitingChildId: "child-api-read-failure",
childIds: ["child-api-read-failure"],
ts: 100,
task: "Parent",
tokensIn: 0,
tokensOut: 0,
totalCost: 0,
}
const log = vi.fn()
const taskHistoryStore = makeTaskHistoryStoreStub(
{ id: "child-api-read-failure", status: "active" },
parentItem,
)
const provider = makeProviderStub({
contextProxy: { globalStorageUri: { fsPath: "/storage" } },
getTaskWithId: vi.fn().mockResolvedValue({ historyItem: parentItem }),
getCurrentTask: vi.fn(() => ({ taskId: "child-api-read-failure" })),
taskHistoryStore,
log,
})
vi.mocked(readTaskMessages).mockResolvedValue([])
vi.mocked(readApiMessages).mockRejectedValue(new Error("api history unavailable"))

const result = await ClineProvider.prototype.reopenParentFromDelegation.call(provider, {
parentTaskId: "parent-api-read-failure",
childTaskId: "child-api-read-failure",
completionResultSummary: "Child done",
})

expect(result).toBe(false)
expect(log).toHaveBeenCalledWith(expect.stringContaining("api history unavailable"))
expect(saveTaskMessages).not.toHaveBeenCalled()
expect(saveApiMessages).not.toHaveBeenCalled()
expect(taskHistoryStore.atomicUpdatePair).not.toHaveBeenCalled()
})

it("reopenParentFromDelegation injects tool_result when new_task tool_use exists in API history", async () => {
const parentItem = {
id: "p-tool",
Expand Down
Loading
Loading