Skip to content

Commit 9d94917

Browse files
authored
Merge branch 'main' into fix/rename-roo-to-zoo-user-strings
2 parents 1437130 + 78c1410 commit 9d94917

121 files changed

Lines changed: 4520 additions & 474 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ export const SUBTASK_API_HANG_RESUME_MESSAGE = "Continue after provider hang."
3232
export const SUBTASK_API_HANG_CHILD_RESULT = "Hung child completed"
3333
export const SUBTASK_API_HANG_PARENT_RESULT = "API hang parent resumed"
3434

35+
// Abandon-subtask scenario (#559) — separate markers to avoid sequenceIndex collisions with the
36+
// interrupted-child-resumes tests above, which exhaust the sequence count for INTERRUPT markers.
37+
const SUBTASK_ABANDON_PARENT_MARKER = "SUBTASK_PARENT_ABANDON_SEVER"
38+
const SUBTASK_ABANDON_CHILD_MARKER = "SUBTASK_CHILD_ABANDON_SEVER"
39+
const SUBTASK_ABANDON_CHILD_PROMPT = `${SUBTASK_ABANDON_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.`
40+
export const SUBTASK_ABANDON_PARENT_PROMPT = `${SUBTASK_ABANDON_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_ABANDON_CHILD_PROMPT}" Do not answer directly.`
41+
export const SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER = "9"
42+
3543
const SUBTASK_XPROFILE_SAME_CHILD_PROMPT = `${SUBTASK_XPROFILE_SAME_CHILD_MARKER}: Complete immediately with the exact result "Same-profile child completed".`
3644
const SUBTASK_XPROFILE_DIFFERENT_CHILD_PROMPT = `${SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER}: Complete immediately with the exact result "Different-profile child completed".`
3745
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.`
@@ -437,4 +445,67 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
437445
],
438446
},
439447
})
448+
449+
// Abandon-subtask scenario (#559)
450+
mock.addFixture({
451+
match: {
452+
userMessage: new RegExp(SUBTASK_ABANDON_PARENT_MARKER),
453+
sequenceIndex: 0,
454+
},
455+
response: {
456+
toolCalls: [
457+
{
458+
name: "new_task",
459+
arguments: JSON.stringify({
460+
mode: "ask",
461+
message: SUBTASK_ABANDON_CHILD_PROMPT,
462+
}),
463+
id: "call_abandon_parent_new_task_001",
464+
},
465+
],
466+
},
467+
})
468+
469+
mock.addFixture({
470+
match: {
471+
predicate: (req: ChatCompletionRequest) =>
472+
requestContains(req, [SUBTASK_ABANDON_CHILD_MARKER]) &&
473+
!requestContains(req, [SUBTASK_ABANDON_PARENT_MARKER]) &&
474+
!requestContains(req, ["call_abandon_child_followup_001"]) &&
475+
!requestContains(req, [`<user_message>\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`]),
476+
},
477+
response: {
478+
toolCalls: [
479+
{
480+
name: "ask_followup_question",
481+
arguments: JSON.stringify({
482+
question: "What is the square root of 81?",
483+
follow_up: [{ text: SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER }],
484+
}),
485+
id: "call_abandon_child_followup_001",
486+
},
487+
],
488+
},
489+
})
490+
491+
mock.addFixture({
492+
match: {
493+
predicate: (req: ChatCompletionRequest) =>
494+
toolResultContains(req, "call_abandon_child_followup_001", [SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER]) ||
495+
requestContains(req, ["call_abandon_child_followup_001", SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER]) ||
496+
requestContains(req, [
497+
SUBTASK_ABANDON_CHILD_MARKER,
498+
`<user_message>\\n${SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER}\\n</user_message>`,
499+
]),
500+
},
501+
response: {
502+
toolCalls: [
503+
{
504+
name: "attempt_completion",
505+
arguments: JSON.stringify({ result: SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER }),
506+
id: "call_abandon_child_completion_002",
507+
},
508+
],
509+
},
510+
})
440511
}

apps/vscode-e2e/src/suite/subtasks.test.ts

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
55
import { setDefaultSuiteTimeout } from "./test-utils"
66
import { sleep, waitFor, waitUntilCompleted } from "./utils"
77
import {
8-
SUBTASK_API_HANG_CHILD_RESULT,
8+
SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER,
9+
SUBTASK_ABANDON_PARENT_PROMPT,
910
SUBTASK_API_HANG_CHILD_MARKER,
11+
SUBTASK_API_HANG_CHILD_RESULT,
1012
SUBTASK_API_HANG_PARENT_MARKER,
1113
SUBTASK_API_HANG_PARENT_PROMPT,
1214
SUBTASK_API_HANG_PARENT_RESULT,
@@ -271,7 +273,10 @@ suite("Roo Code Subtasks", function () {
271273

272274
const parent = await api.getTaskHistoryItem(parentTaskId)
273275
assert.ok(parent, "Parent history item should exist")
274-
assert.strictEqual(parent.status, "active", "Parent status should be 'active' after child completes")
276+
assert.ok(
277+
parent.status === "active" || parent.status === "completed",
278+
`Parent status should be 'active' or 'completed' after child completes (got '${parent.status}')`,
279+
)
275280
assert.strictEqual(parent.awaitingChildId, undefined, "Parent awaitingChildId should be cleared")
276281
assert.strictEqual(parent.delegatedToId, undefined, "Parent delegatedToId should be cleared")
277282
assert.strictEqual(parent.completedByChildId, childTaskId, "Parent completedByChildId should be the child")
@@ -769,4 +774,165 @@ suite("Roo Code Subtasks", function () {
769774
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
770775
}
771776
})
777+
778+
// Issue #559: explicit "Abandon subtask" action. Unlike cancellation alone (which leaves
779+
// the child "interrupted" and the parent "delegated" so the child can still resume and
780+
// report back), abandoning severs the link outright: the parent goes back to "active" and
781+
// the child's parentTaskId/rootTaskId are cleared so a later resume can never reattach it.
782+
test("abandoning an interrupted subtask severs the parent-child link", async () => {
783+
const api = globalThis.api
784+
const asks: Record<string, ClineMessage[]> = {}
785+
const says: Record<string, ClineMessage[]> = {}
786+
787+
const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
788+
if (message.type === "ask") {
789+
asks[taskId] = asks[taskId] || []
790+
asks[taskId].push(message)
791+
}
792+
if (message.type === "say" && message.partial === false) {
793+
says[taskId] = says[taskId] || []
794+
says[taskId].push(message)
795+
}
796+
}
797+
798+
api.on(RooCodeEventName.Message, messageHandler)
799+
800+
try {
801+
const parentTaskId = await api.startNewTask({
802+
configuration: {
803+
mode: "ask",
804+
alwaysAllowModeSwitch: true,
805+
alwaysAllowSubtasks: true,
806+
autoApprovalEnabled: true,
807+
enableCheckpoints: false,
808+
},
809+
text: SUBTASK_ABANDON_PARENT_PROMPT,
810+
})
811+
812+
let childTaskId: string | undefined
813+
await waitFor(() => {
814+
const stack = api.getCurrentTaskStack()
815+
const current = stack[stack.length - 1]
816+
if (current && current !== parentTaskId) {
817+
childTaskId = current
818+
return true
819+
}
820+
return false
821+
})
822+
823+
await waitFor(() => asks[childTaskId!]?.some(({ ask }) => ask === "followup") ?? false)
824+
await waitFor(async () => (await api.getTaskApiConversationHistoryLength(childTaskId!)) > 0)
825+
826+
// Cancel the child — marked "interrupted", parent stays "delegated".
827+
await api.cancelCurrentTask()
828+
829+
await waitFor(() => api.getCurrentTaskStack().at(-1) === childTaskId)
830+
await waitFor(
831+
() => asks[childTaskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
832+
)
833+
834+
const interruptedChild = await api.getTaskHistoryItem(childTaskId!)
835+
assert.strictEqual(interruptedChild?.status, "interrupted", "Child should be marked interrupted")
836+
837+
const delegatedParent = await api.getTaskHistoryItem(parentTaskId)
838+
assert.strictEqual(delegatedParent?.status, "delegated", "Parent should still be delegated before abandon")
839+
assert.strictEqual(
840+
delegatedParent?.awaitingChildId,
841+
childTaskId,
842+
"Parent should await the interrupted child",
843+
)
844+
845+
// The interrupted child is the live/open task at this point (cancelTask rehydrates
846+
// it onto the stack). Abandon must close that live instance before severing the
847+
// persisted link — otherwise a later save on the still-open child would rebuild
848+
// parentTaskId/rootTaskId from its live (readonly) fields and silently reattach it.
849+
const abandoned = await api.abandonSubtask(childTaskId!)
850+
assert.strictEqual(abandoned, true, "abandonSubtask should report the link was severed")
851+
852+
await waitFor(() => api.getCurrentTaskStack().at(-1) !== childTaskId)
853+
854+
const parentAfterAbandon = await api.getTaskHistoryItem(parentTaskId)
855+
assert.strictEqual(parentAfterAbandon?.status, "active", "Parent should return to active after abandon")
856+
assert.strictEqual(
857+
parentAfterAbandon?.awaitingChildId,
858+
undefined,
859+
"Parent awaitingChildId should be cleared",
860+
)
861+
assert.strictEqual(parentAfterAbandon?.delegatedToId, undefined, "Parent delegatedToId should be cleared")
862+
863+
const childAfterAbandon = await api.getTaskHistoryItem(childTaskId!)
864+
// The child's own status is left untouched (VALID_TRANSITIONS only allows interrupted → completed);
865+
// only its parent/root links are cleared so it can never reattach to the parent again.
866+
assert.strictEqual(childAfterAbandon?.status, "interrupted", "Child status stays interrupted")
867+
assert.strictEqual(childAfterAbandon?.parentTaskId, undefined, "Child parentTaskId should be cleared")
868+
assert.strictEqual(childAfterAbandon?.rootTaskId, undefined, "Child rootTaskId should be cleared")
869+
870+
// A second abandon call is a no-op since the parent is no longer delegated to this child.
871+
const secondAbandon = await api.abandonSubtask(childTaskId!)
872+
assert.strictEqual(secondAbandon, false, "Second abandonSubtask call should be a no-op")
873+
874+
// Resume and complete the abandoned child — it must NOT reopen or reattach to the
875+
// parent. Before the abandon fix, a subsequent save on the still-live child could
876+
// silently rewrite its persisted parentTaskId back to the parent; this proves the
877+
// link stays severed all the way through a real resume/save/complete cycle.
878+
// api.resumeTask() re-instantiates the child from history, which re-raises its own
879+
// "resume_task" ask; answering it with the follow-up answer (same pattern the sibling
880+
// "cancelled child completes and reopens parent" test above uses) both resumes the
881+
// task and supplies the answer the re-asked follow-up question is waiting for.
882+
// asks[childTaskId] already holds the earlier resume_task ask from the pre-abandon
883+
// cancellation, so the wait below must look for a NEW one, not just any occurrence.
884+
const askCountBeforeResume = asks[childTaskId!]?.length ?? 0
885+
await api.resumeTask(childTaskId!)
886+
await waitFor(() =>
887+
(asks[childTaskId!] ?? [])
888+
.slice(askCountBeforeResume)
889+
.some(({ type, ask }) => type === "ask" && ask === "resume_task"),
890+
)
891+
892+
const completedChildTaskId = await waitUntilCompleted({
893+
api,
894+
start: async () => {
895+
await api.sendMessage(SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER)
896+
return childTaskId!
897+
},
898+
})
899+
900+
assert.strictEqual(
901+
completedChildTaskId,
902+
childTaskId,
903+
"The abandoned child itself should be the task that completes, not the parent",
904+
)
905+
assert.strictEqual(
906+
says[parentTaskId]?.find(({ say }) => say === "completion_result"),
907+
undefined,
908+
"Parent must never complete/reopen after its abandoned child resumes and completes",
909+
)
910+
911+
const parentAfterChildCompletes = await api.getTaskHistoryItem(parentTaskId)
912+
assert.strictEqual(
913+
parentAfterChildCompletes?.status,
914+
"active",
915+
"Parent status must remain untouched by the abandoned child's completion",
916+
)
917+
assert.strictEqual(
918+
parentAfterChildCompletes?.awaitingChildId,
919+
undefined,
920+
"Parent must not start awaiting the abandoned child again",
921+
)
922+
923+
const childAfterCompletion = await api.getTaskHistoryItem(childTaskId!)
924+
assert.strictEqual(
925+
childAfterCompletion?.parentTaskId,
926+
undefined,
927+
"Child parentTaskId must still be cleared after it completes on its own — " +
928+
"proves the live-instance save did not resurrect the old link",
929+
)
930+
} finally {
931+
api.off(RooCodeEventName.Message, messageHandler)
932+
while (api.getCurrentTaskStack().length > 0) {
933+
await api.clearCurrentTask()
934+
}
935+
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
936+
}
937+
})
772938
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {
2+
SECRET_STATE_KEYS,
3+
dynamicProviders,
4+
kimiCodeDefaultModelId,
5+
providerSettingsSchema,
6+
providerSettingsSchemaDiscriminated,
7+
} from "../index.js"
8+
9+
describe("Kimi Code provider types", () => {
10+
it("registers Kimi Code as a dynamic provider with a distinct secret", () => {
11+
expect(dynamicProviders).toContain("kimi-code")
12+
expect(SECRET_STATE_KEYS).toContain("kimiCodeApiKey")
13+
expect(SECRET_STATE_KEYS).toContain("moonshotApiKey")
14+
})
15+
16+
it("parses OAuth and API-key settings independently from Moonshot", () => {
17+
expect(
18+
providerSettingsSchemaDiscriminated.parse({
19+
apiProvider: "kimi-code",
20+
kimiCodeAuthMethod: "api-key",
21+
kimiCodeApiKey: "kimi-key",
22+
apiModelId: kimiCodeDefaultModelId,
23+
}),
24+
).toMatchObject({ kimiCodeApiKey: "kimi-key" })
25+
expect(providerSettingsSchema.parse({ apiProvider: "kimi-code", kimiCodeAuthMethod: "oauth" })).toMatchObject({
26+
kimiCodeAuthMethod: "oauth",
27+
})
28+
})
29+
})

packages/types/src/__tests__/lite-llm.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe("LiteLLM preserveReasoning model detection", () => {
1212
})
1313

1414
it("matches provider-prefixed routed model names by their final segment", () => {
15+
expect(isLiteLLMPreserveReasoningModel("kimi-k3")).toBe(true)
1516
expect(isLiteLLMPreserveReasoningModel("deepseek/deepseek-reasoner")).toBe(true)
1617
expect(isLiteLLMPreserveReasoningModel("bedrock/moonshot.kimi-k2-thinking")).toBe(true)
1718
expect(isLiteLLMPreserveReasoningModel("fireworks_ai/accounts/fireworks/models/kimi-k2p7-code")).toBe(true)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { ModelInfo } from "../model.js"
2+
import { MOONSHOT_DEFAULT_TEMPERATURE, moonshotDefaultModelId, moonshotModels } from "../providers/moonshot.js"
3+
4+
const modelEntries: [string, ModelInfo][] = Object.entries(moonshotModels)
5+
const modelInfos: ModelInfo[] = Object.values(moonshotModels)
6+
7+
describe("moonshot registry", () => {
8+
describe("moonshotModels registry invariants", () => {
9+
it("every entry has a positive maxTokens and contextWindow", () => {
10+
for (const [id, info] of modelEntries) {
11+
expect(info.maxTokens).toBeGreaterThan(0)
12+
expect(info.contextWindow).toBeGreaterThan(0)
13+
// Sanity: max output must not exceed the context window.
14+
expect(info.maxTokens).toBeLessThanOrEqual(info.contextWindow)
15+
void id
16+
}
17+
})
18+
19+
it("every entry declares supportsImages and supportsPromptCache", () => {
20+
for (const info of modelInfos) {
21+
expect(typeof info.supportsImages).toBe("boolean")
22+
expect(typeof info.supportsPromptCache).toBe("boolean")
23+
}
24+
})
25+
26+
it("models with an array supportsReasoningEffort expose a non-empty allow-list", () => {
27+
for (const info of modelInfos) {
28+
if (Array.isArray(info.supportsReasoningEffort)) {
29+
expect(info.supportsReasoningEffort.length).toBeGreaterThan(0)
30+
}
31+
}
32+
})
33+
34+
it("every entry declares a reasoningEffort that is covered by its allow-list", () => {
35+
for (const info of modelInfos) {
36+
if (Array.isArray(info.supportsReasoningEffort) && info.reasoningEffort !== undefined) {
37+
expect(info.supportsReasoningEffort).toContain(info.reasoningEffort)
38+
}
39+
}
40+
})
41+
})
42+
43+
describe("kimi-k3", () => {
44+
it("exposes always-on reasoning with effort allow-list and reasoning preservation", () => {
45+
const info = moonshotModels["kimi-k3"]
46+
expect(info).toBeDefined()
47+
expect(info.maxTokens).toBe(131_072)
48+
expect(info.contextWindow).toBe(1_048_576)
49+
expect(info.supportsImages).toBe(true)
50+
expect(info.supportsPromptCache).toBe(true)
51+
expect(info.supportsReasoningEffort).toEqual(["low", "high", "max"])
52+
expect(info.reasoningEffort).toBe("max")
53+
expect(info.preserveReasoning).toBe(true)
54+
expect(info.defaultTemperature).toBe(1.0)
55+
expect(info.inputPrice).toBe(3.0)
56+
expect(info.outputPrice).toBe(15.0)
57+
expect(info.cacheWritesPrice).toBe(0)
58+
expect(info.cacheReadsPrice).toBe(0.3)
59+
})
60+
})
61+
62+
describe("defaults", () => {
63+
it("the default model id is a curated registry entry", () => {
64+
expect(moonshotDefaultModelId).toBe("kimi-k2-0905-preview")
65+
expect(moonshotModels[moonshotDefaultModelId]).toBeDefined()
66+
})
67+
68+
it("exposes a deterministic default temperature", () => {
69+
expect(MOONSHOT_DEFAULT_TEMPERATURE).toBe(0.6)
70+
})
71+
})
72+
})

0 commit comments

Comments
 (0)