Skip to content

Commit 7d0879b

Browse files
authored
fix(terminal): fix output loss on cold terminals by moving read() into onDidStartTerminalShellExecution (#800) (#834)
* fix(terminal): detect D marker directly to recover from lost VSCode shell-integration completion signals * fix(terminal): move execution.read() into onDidStartTerminalShellExecution to fix cold-terminal zero-chunk output loss * fix(terminal): update unit tests for read()-in-TerminalRegistry architecture * fix(terminal): fix Windows CI test timing and improve coverage * fix(terminal): address code review findings * fix(terminal): address CodeRabbit review findings
1 parent 9e218a5 commit 7d0879b

28 files changed

Lines changed: 2051 additions & 137 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "COLD_SHELL_INIT_E2E"
6+
},
7+
"response": {
8+
"toolCalls": [
9+
{
10+
"name": "execute_command",
11+
"arguments": "{\"command\":\"python3 -c \\\"\\nimport sys\\nprint('cold-init-ok', file=sys.stdout)\\nsys.exit(0)\\n\\\"\"}",
12+
"id": "call_cold_shell_init_001"
13+
}
14+
]
15+
}
16+
}
17+
]
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "FAST_EXIT_SHELL_RACE_E2E"
6+
},
7+
"response": {
8+
"toolCalls": [
9+
{
10+
"name": "execute_command",
11+
"arguments": "{\"command\":\"python3 -c \\\"\\nimport sys\\nprint('boom', file=sys.stderr)\\nsys.exit(1)\\n\\\"\"}",
12+
"id": "call_fast_exit_shell_race_001"
13+
}
14+
]
15+
}
16+
}
17+
]
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "LONG_RUNNING_SILENT_COMMAND_E2E"
6+
},
7+
"response": {
8+
"toolCalls": [
9+
{
10+
"name": "execute_command",
11+
"arguments": "{\"command\":\"sleep 5\"}",
12+
"id": "call_long_running_silent_001"
13+
}
14+
]
15+
}
16+
}
17+
]
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "TERMINAL_REUSE_SHELL_RACE_E2E"
6+
},
7+
"response": {
8+
"toolCalls": [
9+
{
10+
"name": "execute_command",
11+
"arguments": "{\"command\":\"python3 -c \\\"\\nimport sys\\nprint('first', file=sys.stderr)\\nsys.exit(0)\\n\\\"\"}",
12+
"id": "call_terminal_reuse_001"
13+
}
14+
]
15+
}
16+
}
17+
]
18+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "ZERO_CHUNK_SHELL_RACE_E2E"
6+
},
7+
"response": {
8+
"toolCalls": [
9+
{
10+
"name": "execute_command",
11+
"arguments": "{\"command\":\"python3 -c \\\"\\nimport sys\\nprint('boom', file=sys.stderr)\\nsys.exit(1)\\n\\\"\"}",
12+
"id": "call_zero_chunk_shell_race_001"
13+
}
14+
]
15+
}
16+
}
17+
]
18+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { ChatCompletionRequest, ChatMessage } from "@copilotkit/aimock"
2+
3+
import { LLMock } from "@copilotkit/aimock"
4+
5+
function anyToolResultContains(req: ChatCompletionRequest, ...terms: string[]): boolean {
6+
const messages: ChatMessage[] = Array.isArray(req?.messages) ? req.messages : []
7+
return messages.some(
8+
(msg) =>
9+
msg?.role === "tool" &&
10+
typeof msg.content === "string" &&
11+
terms.every((t) => (msg.content as string).includes(t)),
12+
)
13+
}
14+
15+
export function addColdShellInitFixtures(mock: InstanceType<typeof LLMock>) {
16+
// On cold zsh terminals the first execution may produce 0 chunks (VSCode
17+
// execution.read() limitation on basic shell integration — see issue #242897).
18+
// When the first result is empty, the mock retries the same command so the
19+
// second attempt (on the now-warm terminal) captures real output.
20+
mock.addFixture({
21+
match: {
22+
toolCallId: "call_cold_shell_init_001",
23+
predicate: (req: ChatCompletionRequest) =>
24+
// First attempt returned empty — retry the command
25+
!anyToolResultContains(req, "cold-init-ok"),
26+
},
27+
response: {
28+
toolCalls: [
29+
{
30+
name: "execute_command",
31+
arguments: JSON.stringify({
32+
command: "python3 -c \"\nimport sys\nprint('cold-init-ok', file=sys.stdout)\nsys.exit(0)\n\"",
33+
}),
34+
id: "call_cold_shell_init_003",
35+
},
36+
],
37+
},
38+
})
39+
40+
// Match whichever attempt (first or retry) delivers real output — prove
41+
// the guard kept the process alive long enough for the output to arrive.
42+
mock.addFixture({
43+
match: {
44+
predicate: (req: ChatCompletionRequest) => anyToolResultContains(req, "cold-init-ok", "Exit code: 0"),
45+
},
46+
response: {
47+
toolCalls: [
48+
{
49+
name: "attempt_completion",
50+
arguments: JSON.stringify({ result: "Cold shell init command completed with real output." }),
51+
id: "call_cold_shell_init_002",
52+
},
53+
],
54+
},
55+
})
56+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { LLMock } from "@copilotkit/aimock"
2+
3+
import { toolResultContains } from "./tool-result"
4+
5+
export function addFastExitShellRaceResultFixtures(mock: InstanceType<typeof LLMock>) {
6+
mock.addFixture({
7+
match: {
8+
toolCallId: "call_fast_exit_shell_race_001",
9+
// VSCode drops onDidEndTerminalShellExecution for this command (the race under
10+
// test), so TerminalProcess.run() only has the D marker itself as proof of
11+
// completion, never a real exit code (see ExecuteCommandTool.ts's
12+
// `exitDetails === undefined` branch). Match on the actual stderr output and the
13+
// specific unknown-exit-status text so this fixture -- and the e2e assertions it
14+
// drives -- would fail if either the output capture or that fallback wording
15+
// regressed, instead of passing on any generic "command executed" result.
16+
predicate: (req) =>
17+
toolResultContains(req, "call_fast_exit_shell_race_001", [
18+
"boom",
19+
"<VSCE exitDetails == undefined: terminal output and command execution status is unknown.>",
20+
]),
21+
},
22+
response: {
23+
toolCalls: [
24+
{
25+
name: "attempt_completion",
26+
arguments: JSON.stringify({ result: "The script ran and printed 'boom' to stderr." }),
27+
id: "call_fast_exit_shell_race_002",
28+
},
29+
],
30+
},
31+
})
32+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { LLMock } from "@copilotkit/aimock"
2+
3+
import { toolResultContains } from "./tool-result"
4+
5+
export function addLongRuningSilentCommandFixtures(mock: InstanceType<typeof LLMock>) {
6+
// `sleep 5` produces no output and completes normally via onDidEndTerminalShellExecution.
7+
// The idle timeout must NOT fire here — it must only fire on zero-chunk commands where
8+
// the stream stays open AND the event is delayed (the { ... }-wrapped multiline bug).
9+
// For `sleep 5`, the stream stays open (so the idle timer is never active after the
10+
// first chunk — but actually sleep 5 may produce no chunks either). The distinction
11+
// is that `sleep 5` DOES receive onDidEndTerminalShellExecution promptly after exit,
12+
// which breaks the loop via DONE_SENTINEL before the 3s idle timer fires.
13+
mock.addFixture({
14+
match: {
15+
toolCallId: "call_long_running_silent_001",
16+
predicate: (req) =>
17+
toolResultContains(req, "call_long_running_silent_001", [
18+
// sleep exits with code 0 — the normal exit status path
19+
"Exit code: 0",
20+
]),
21+
},
22+
response: {
23+
toolCalls: [
24+
{
25+
name: "attempt_completion",
26+
arguments: JSON.stringify({ result: "The sleep command completed successfully." }),
27+
id: "call_long_running_silent_002",
28+
},
29+
],
30+
},
31+
})
32+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { LLMock } from "@copilotkit/aimock"
2+
3+
import { toolResultContains } from "./tool-result"
4+
5+
export function addTerminalReuseShellRaceFixtures(mock: InstanceType<typeof LLMock>) {
6+
// First command completes — model issues a second command on the same terminal.
7+
// With the temp-script fix, both commands now deliver real output.
8+
mock.addFixture({
9+
match: {
10+
toolCallId: "call_terminal_reuse_001",
11+
predicate: (req) => toolResultContains(req, "call_terminal_reuse_001", ["first", "Exit code: 0"]),
12+
},
13+
response: {
14+
toolCalls: [
15+
{
16+
name: "execute_command",
17+
arguments: JSON.stringify({
18+
command: "python3 -c \"\nimport sys\nprint('second', file=sys.stderr)\nsys.exit(0)\n\"",
19+
}),
20+
id: "call_terminal_reuse_002",
21+
},
22+
],
23+
},
24+
})
25+
26+
// Second command on the reused terminal also completes.
27+
mock.addFixture({
28+
match: {
29+
toolCallId: "call_terminal_reuse_002",
30+
predicate: (req) => toolResultContains(req, "call_terminal_reuse_002", ["second", "Exit code: 0"]),
31+
},
32+
response: {
33+
toolCalls: [
34+
{
35+
name: "attempt_completion",
36+
arguments: JSON.stringify({ result: "Both commands ran on the reused terminal." }),
37+
id: "call_terminal_reuse_003",
38+
},
39+
],
40+
},
41+
})
42+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { LLMock } from "@copilotkit/aimock"
2+
3+
import { toolResultContains } from "./tool-result"
4+
5+
export function addZeroChunkShellRaceResultFixtures(mock: InstanceType<typeof LLMock>) {
6+
mock.addFixture({
7+
match: {
8+
toolCallId: "call_zero_chunk_shell_race_001",
9+
// The multiline command is now written to a temp script file and executed
10+
// via `sh /tmp/roo-cmd-*.sh` to avoid the VSCode { ... }-wrapping bug that
11+
// caused the stream to be closed before read() arrived (zero chunks).
12+
// The real output ('boom' on stderr) and exit code (1) now reach the model.
13+
predicate: (req) => toolResultContains(req, "call_zero_chunk_shell_race_001", ["boom", "Exit code: 1"]),
14+
},
15+
response: {
16+
toolCalls: [
17+
{
18+
name: "attempt_completion",
19+
arguments: JSON.stringify({ result: "The script ran and printed 'boom' to stderr." }),
20+
id: "call_zero_chunk_shell_race_002",
21+
},
22+
],
23+
},
24+
})
25+
}

0 commit comments

Comments
 (0)