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
83 changes: 83 additions & 0 deletions apps/vscode-e2e/src/fixtures/subtasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const SUBTASK_PARENT_MARKER = "SUBTASK_PARENT_CANCELLATION_SMOKE"
const SUBTASK_CHILD_MARKER = "SUBTASK_CHILD_CALCULATOR_SMOKE"
const SUBTASK_INTERRUPT_PARENT_MARKER = "SUBTASK_PARENT_INTERRUPT_RESUME"
const SUBTASK_INTERRUPT_CHILD_MARKER = "SUBTASK_CHILD_INTERRUPT_RESUME"
export const SUBTASK_API_HANG_PARENT_MARKER = "SUBTASK_PARENT_API_HANG_INTERRUPT_RESUME"
export const SUBTASK_API_HANG_CHILD_MARKER = "SUBTASK_CHILD_API_HANG_INTERRUPT_RESUME"
const SUBTASK_FAST_PARENT_MARKER = "SUBTASK_PARENT_IMMEDIATE_COMPLETION"
const SUBTASK_FAST_CHILD_MARKER = "SUBTASK_CHILD_IMMEDIATE_COMPLETION"
const SUBTASK_XPROFILE_PARENT_MARKER = "SUBTASK_PARENT_CROSS_PROFILE"
Expand All @@ -24,13 +26,21 @@ export const SUBTASK_INTERRUPT_PARENT_PROMPT = `${SUBTASK_INTERRUPT_PARENT_MARKE
export const SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER = "9"
export const SUBTASK_INTERRUPT_PARENT_RESULT = "Interrupted parent resumed"

const SUBTASK_API_HANG_CHILD_PROMPT = `${SUBTASK_API_HANG_CHILD_MARKER}: Complete with the exact result "Hung child completed".`
export const SUBTASK_API_HANG_PARENT_PROMPT = `${SUBTASK_API_HANG_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_API_HANG_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "API hang parent resumed".`
export const SUBTASK_API_HANG_RESUME_MESSAGE = "Continue after provider hang."
export const SUBTASK_API_HANG_CHILD_RESULT = "Hung child completed"
export const SUBTASK_API_HANG_PARENT_RESULT = "API hang parent resumed"

const SUBTASK_XPROFILE_SAME_CHILD_PROMPT = `${SUBTASK_XPROFILE_SAME_CHILD_MARKER}: Complete immediately with the exact result "Same-profile child completed".`
const SUBTASK_XPROFILE_DIFFERENT_CHILD_PROMPT = `${SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER}: Complete immediately with the exact result "Different-profile child completed".`
export const SUBTASK_XPROFILE_PARENT_PROMPT = `${SUBTASK_XPROFILE_PARENT_MARKER}: First use new_task to create a code-mode subtask with this exact message: "${SUBTASK_XPROFILE_SAME_CHILD_PROMPT}" After it returns, create an ask-mode subtask with the next instructions you receive.`
export const SUBTASK_XPROFILE_SAME_CHILD_RESULT = "Same-profile child completed"
export const SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT = "Different-profile child completed"
export const SUBTASK_XPROFILE_PARENT_RESULT = "Sequential cross-profile parent resumed"

const apiHangChildMatch = new RegExp(SUBTASK_API_HANG_CHILD_MARKER)

const requestContains = (req: ChatCompletionRequest, expected: string[]) => {
const rawRequest = JSON.stringify(req)
return expected.every((text) => rawRequest.includes(text))
Expand Down Expand Up @@ -167,6 +177,79 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
},
})

mock.addFixture({
match: {
userMessage: new RegExp(SUBTASK_API_HANG_PARENT_MARKER),
sequenceIndex: 0,
},
response: {
toolCalls: [
{
name: "new_task",
arguments: JSON.stringify({
mode: "ask",
message: SUBTASK_API_HANG_CHILD_PROMPT,
}),
id: "call_api_hang_parent_new_task_001",
},
],
},
})

mock.addFixture({
match: {
userMessage: apiHangChildMatch,
sequenceIndex: 0,
},
// Keep the first child response pending long enough for the e2e test to cancel an in-flight API request.
latency: 15_000,
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: SUBTASK_API_HANG_CHILD_RESULT }),
id: "call_api_hang_child_completion_002",
},
],
},
})

mock.addFixture({
match: {
userMessage: apiHangChildMatch,
sequenceIndex: 1,
},
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: SUBTASK_API_HANG_CHILD_RESULT }),
id: "call_api_hang_child_completion_003",
},
],
},
})

mock.addFixture({
match: {
predicate: (req: ChatCompletionRequest) =>
requestContains(req, [
SUBTASK_API_HANG_PARENT_MARKER,
"call_api_hang_parent_new_task_001",
SUBTASK_API_HANG_CHILD_RESULT,
]),
},
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: SUBTASK_API_HANG_PARENT_RESULT }),
id: "call_api_hang_parent_completion_004",
},
],
},
})

// Issue #457 sequence: a same-profile child returns first, then the resumed
// parent delegates to a child whose mode uses a different API profile.
mock.addFixture({
Expand Down
152 changes: 152 additions & 0 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
import { setDefaultSuiteTimeout } from "./test-utils"
import { sleep, waitFor, waitUntilCompleted } from "./utils"
import {
SUBTASK_API_HANG_CHILD_RESULT,
SUBTASK_API_HANG_CHILD_MARKER,
SUBTASK_API_HANG_PARENT_MARKER,
SUBTASK_API_HANG_PARENT_PROMPT,
SUBTASK_API_HANG_PARENT_RESULT,
SUBTASK_API_HANG_RESUME_MESSAGE,
SUBTASK_CHILD_FOLLOWUP_ANSWER,
SUBTASK_FAST_PARENT_PROMPT,
SUBTASK_INTERRUPT_CHILD_FOLLOWUP_ANSWER,
Expand All @@ -17,6 +23,45 @@ import {
SUBTASK_XPROFILE_SAME_CHILD_RESULT,
} from "../fixtures/subtasks"

type AimockMessageContent = string | Array<{ type?: string; text?: string }>

type AimockJournalEntry = {
body?: {
messages?: Array<{
role?: string
content?: AimockMessageContent
}>
}
}

const messageContentText = (content?: AimockMessageContent) => {
if (typeof content === "string") {
return content
}

return content?.map((part) => part.text ?? "").join("") ?? ""
}

const waitForAimockRequestContaining = async (expectedText: string, excludeText?: string) => {
const aimockUrl = process.env.AIMOCK_URL
assert.ok(aimockUrl, "AIMOCK_URL must be set for aimock journal assertions")

await waitFor(async () => {
const response = await fetch(`${aimockUrl}/__aimock/journal`)
const entries = (await response.json()) as AimockJournalEntry[]

return entries.some((entry) => {
const messages = entry.body?.messages
if (!messages) return false
const entryText = messages.map((m) => messageContentText(m.content)).join("")
if (excludeText && entryText.includes(excludeText)) return false
return messages.some(
(message) => message.role === "user" && messageContentText(message.content).includes(expectedText),
)
})
})
}

suite("Roo Code Subtasks", function () {
setDefaultSuiteTimeout(this)

Expand Down Expand Up @@ -412,6 +457,113 @@ suite("Roo Code Subtasks", function () {
}
})

// Issue #566: a child interrupted while its provider request is still pending
// must keep its parent link when manually resumed and completed.
test("API-hung interrupted child resumes and returns to parent", async () => {
const api = globalThis.api
const asks: Record<string, ClineMessage[]> = {}
const says: Record<string, ClineMessage[]> = {}

const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
if (message.type === "ask") {
asks[taskId] = asks[taskId] || []
asks[taskId].push(message)
}
if (message.type === "say" && message.partial === false) {
says[taskId] = says[taskId] || []
says[taskId].push(message)
}
}

api.on(RooCodeEventName.Message, messageHandler)

try {
const parentTaskId = await api.startNewTask({
configuration: {
mode: "ask",
alwaysAllowModeSwitch: true,
alwaysAllowSubtasks: true,
autoApprovalEnabled: true,
enableCheckpoints: false,
},
text: SUBTASK_API_HANG_PARENT_PROMPT,
})

let childTaskId: string | undefined
await waitFor(() => {
const stack = api.getCurrentTaskStack()
const current = stack[stack.length - 1]
if (current && current !== parentTaskId) {
childTaskId = current
return true
}
return false
})

await waitForAimockRequestContaining(SUBTASK_API_HANG_CHILD_MARKER, SUBTASK_API_HANG_PARENT_MARKER)

await api.cancelCurrentTask()

await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId)
await waitFor(
() => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
)

const interruptedChild = await api.getTaskHistoryItem(childTaskId!)
assert.strictEqual(interruptedChild?.status, "interrupted", "Child should be interrupted after manual stop")
assert.strictEqual(
interruptedChild?.parentTaskId,
parentTaskId,
"Interrupted child should retain its parent link before resume",
)

const completedParentTaskId = await waitUntilCompleted({
api,
start: async () => {
await api.sendMessage(SUBTASK_API_HANG_RESUME_MESSAGE)
return parentTaskId
},
})

assert.strictEqual(
completedParentTaskId,
parentTaskId,
"Parent task should complete after API-hung child resumes and reports back",
)
assert.strictEqual(
says[childTaskId!]
?.filter(({ say }) => say === "completion_result")
.map(({ text }) => text?.trim())
.find((text) => text === SUBTASK_API_HANG_CHILD_RESULT),
SUBTASK_API_HANG_CHILD_RESULT,
"Child should complete with its expected result after resume",
)
assert.strictEqual(
says[parentTaskId]
?.filter(({ say }) => say === "completion_result")
.map(({ text }) => text?.trim())
.find((text) => text === SUBTASK_API_HANG_PARENT_RESULT),
SUBTASK_API_HANG_PARENT_RESULT,
"Parent should resume and complete with its expected result",
)

const parent = await api.getTaskHistoryItem(parentTaskId)
assert.notStrictEqual(parent?.status, "delegated", "Parent history should not remain delegated")
assert.strictEqual(parent?.awaitingChildId, undefined, "Parent awaitingChildId should be cleared")
assert.strictEqual(parent?.completedByChildId, childTaskId, "Parent should record completed child")

const child = await api.getTaskHistoryItem(childTaskId!)
assert.strictEqual(child?.status, "completed", "Child history should be completed")
assert.strictEqual(child?.parentTaskId, parentTaskId, "Completed child should still point to parent")
} finally {
api.off(RooCodeEventName.Message, messageHandler)
while (api.getCurrentTaskStack().length > 0) {
await api.clearCurrentTask()
}
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
}
})

test("same-profile child returns before a different-profile child", async () => {
const api = globalThis.api
const says: Record<string, ClineMessage[]> = {}
Expand Down
Loading
Loading