Skip to content

Commit 4c5c41d

Browse files
committed
test(vscode-e2e): cover orchestrator cancellation recovery
1 parent 3182f4f commit 4c5c41d

4 files changed

Lines changed: 419 additions & 0 deletions

File tree

apps/vscode-e2e/src/fixtures/orchestrator-plan.spec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"
33
import {
44
ORCHESTRATOR_FAN_OUT_CHILD_STEPS,
55
ORCHESTRATOR_FAN_OUT_FINAL_RESULT,
6+
ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP,
7+
ORCHESTRATOR_CANCELLATION_RECOVERY_FINAL_RESULT,
8+
ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER,
9+
ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER,
10+
ORCHESTRATOR_CANCELLATION_RECOVERY_PARENT_PROMPT,
611
ORCHESTRATOR_FAN_OUT_PARENT_PROMPT,
712
ORCHESTRATOR_NESTED_DELEGATION_CHILD_FINAL_RESULT,
813
ORCHESTRATOR_NESTED_DELEGATION_CHILD_ORCHESTRATOR_STEP,
@@ -14,10 +19,14 @@ import {
1419
ORCHESTRATOR_REPEATED_DELEGATION_FINAL_RESULT,
1520
ORCHESTRATOR_REPEATED_DELEGATION_MARKER,
1621
ORCHESTRATOR_REPEATED_DELEGATION_PARENT_PROMPT,
22+
buildOrchestratorCancellationRecoveryResumeExpectations,
1723
buildOrchestratorNestedChildResumeExpectations,
1824
buildOrchestratorNestedParentResumeExpectations,
1925
buildOrchestratorRepeatedResumeExpectations,
2026
buildOrchestratorResumeExpectations,
27+
shouldMatchOrchestratorCancellationChildCompletionRequest,
28+
shouldMatchOrchestratorCancellationChildRequest,
29+
shouldMatchOrchestratorCancellationRecoveryResumeRequest,
2130
shouldMatchOrchestratorChildRequest,
2231
shouldMatchOrchestratorNestedChildResumeRequest,
2332
shouldMatchOrchestratorNestedParentResumeRequest,
@@ -276,4 +285,84 @@ describe("orchestrator fan-out delegation plan", () => {
276285
}
277286
expect(ORCHESTRATOR_NESTED_DELEGATION_FINAL_RESULT).toContain(ORCHESTRATOR_NESTED_DELEGATION_CHILD_FINAL_RESULT)
278287
})
288+
289+
it("defines a cancellation recovery parent/child plan with a pending ask child", () => {
290+
expect(ORCHESTRATOR_CANCELLATION_RECOVERY_PARENT_PROMPT).toContain(ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER)
291+
expect(ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP).toEqual(
292+
expect.objectContaining({
293+
mode: "ask",
294+
role: "cancellation-child",
295+
summary: "Cancellation recovery child summary: resumed after interruption.",
296+
}),
297+
)
298+
expect(ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.prompt).toContain("Ask the user exactly")
299+
expect(ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.prompt).toContain(
300+
ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER,
301+
)
302+
})
303+
304+
it("matches cancellation child requests without colliding with other orchestrator scenarios", () => {
305+
expect(
306+
shouldMatchOrchestratorCancellationChildRequest(
307+
`child ${ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.marker}`,
308+
),
309+
).toBe(true)
310+
expect(shouldMatchOrchestratorCancellationChildRequest(ORCHESTRATOR_CANCELLATION_RECOVERY_PARENT_PROMPT)).toBe(
311+
false,
312+
)
313+
expect(
314+
shouldMatchOrchestratorCancellationChildRequest(
315+
`${ORCHESTRATOR_NESTED_DELEGATION_MARKER} embeds ${ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.marker}`,
316+
),
317+
).toBe(false)
318+
})
319+
320+
it("matches cancellation child completion only after the recovery answer", () => {
321+
expect(
322+
shouldMatchOrchestratorCancellationChildCompletionRequest(
323+
`${ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.marker} call_orchestrator_cancellation_child_followup_001 ${ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER}`,
324+
),
325+
).toBe(true)
326+
expect(
327+
shouldMatchOrchestratorCancellationChildCompletionRequest(
328+
`${ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.marker} without the answer`,
329+
),
330+
).toBe(false)
331+
expect(
332+
shouldMatchOrchestratorCancellationChildCompletionRequest(
333+
`${ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER} call_cancellation_child_followup_001 ${ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER}`,
334+
),
335+
).toBe(false)
336+
})
337+
338+
it("matches cancellation recovery parent resumes only after child result injection", () => {
339+
const expectations = buildOrchestratorCancellationRecoveryResumeExpectations()
340+
expect(expectations).toEqual([
341+
{
342+
stepIndex: 1,
343+
requiredSummaries: [ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.summary],
344+
nextMode: undefined,
345+
},
346+
])
347+
348+
const parentResumeRequest = `${ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER} completed.\\n\\nResult:\\n${ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.summary}`
349+
expect(
350+
shouldMatchOrchestratorCancellationRecoveryResumeRequest(parentResumeRequest, [
351+
ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.summary,
352+
]),
353+
).toBe(true)
354+
expect(
355+
shouldMatchOrchestratorCancellationRecoveryResumeRequest(
356+
`${ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER} completed.\\n\\nResult:\\nmissing`,
357+
[ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.summary],
358+
),
359+
).toBe(false)
360+
})
361+
362+
it("composes cancellation recovery final result with an explicit cancellation marker", () => {
363+
expect(ORCHESTRATOR_CANCELLATION_RECOVERY_FINAL_RESULT).toContain("cancellation recovery")
364+
expect(ORCHESTRATOR_CANCELLATION_RECOVERY_FINAL_RESULT).toContain(
365+
ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.summary,
366+
)
367+
})
279368
})

apps/vscode-e2e/src/fixtures/orchestrator-plan.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type OrchestratorFanOutChildStep = {
1111

1212
export type OrchestratorRepeatedDelegationRole = "requirements" | "design" | "implementation"
1313
export type OrchestratorNestedDelegationRole = "child-orchestrator" | "requirements" | "implementation"
14+
export type OrchestratorCancellationRecoveryRole = "cancellation-child"
1415

1516
export type OrchestratorRepeatedDelegationChildStep = OrchestratorFanOutChildStep & {
1617
readonly round: number
@@ -21,6 +22,11 @@ export type OrchestratorNestedDelegationStep = OrchestratorFanOutChildStep & {
2122
readonly role: OrchestratorNestedDelegationRole
2223
}
2324

25+
export type OrchestratorCancellationRecoveryStep = OrchestratorFanOutChildStep & {
26+
readonly role: OrchestratorCancellationRecoveryRole
27+
readonly followupAnswer: string
28+
}
29+
2430
export type OrchestratorResumeExpectation = {
2531
readonly stepIndex: number
2632
readonly requiredSummaries: string[]
@@ -31,6 +37,7 @@ export const ORCHESTRATOR_FAN_OUT_MARKER = "ORCHESTRATOR_SINGLE_ROUND_FAN_OUT"
3137
export const ORCHESTRATOR_FAN_OUT_RESULT_INJECTION = "completed.\\n\\nResult:"
3238
export const ORCHESTRATOR_REPEATED_DELEGATION_MARKER = "ORCHESTRATOR_REPEATED_DELEGATION_STRESS"
3339
export const ORCHESTRATOR_NESTED_DELEGATION_MARKER = "ORCHESTRATOR_NESTED_DELEGATION"
40+
export const ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER = "ORCHESTRATOR_CANCELLATION_RECOVERY"
3441

3542
export const ORCHESTRATOR_FAN_OUT_CHILD_STEPS: readonly OrchestratorFanOutChildStep[] = [
3643
{
@@ -158,6 +165,23 @@ export const ORCHESTRATOR_NESTED_DELEGATION_PARENT_PROMPT = `${ORCHESTRATOR_NEST
158165

159166
export const ORCHESTRATOR_NESTED_DELEGATION_FINAL_RESULT = `Nested top-level orchestrator complete:\n- B: ${ORCHESTRATOR_NESTED_DELEGATION_CHILD_FINAL_RESULT}`
160167

168+
export const ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER = "resume recovered cancellation child"
169+
170+
export const ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP: OrchestratorCancellationRecoveryStep = {
171+
role: "cancellation-child",
172+
mode: "ask",
173+
marker: "ORCH_CANCEL_RECOVERY_CHILD",
174+
prompt: `ORCH_CANCEL_RECOVERY_CHILD: Ask the user exactly this follow-up question: Type "${ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER}" to recover the cancelled child. After the user answers, complete with the exact result "Cancellation recovery child summary: resumed after interruption."`,
175+
summary: "Cancellation recovery child summary: resumed after interruption.",
176+
followupAnswer: ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER,
177+
newTaskToolCallId: "call_orchestrator_cancellation_parent_new_task_001",
178+
completionToolCallId: "call_orchestrator_cancellation_child_completion_002",
179+
}
180+
181+
export const ORCHESTRATOR_CANCELLATION_RECOVERY_PARENT_PROMPT = `${ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER}: Delegate exactly one ask-mode child B with this exact message: "${ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.prompt}". Wait for B to return; do not complete before B returns. After B returns, complete with a final cancellation recovery summary containing B's result.`
182+
183+
export const ORCHESTRATOR_CANCELLATION_RECOVERY_FINAL_RESULT = `Orchestrator cancellation recovery complete:\n- B: ${ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.summary}`
184+
161185
function buildResumeExpectations(steps: readonly OrchestratorFanOutChildStep[]): OrchestratorResumeExpectation[] {
162186
return steps.map((_step, index) => ({
163187
stepIndex: index + 1,
@@ -182,15 +206,55 @@ export function buildOrchestratorNestedParentResumeExpectations(): OrchestratorR
182206
return buildResumeExpectations([ORCHESTRATOR_NESTED_DELEGATION_CHILD_ORCHESTRATOR_STEP])
183207
}
184208

209+
export function buildOrchestratorCancellationRecoveryResumeExpectations(): OrchestratorResumeExpectation[] {
210+
return buildResumeExpectations([ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP])
211+
}
212+
185213
export function shouldMatchOrchestratorChildRequest(rawRequest: string, childMarker: string): boolean {
186214
return (
187215
rawRequest.includes(childMarker) &&
188216
!rawRequest.includes(ORCHESTRATOR_FAN_OUT_MARKER) &&
189217
!rawRequest.includes(ORCHESTRATOR_REPEATED_DELEGATION_MARKER) &&
190218
!rawRequest.includes(ORCHESTRATOR_NESTED_DELEGATION_MARKER) &&
219+
!rawRequest.includes(ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER) &&
191220
!rawRequest.includes(ORCHESTRATOR_NESTED_DELEGATION_CHILD_ORCHESTRATOR_STEP.marker)
192221
)
193222
}
223+
224+
function shouldMatchOrchestratorCancellationChildBase(rawRequest: string): boolean {
225+
return (
226+
rawRequest.includes(ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.marker) &&
227+
!rawRequest.includes(`${ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER}: Delegate exactly one ask-mode child B`) &&
228+
!rawRequest.includes(ORCHESTRATOR_FAN_OUT_MARKER) &&
229+
!rawRequest.includes(ORCHESTRATOR_REPEATED_DELEGATION_MARKER) &&
230+
!rawRequest.includes(ORCHESTRATOR_NESTED_DELEGATION_MARKER)
231+
)
232+
}
233+
234+
export function shouldMatchOrchestratorCancellationChildRequest(rawRequest: string): boolean {
235+
const followupToolCallId = ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.completionToolCallId.replace(
236+
"completion_002",
237+
"followup_001",
238+
)
239+
240+
return shouldMatchOrchestratorCancellationChildBase(rawRequest) && !rawRequest.includes(followupToolCallId)
241+
}
242+
243+
export function shouldMatchOrchestratorCancellationChildCompletionRequest(rawRequest: string): boolean {
244+
const followupToolCallId = ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.completionToolCallId.replace(
245+
"completion_002",
246+
"followup_001",
247+
)
248+
249+
return (
250+
shouldMatchOrchestratorCancellationChildBase(rawRequest) &&
251+
((rawRequest.includes(followupToolCallId) &&
252+
rawRequest.includes(ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER)) ||
253+
rawRequest.includes(
254+
`<user_message>\\n${ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER}\\n</user_message>`,
255+
))
256+
)
257+
}
194258
function requestContainsResultSummary(rawRequest: string, summary: string): boolean {
195259
const jsonEscapedSummary = summary.replaceAll("\n", "\\n")
196260

@@ -240,3 +304,14 @@ export function shouldMatchOrchestratorNestedParentResumeRequest(
240304
requiredSummaries.every((summary) => requestContainsResultSummary(rawRequest, summary))
241305
)
242306
}
307+
308+
export function shouldMatchOrchestratorCancellationRecoveryResumeRequest(
309+
rawRequest: string,
310+
requiredSummaries: readonly string[],
311+
): boolean {
312+
return (
313+
rawRequest.includes(ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER) &&
314+
rawRequest.includes(ORCHESTRATOR_FAN_OUT_RESULT_INJECTION) &&
315+
requiredSummaries.every((summary) => requestContainsResultSummary(rawRequest, summary))
316+
)
317+
}

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { LLMock } from "@copilotkit/aimock"
22
import type { ChatCompletionRequest } from "@copilotkit/aimock"
33

44
import {
5+
ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP,
6+
ORCHESTRATOR_CANCELLATION_RECOVERY_FINAL_RESULT,
7+
ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER,
8+
ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER,
9+
ORCHESTRATOR_CANCELLATION_RECOVERY_PARENT_PROMPT,
510
ORCHESTRATOR_FAN_OUT_CHILD_STEPS,
611
ORCHESTRATOR_FAN_OUT_FINAL_RESULT,
712
ORCHESTRATOR_FAN_OUT_MARKER,
@@ -17,10 +22,14 @@ import {
1722
ORCHESTRATOR_REPEATED_DELEGATION_FINAL_RESULT,
1823
ORCHESTRATOR_REPEATED_DELEGATION_MARKER,
1924
ORCHESTRATOR_REPEATED_DELEGATION_PARENT_PROMPT,
25+
buildOrchestratorCancellationRecoveryResumeExpectations,
2026
buildOrchestratorNestedChildResumeExpectations,
2127
buildOrchestratorNestedParentResumeExpectations,
2228
buildOrchestratorRepeatedResumeExpectations,
2329
buildOrchestratorResumeExpectations,
30+
shouldMatchOrchestratorCancellationChildCompletionRequest,
31+
shouldMatchOrchestratorCancellationChildRequest,
32+
shouldMatchOrchestratorCancellationRecoveryResumeRequest,
2433
shouldMatchOrchestratorChildRequest,
2534
shouldMatchOrchestratorNestedChildResumeRequest,
2635
shouldMatchOrchestratorNestedParentResumeRequest,
@@ -29,6 +38,11 @@ import {
2938
} from "./orchestrator-plan"
3039

3140
export {
41+
ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP,
42+
ORCHESTRATOR_CANCELLATION_RECOVERY_FINAL_RESULT,
43+
ORCHESTRATOR_CANCELLATION_RECOVERY_FOLLOWUP_ANSWER,
44+
ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER,
45+
ORCHESTRATOR_CANCELLATION_RECOVERY_PARENT_PROMPT,
3246
ORCHESTRATOR_FAN_OUT_CHILD_STEPS,
3347
ORCHESTRATOR_FAN_OUT_FINAL_RESULT,
3448
ORCHESTRATOR_FAN_OUT_MARKER,
@@ -44,10 +58,14 @@ export {
4458
ORCHESTRATOR_REPEATED_DELEGATION_FINAL_RESULT,
4559
ORCHESTRATOR_REPEATED_DELEGATION_MARKER,
4660
ORCHESTRATOR_REPEATED_DELEGATION_PARENT_PROMPT,
61+
buildOrchestratorCancellationRecoveryResumeExpectations,
4762
buildOrchestratorNestedChildResumeExpectations,
4863
buildOrchestratorNestedParentResumeExpectations,
4964
buildOrchestratorRepeatedResumeExpectations,
5065
buildOrchestratorResumeExpectations,
66+
shouldMatchOrchestratorCancellationChildCompletionRequest,
67+
shouldMatchOrchestratorCancellationChildRequest,
68+
shouldMatchOrchestratorCancellationRecoveryResumeRequest,
5169
shouldMatchOrchestratorChildRequest,
5270
shouldMatchOrchestratorNestedChildResumeRequest,
5371
shouldMatchOrchestratorNestedParentResumeRequest,
@@ -303,4 +321,79 @@ export function addOrchestratorFixtures(mock: InstanceType<typeof LLMock>) {
303321
},
304322
})
305323
}
324+
325+
mock.addFixture({
326+
match: {
327+
userMessage: new RegExp(ORCHESTRATOR_CANCELLATION_RECOVERY_MARKER),
328+
sequenceIndex: 0,
329+
},
330+
response: {
331+
toolCalls: [
332+
{
333+
name: "new_task",
334+
arguments: JSON.stringify({
335+
mode: ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.mode,
336+
message: ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.prompt,
337+
}),
338+
id: ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.newTaskToolCallId,
339+
},
340+
],
341+
},
342+
})
343+
344+
mock.addFixture({
345+
match: {
346+
predicate: (req: ChatCompletionRequest) =>
347+
shouldMatchOrchestratorCancellationChildRequest(requestText(req)),
348+
},
349+
response: {
350+
toolCalls: [
351+
{
352+
name: "ask_followup_question",
353+
arguments: JSON.stringify({
354+
question: `Type "${ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.followupAnswer}" to recover the cancelled child.`,
355+
follow_up: [{ text: ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.followupAnswer }],
356+
}),
357+
id: "call_orchestrator_cancellation_child_followup_001",
358+
},
359+
],
360+
},
361+
})
362+
363+
mock.addFixture({
364+
match: {
365+
predicate: (req: ChatCompletionRequest) =>
366+
shouldMatchOrchestratorCancellationChildCompletionRequest(requestText(req)),
367+
},
368+
response: {
369+
toolCalls: [
370+
{
371+
name: "attempt_completion",
372+
arguments: JSON.stringify({ result: ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.summary }),
373+
id: ORCHESTRATOR_CANCELLATION_RECOVERY_CHILD_STEP.completionToolCallId,
374+
},
375+
],
376+
},
377+
})
378+
379+
for (const expectation of [...buildOrchestratorCancellationRecoveryResumeExpectations()].reverse()) {
380+
mock.addFixture({
381+
match: {
382+
predicate: (req: ChatCompletionRequest) =>
383+
shouldMatchOrchestratorCancellationRecoveryResumeRequest(
384+
requestText(req),
385+
expectation.requiredSummaries,
386+
),
387+
},
388+
response: {
389+
toolCalls: [
390+
{
391+
name: "attempt_completion",
392+
arguments: JSON.stringify({ result: ORCHESTRATOR_CANCELLATION_RECOVERY_FINAL_RESULT }),
393+
id: "call_orchestrator_cancellation_parent_completion_002",
394+
},
395+
],
396+
},
397+
})
398+
}
306399
}

0 commit comments

Comments
 (0)