Skip to content

Commit a24919c

Browse files
committed
test(e2e): validation and bumping codecov
1 parent 0bf4a25 commit a24919c

5 files changed

Lines changed: 180 additions & 23 deletions

File tree

apps/vscode-e2e/src/fixtures/subtasks.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ import { LLMock } from "@copilotkit/aimock"
22

33
import { toolResultContains } from "./tool-result"
44

5-
export const SUBTASK_PARENT_PROMPT = "SUBTASK_PARENT_CANCELLATION_SMOKE"
6-
export const SUBTASK_CHILD_PROMPT = "SUBTASK_CHILD_CALCULATOR_SMOKE"
5+
const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE"
6+
const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE"
7+
8+
export const SUBTASK_CHILD_PROMPT = `${SUBTASK_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.`
9+
export const SUBTASK_PARENT_PROMPT = `${SUBTASK_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_CHILD_PROMPT}" Do not answer directly.`
710
export const SUBTASK_CHILD_FOLLOWUP_ANSWER = "9"
811
const INTERRUPTED_TOOL_RESULT = "Task was interrupted before this tool call could be completed."
912

1013
export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
1114
mock.addFixture({
1215
match: {
13-
userMessage: new RegExp(SUBTASK_PARENT_PROMPT),
16+
userMessage: new RegExp(SUBTASK_PARENT_MARKER),
1417
},
1518
response: {
1619
toolCalls: [
@@ -28,7 +31,7 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
2831

2932
mock.addFixture({
3033
match: {
31-
userMessage: new RegExp(SUBTASK_CHILD_PROMPT),
34+
userMessage: new RegExp(SUBTASK_CHILD_MARKER),
3235
},
3336
response: {
3437
toolCalls: [

src/core/tools/AttemptCompletionTool.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -96,18 +96,21 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
9696
// This shows the user the completion result and waits for acceptance
9797
// without injecting another tool_result to the parent
9898
} else if (status === "active") {
99-
// Normal subtask completion - do delegation
100-
const delegation = await this.delegateToParent(
101-
task,
102-
result,
103-
provider,
104-
askFinishSubTaskApproval,
105-
pushToolResult,
106-
)
107-
if (delegation === "delegated") {
108-
this.emitTaskCompleted(task)
99+
const { historyItem: parentHistory } = await provider.getTaskWithId(task.parentTaskId)
100+
101+
if (parentHistory.status === "delegated" && parentHistory.awaitingChildId === task.taskId) {
102+
const delegation = await this.delegateToParent(
103+
task,
104+
result,
105+
provider,
106+
askFinishSubTaskApproval,
107+
pushToolResult,
108+
)
109+
if (delegation === "delegated") {
110+
this.emitTaskCompleted(task)
111+
}
112+
if (delegation !== "continue") return
109113
}
110-
if (delegation !== "continue") return
111114
} else {
112115
// Unexpected status (undefined or "delegated") - log error and skip delegation
113116
// undefined indicates a bug in status persistence during child creation

src/core/tools/__tests__/attemptCompletionTool.spec.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,102 @@ describe("attemptCompletionTool", () => {
484484
})
485485

486486
describe("completion lifecycle", () => {
487+
it("delegates an active subtask completion only when the parent is awaiting that child", async () => {
488+
const block: AttemptCompletionToolUse = {
489+
type: "tool_use",
490+
name: "attempt_completion",
491+
params: { result: "9" },
492+
nativeArgs: { result: "9" },
493+
partial: false,
494+
}
495+
const mockProvider = {
496+
getTaskWithId: vi.fn().mockImplementation((id: string) => {
497+
if (id === "child-1") {
498+
return Promise.resolve({ historyItem: { id, status: "active" } })
499+
}
500+
if (id === "parent-1") {
501+
return Promise.resolve({
502+
historyItem: { id, status: "delegated", awaitingChildId: "child-1" },
503+
})
504+
}
505+
throw new Error(`unexpected task id ${id}`)
506+
}),
507+
reopenParentFromDelegation: vi.fn().mockResolvedValue(undefined),
508+
}
509+
510+
Object.assign(mockTask, {
511+
taskId: "child-1",
512+
parentTaskId: "parent-1",
513+
providerRef: { deref: () => mockProvider },
514+
})
515+
mockAskFinishSubTaskApproval.mockResolvedValue(true)
516+
517+
const callbacks: AttemptCompletionCallbacks = {
518+
askApproval: mockAskApproval,
519+
handleError: mockHandleError,
520+
pushToolResult: mockPushToolResult,
521+
askFinishSubTaskApproval: mockAskFinishSubTaskApproval,
522+
toolDescription: mockToolDescription,
523+
}
524+
525+
await attemptCompletionTool.handle(mockTask as Task, block, callbacks)
526+
527+
expect(mockAskFinishSubTaskApproval).toHaveBeenCalled()
528+
expect(mockProvider.reopenParentFromDelegation).toHaveBeenCalledWith({
529+
parentTaskId: "parent-1",
530+
childTaskId: "child-1",
531+
completionResultSummary: "9",
532+
})
533+
expect(mockTask.ask).not.toHaveBeenCalled()
534+
expect(mockPushToolResult).toHaveBeenCalledWith("")
535+
})
536+
537+
it("does not delegate a lineage-preserving subtask when the parent is no longer awaiting it", async () => {
538+
const block: AttemptCompletionToolUse = {
539+
type: "tool_use",
540+
name: "attempt_completion",
541+
params: { result: "9" },
542+
nativeArgs: { result: "9" },
543+
partial: false,
544+
}
545+
const mockProvider = {
546+
getTaskWithId: vi.fn().mockImplementation((id: string) => {
547+
if (id === "child-1") {
548+
return Promise.resolve({ historyItem: { id, status: "active" } })
549+
}
550+
if (id === "parent-1") {
551+
return Promise.resolve({
552+
historyItem: { id, status: "active", awaitingChildId: undefined },
553+
})
554+
}
555+
throw new Error(`unexpected task id ${id}`)
556+
}),
557+
reopenParentFromDelegation: vi.fn().mockResolvedValue(undefined),
558+
}
559+
560+
Object.assign(mockTask, {
561+
taskId: "child-1",
562+
parentTaskId: "parent-1",
563+
providerRef: { deref: () => mockProvider },
564+
})
565+
mockAskFinishSubTaskApproval.mockResolvedValue(true)
566+
567+
const callbacks: AttemptCompletionCallbacks = {
568+
askApproval: mockAskApproval,
569+
handleError: mockHandleError,
570+
pushToolResult: mockPushToolResult,
571+
askFinishSubTaskApproval: mockAskFinishSubTaskApproval,
572+
toolDescription: mockToolDescription,
573+
}
574+
575+
await attemptCompletionTool.handle(mockTask as Task, block, callbacks)
576+
577+
expect(mockAskFinishSubTaskApproval).not.toHaveBeenCalled()
578+
expect(mockProvider.reopenParentFromDelegation).not.toHaveBeenCalled()
579+
expect(mockTask.ask).toHaveBeenCalledWith("completion_result", "", false)
580+
expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("child-1")
581+
})
582+
487583
it("emits TaskCompleted only when completion is accepted", async () => {
488584
const block: AttemptCompletionToolUse = {
489585
type: "tool_use",

src/core/webview/ClineProvider.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2970,11 +2970,6 @@ export class ClineProvider
29702970
awaitingChildId: undefined,
29712971
})
29722972

2973-
historyItem = {
2974-
...historyItem,
2975-
parentTaskId: undefined,
2976-
rootTaskId: undefined,
2977-
}
29782973
parentTask = undefined
29792974
rootTask = undefined
29802975
}

src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ describe("ClineProvider flicker-free cancel", () => {
317317
expect((provider as any).clineStack[1]).toBe(mockTask2)
318318
})
319319

320-
it("detaches a cancelled delegated child before rehydrating it", async () => {
320+
it("detaches runtime parent links for a cancelled delegated child while preserving history lineage", async () => {
321321
const mockRootTask = { taskId: "root-1" }
322322
const mockParentTask = { taskId: "parent-1" }
323323
const childHistory: HistoryItem = {
@@ -387,11 +387,71 @@ describe("ClineProvider flicker-free cancel", () => {
387387
expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith(
388388
expect.objectContaining({
389389
id: "child-1",
390-
parentTaskId: undefined,
391-
rootTaskId: undefined,
390+
parentTaskId: "parent-1",
391+
rootTaskId: "root-1",
392392
parentTask: undefined,
393393
rootTask: undefined,
394394
}),
395395
)
396396
})
397+
398+
it("continues rehydrating a cancelled child when delegated parent detach fails", async () => {
399+
const mockRootTask = { taskId: "root-1" }
400+
const mockParentTask = { taskId: "parent-1" }
401+
const childHistory: HistoryItem = {
402+
id: "child-1",
403+
number: 2,
404+
task: "child task",
405+
ts: Date.now(),
406+
tokensIn: 10,
407+
tokensOut: 20,
408+
totalCost: 0.001,
409+
workspace: "/test/workspace",
410+
parentTaskId: "parent-1",
411+
rootTaskId: "root-1",
412+
}
413+
414+
Object.assign(mockTask1, {
415+
taskId: "child-1",
416+
instanceId: "instance-child",
417+
rootTask: mockRootTask,
418+
parentTask: mockParentTask,
419+
parentTaskId: "parent-1",
420+
cancelCurrentRequest: vi.fn(),
421+
abortTask: vi.fn(),
422+
abandoned: false,
423+
isStreaming: false,
424+
didFinishAbortingStream: true,
425+
isWaitingForFirstChunk: false,
426+
})
427+
;(provider as any).clineStack = [mockTask1]
428+
provider.getTaskWithId = vi.fn().mockImplementation((id) => {
429+
if (id === "child-1") {
430+
return Promise.resolve({ historyItem: childHistory })
431+
}
432+
if (id === "parent-1") {
433+
return Promise.reject(new Error("parent lookup failed"))
434+
}
435+
throw new Error(`unexpected task lookup: ${id}`)
436+
}) as any
437+
438+
const createTaskWithHistoryItemSpy = vi
439+
.spyOn(provider, "createTaskWithHistoryItem")
440+
.mockResolvedValue(undefined as any)
441+
442+
await provider.cancelTask()
443+
444+
expect(mockOutputChannel.appendLine).toHaveBeenCalledWith(
445+
expect.stringContaining("[cancelTask] Failed to detach delegated parent for child-1: parent lookup failed"),
446+
)
447+
expect(createTaskWithHistoryItemSpy).toHaveBeenCalledWith(
448+
expect.objectContaining({
449+
id: "child-1",
450+
parentTaskId: "parent-1",
451+
rootTaskId: "root-1",
452+
parentTask: mockParentTask,
453+
rootTask: mockRootTask,
454+
}),
455+
)
456+
})
397457
})

0 commit comments

Comments
 (0)