Skip to content

Commit 218d777

Browse files
authored
Merge pull request #1 from DScoNOIZ/fix/issue-457-delegation-return
fix: prevent parent task from hanging when subtask delegation returns after per-mode API profile switch
2 parents eff686e + b2a8c5c commit 218d777

114 files changed

Lines changed: 5967 additions & 874 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.

PRIVACY.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,6 @@ go—and, importantly, where they don't.
4040
We retain telemetry only as long as needed for product analytics and debugging.
4141
Telemetry does **not** collect your code or AI prompts, and you can opt out at
4242
any time through the settings.
43-
- **Zoo Code Observability (Authenticated Subscribers Only):** If you sign in to
44-
Zoo Code and have an active subscription, Zoo Code will send LLM usage
45-
telemetry to the Zoo Code backend (zoocode.dev). This includes task ID, AI
46-
provider name, model name, token counts (input/output/cache), and estimated
47-
cost. This data is linked to your authenticated Zoo Code account. You can stop
48-
this collection at any time by signing out via the Zoo Code badge in the chat
49-
area.
5043
- **Marketplace Requests**: When you browse or search the Marketplace for Model
5144
Configuration Profiles (MCPs) or Custom Modes, Zoo Code makes a secure API
5245
call to Zoo Code's backend servers to retrieve listing information. These
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "TERMINAL_PROFILE_E2E_OVERRIDE"
6+
},
7+
"response": {
8+
"toolCalls": [
9+
{
10+
"name": "execute_command",
11+
"arguments": "{\"command\":\"printf 'zoo-profile-override-ok\\\\n' > terminal-profile-e2e/terminal-profile-override.txt\"}",
12+
"id": "call_terminal_profile_override_001"
13+
}
14+
]
15+
}
16+
},
17+
{
18+
"match": {
19+
"userMessage": "TERMINAL_PROFILE_E2E_DEFAULT"
20+
},
21+
"response": {
22+
"toolCalls": [
23+
{
24+
"name": "execute_command",
25+
"arguments": "{\"command\":\"printf 'zoo-profile-default-ok\\\\n' > terminal-profile-e2e/terminal-profile-default.txt\"}",
26+
"id": "call_terminal_profile_default_001"
27+
}
28+
]
29+
}
30+
}
31+
]
32+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { LLMock } from "@copilotkit/aimock"
2+
3+
import { toolResultContains } from "./tool-result"
4+
5+
type TerminalProfileToolCall = {
6+
name: "execute_command" | "attempt_completion"
7+
params: Record<string, unknown>
8+
id: string
9+
}
10+
11+
type TerminalProfileFixture = {
12+
toolCallId: string
13+
expected: string[]
14+
toolCalls: TerminalProfileToolCall[]
15+
}
16+
17+
export function addTerminalProfileResultFixtures(mock: InstanceType<typeof LLMock>) {
18+
const fixtures: TerminalProfileFixture[] = [
19+
{
20+
toolCallId: "call_terminal_profile_override_001",
21+
expected: ["Exit code: 0"],
22+
toolCalls: [
23+
{
24+
name: "attempt_completion",
25+
params: { result: "Ran the command using the Zoo E2E Bash profile override." },
26+
id: "call_terminal_profile_override_002",
27+
},
28+
],
29+
},
30+
{
31+
toolCallId: "call_terminal_profile_default_001",
32+
expected: ["Exit code: 0"],
33+
toolCalls: [
34+
{
35+
name: "attempt_completion",
36+
params: { result: "Ran the command using the default terminal profile." },
37+
id: "call_terminal_profile_default_002",
38+
},
39+
],
40+
},
41+
]
42+
43+
for (const fixture of fixtures) {
44+
mock.addFixture({
45+
match: {
46+
toolCallId: fixture.toolCallId,
47+
predicate: (req) => toolResultContains(req, fixture.toolCallId, fixture.expected),
48+
},
49+
response: {
50+
toolCalls: fixture.toolCalls.map((toolCall) => ({
51+
name: toolCall.name,
52+
arguments: JSON.stringify(toolCall.params),
53+
id: toolCall.id,
54+
})),
55+
},
56+
})
57+
}
58+
}

apps/vscode-e2e/src/runTest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { LLMock } from "@copilotkit/aimock"
77

88
import { addApplyDiffResultFixtures } from "./fixtures/apply-diff"
99
import { addExecuteCommandResultFixtures } from "./fixtures/execute-command"
10+
import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile"
1011
import { addListFilesResultFixtures } from "./fixtures/list-files"
1112
import { addReadFileResultFixtures } from "./fixtures/read-file"
1213
import { addSearchFilesResultFixtures } from "./fixtures/search-files"
@@ -108,6 +109,7 @@ async function main() {
108109
if (!isRecord) {
109110
addApplyDiffResultFixtures(mock)
110111
addExecuteCommandResultFixtures(mock)
112+
addTerminalProfileResultFixtures(mock)
111113
addListFilesResultFixtures(mock)
112114
addReadFileResultFixtures(mock)
113115
addSearchFilesResultFixtures(mock)
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/**
2+
* Linux-only e2e smoke test for the VS Code terminal profile override.
3+
*
4+
* Proves that:
5+
* 1. Setting a profile override causes commands to run through the selected
6+
* VS Code integrated-terminal shell without a shell_integration_warning.
7+
* 2. Clearing the override starts a fresh terminal on the next command.
8+
*
9+
* Windows profile coverage (cmd.exe fast-path, PowerShell) is proven by unit
10+
* tests in src/integrations/terminal/__tests__/. This test requires /bin/bash
11+
* which only exists on Linux/macOS.
12+
*/
13+
import * as assert from "assert"
14+
import * as fs from "fs/promises"
15+
import * as path from "path"
16+
import * as vscode from "vscode"
17+
18+
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
19+
20+
import { sleep, waitUntilCompleted } from "../utils"
21+
import { setDefaultSuiteTimeout } from "../test-utils"
22+
23+
const TEST_DIR_NAME = "terminal-profile-e2e"
24+
const OVERRIDE_FILE = "terminal-profile-override.txt"
25+
const DEFAULT_FILE = "terminal-profile-default.txt"
26+
const PROFILE_NAME = "Zoo E2E Bash"
27+
28+
suite("Terminal Profile", function () {
29+
if (process.platform !== "linux") {
30+
return
31+
}
32+
33+
setDefaultSuiteTimeout(this)
34+
35+
let workspaceDir: string
36+
let testDir: string
37+
let originalProfiles: Record<string, unknown> | undefined
38+
39+
suiteSetup(async () => {
40+
const aimockUrl = process.env.AIMOCK_URL
41+
const isRecord = process.env.AIMOCK_RECORD === "true"
42+
43+
await globalThis.api.setConfiguration({
44+
apiProvider: "openrouter" as const,
45+
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
46+
openRouterModelId: "anthropic/claude-sonnet-4.5",
47+
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
48+
})
49+
50+
const workspaceFolders = vscode.workspace.workspaceFolders
51+
if (!workspaceFolders?.length) throw new Error("No workspace folder found")
52+
workspaceDir = workspaceFolders[0]!.uri.fsPath
53+
testDir = path.join(workspaceDir, TEST_DIR_NAME)
54+
await fs.rm(testDir, { recursive: true, force: true })
55+
await fs.mkdir(testDir, { recursive: true })
56+
57+
// Save the current global linux profiles so we can restore them in teardown.
58+
originalProfiles = vscode.workspace
59+
.getConfiguration("terminal.integrated.profiles")
60+
.inspect<Record<string, unknown>>("linux")?.globalValue
61+
62+
// Write the test profile to VS Code user (global) settings.
63+
// Terminal.getConfiguredProfiles() intentionally excludes workspace settings
64+
// for security, so global scope is required here.
65+
await vscode.workspace.getConfiguration("terminal.integrated.profiles").update(
66+
"linux",
67+
{
68+
...originalProfiles,
69+
[PROFILE_NAME]: { path: "/bin/bash", args: ["--noprofile", "--norc"] },
70+
},
71+
vscode.ConfigurationTarget.Global,
72+
)
73+
74+
// Activate the profile override in-process. api.setConfiguration() alone
75+
// does not call Terminal.setTerminalProfile(), so this dedicated method is
76+
// required to wire up the static in the running extension host.
77+
globalThis.api.setTerminalProfile(PROFILE_NAME)
78+
})
79+
80+
suiteTeardown(async () => {
81+
try {
82+
await globalThis.api.cancelCurrentTask()
83+
} catch {
84+
// task may not be running
85+
}
86+
87+
// Always restore — order matters: clear profile first so any subsequent
88+
// terminal creation uses the default, then restore VS Code settings.
89+
globalThis.api.setTerminalProfile(undefined)
90+
91+
await vscode.workspace
92+
.getConfiguration("terminal.integrated.profiles")
93+
.update("linux", originalProfiles, vscode.ConfigurationTarget.Global)
94+
95+
await fs.rm(testDir, { recursive: true, force: true })
96+
97+
const aimockUrl = process.env.AIMOCK_URL
98+
const isRecord = process.env.AIMOCK_RECORD === "true"
99+
await globalThis.api.setConfiguration({
100+
apiProvider: "openrouter" as const,
101+
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
102+
openRouterModelId: "openai/gpt-4.1",
103+
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
104+
})
105+
})
106+
107+
setup(async () => {
108+
try {
109+
await globalThis.api.cancelCurrentTask()
110+
} catch {
111+
// task may not be running
112+
}
113+
114+
await fs.rm(path.join(testDir, OVERRIDE_FILE), { force: true })
115+
await fs.rm(path.join(testDir, DEFAULT_FILE), { force: true })
116+
await sleep(100)
117+
})
118+
119+
teardown(async () => {
120+
try {
121+
await globalThis.api.cancelCurrentTask()
122+
} catch {
123+
// task may not be running
124+
}
125+
126+
await sleep(100)
127+
})
128+
129+
test("executes command through profile override without shell integration warning", async function () {
130+
const api = globalThis.api
131+
const messages: ClineMessage[] = []
132+
133+
const messageHandler = ({ message }: { message: ClineMessage }) => {
134+
messages.push(message)
135+
}
136+
api.on(RooCodeEventName.Message, messageHandler)
137+
138+
try {
139+
await waitUntilCompleted({
140+
api,
141+
start: () =>
142+
api.startNewTask({
143+
configuration: {
144+
mode: "code",
145+
autoApprovalEnabled: true,
146+
alwaysAllowExecute: true,
147+
allowedCommands: ["*"],
148+
terminalShellIntegrationDisabled: false,
149+
},
150+
text: "TERMINAL_PROFILE_E2E_OVERRIDE",
151+
}),
152+
timeout: 90_000,
153+
})
154+
155+
const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning")
156+
const gotError = messages.some((m) => m.type === "say" && m.say === "error")
157+
158+
assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with a valid profile")
159+
assert.strictEqual(
160+
gotError,
161+
false,
162+
`Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`,
163+
)
164+
165+
const content = await fs.readFile(path.join(testDir, OVERRIDE_FILE), "utf-8")
166+
assert.ok(content.includes("zoo-profile-override-ok"), `Output file should contain marker, got: ${content}`)
167+
168+
assert.ok(vscode.window.terminals.length >= 1, "At least one VS Code terminal should exist")
169+
const profileTerminal = vscode.window.terminals.find((terminal) => {
170+
const options = terminal.creationOptions as vscode.TerminalOptions
171+
return (
172+
options.name === "Zoo Code" &&
173+
options.shellPath === "/bin/bash" &&
174+
Array.isArray(options.shellArgs) &&
175+
options.shellArgs.includes("--noprofile") &&
176+
options.shellArgs.includes("--norc")
177+
)
178+
})
179+
assert.ok(profileTerminal, "Expected a Zoo Code terminal created with the configured Bash profile")
180+
} finally {
181+
api.off(RooCodeEventName.Message, messageHandler)
182+
}
183+
})
184+
185+
test("starts a fresh terminal after clearing the profile override", async function () {
186+
const api = globalThis.api
187+
const messages: ClineMessage[] = []
188+
189+
const messageHandler = ({ message }: { message: ClineMessage }) => {
190+
messages.push(message)
191+
}
192+
api.on(RooCodeEventName.Message, messageHandler)
193+
194+
try {
195+
// Clear the override — this also calls TerminalRegistry.closeIdleTerminals()
196+
// so the terminal from test 1 is disposed before this task runs.
197+
api.setTerminalProfile(undefined)
198+
await sleep(200) // let VS Code process the disposal before the next task
199+
200+
await waitUntilCompleted({
201+
api,
202+
start: () =>
203+
api.startNewTask({
204+
configuration: {
205+
mode: "code",
206+
autoApprovalEnabled: true,
207+
alwaysAllowExecute: true,
208+
allowedCommands: ["*"],
209+
terminalShellIntegrationDisabled: false,
210+
},
211+
text: "TERMINAL_PROFILE_E2E_DEFAULT",
212+
}),
213+
timeout: 90_000,
214+
})
215+
216+
const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning")
217+
const gotError = messages.some((m) => m.type === "say" && m.say === "error")
218+
219+
assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with the default profile")
220+
assert.strictEqual(
221+
gotError,
222+
false,
223+
`Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`,
224+
)
225+
226+
const content = await fs.readFile(path.join(testDir, DEFAULT_FILE), "utf-8")
227+
assert.ok(content.includes("zoo-profile-default-ok"), `Output file should contain marker, got: ${content}`)
228+
} finally {
229+
api.off(RooCodeEventName.Message, messageHandler)
230+
}
231+
})
232+
})

packages/types/src/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,13 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
139139
* @throws Error if the profile does not exist
140140
*/
141141
setActiveProfile(name: string): Promise<string | undefined>
142+
/**
143+
* Activates a process-wide VS Code terminal profile override for Zoo Code
144+
* commands. This is intended for trusted extension integrations.
145+
* Passing undefined restores the VS Code default profile behavior and
146+
* closes idle terminals so the next command starts fresh.
147+
*/
148+
setTerminalProfile(name: string | undefined): void
142149
}
143150

144151
export interface RooCodeIpcServer extends EventEmitter<IpcServerEvents> {

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ export const globalSettingsSchema = z.object({
176176
terminalZshOhMy: z.boolean().optional(),
177177
terminalZshP10k: z.boolean().optional(),
178178
terminalZdotdir: z.boolean().optional(),
179+
terminalProfile: z.string().optional(),
179180
execaShellPath: z.string().optional(),
180181

181182
diagnosticsEnabled: z.boolean().optional(),

packages/types/src/model.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export const modelInfoSchema = z.object({
8181
promptCacheRetention: z.enum(["in_memory", "24h"]).optional(),
8282
// Capability flag to indicate whether the model supports an output verbosity parameter
8383
supportsVerbosity: z.boolean().optional(),
84+
// Capability flag to indicate whether the model exposes a user-configurable max output
85+
// tokens control in settings. When set, the settings UI surfaces a slider that persists
86+
// `modelMaxTokens`; when the user leaves it unset, the default output clamp is used.
87+
supportsMaxTokens: z.boolean().optional(),
8488
supportsReasoningBudget: z.boolean().optional(),
8589
// Capability flag to indicate whether the model supports simple on/off binary reasoning
8690
supportsReasoningBinary: z.boolean().optional(),

0 commit comments

Comments
 (0)