mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-28 04:51:50 +00:00
fix(tools): key Bash terminal metas off the announced tool_use id (#917)
## Problem
The `Bash` branch of `toolUpdateFromToolResult` derives its terminal id
from the result block and falls back to the empty string:
```ts
const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
```
On that fallback it still emits the whole terminal payload — `content:
[{ type: "terminal", terminalId: "" }]` plus `terminal_info`,
`terminal_output` and `terminal_exit`, every one keyed on `""`.
No client has a terminal under that id. `toolInfoFromToolUse` announces
the terminal as `toolUse.id` (`src/tools.ts:161`), so the empty id
matches nothing that was created. A client that buffers output for
terminals it has not been told about then holds the data indefinitely
behind an id that never arrives — Zed keeps it in
`pending_terminal_output` / `pending_terminal_exit` and drains only on a
matching create — so the command's output is silently stranded and the
user is left looking at an empty terminal.
It fails quietly, which is what makes it worth fixing: nothing logs, and
the update looks well-formed on the wire.
## Change
Prefer `toolUse.id` — the id that was actually announced — and keep the
result block's `tool_use_id` as a fallback. The two agree whenever both
are present, since the caller looks the tool_use up by that id, so this
only changes behaviour when one is missing.
When neither yields an id, fall through to the code-block rendering that
already exists immediately below rather than emitting a terminal
reference the client cannot resolve.
## Tests
Three cases added to `src/tests/tools.test.ts`:
- metas are keyed off the tool_use id even when the result block
disagrees
- the result block's `tool_use_id` is still used when the tool_use is
unavailable
- no id at all degrades to the `console` code block, with no `_meta`
Two of the three fail on `main` and pass with the change; the third
guards the preserved fallback. `npm run build`, `npm run check` and
`vitest run src/tests/tools.test.ts` (112 passed) are clean.
Note: `src/tests/create-session-options.test.ts > session/load seeds the
window from the resumed session's getContextUsage report` fails on a
clean `main` checkout here too — pre-existing and unrelated.
## How I got here
Found while tracing the "Tool call not found" rows in #913 — unrelated
cause, same file. The `terminal_id: ""` path came up while ruling the
terminal-streaming path out as the source of those.
---------
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -491,6 +491,94 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keys the terminal metas off the tool_use id, which is what was announced", () => {
|
||||
// `toolInfoFromToolUse` announces the terminal as `toolUse.id`, so the
|
||||
// result's metas have to use the same value for the client to match them
|
||||
// up. A result block that disagrees (or omits `tool_use_id`) must not be
|
||||
// allowed to retarget them.
|
||||
const toolResult = {
|
||||
...makeBashResult("out", "", 0),
|
||||
tool_use_id: "toolu_something_else",
|
||||
};
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
|
||||
expect(update._meta).toEqual({
|
||||
terminal_info: { terminal_id: "toolu_bash" },
|
||||
terminal_output: { terminal_id: "toolu_bash", data: "out" },
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the result's tool_use_id when the tool_use is unavailable", () => {
|
||||
const toolResult = makeBashResult("out", "", 0);
|
||||
const update = toolUpdateFromToolResult(toolResult, { name: "Bash" }, true);
|
||||
|
||||
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
|
||||
expect(update._meta).toEqual({
|
||||
terminal_info: { terminal_id: "toolu_bash" },
|
||||
terminal_output: { terminal_id: "toolu_bash", data: "out" },
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a code block instead of a dangling terminal when no id is available", () => {
|
||||
// Previously this emitted `terminal_id: ""` for all three metas. Nothing
|
||||
// on the client has a terminal under that id, so the output was stranded
|
||||
// (Zed buffers output/exit for unknown terminals indefinitely) and the
|
||||
// user saw an empty terminal. Degrade to the non-terminal rendering.
|
||||
// `tool_use_id` is required on every result-block type, so a block without
|
||||
// it can only arrive at runtime (an older or non-conforming emitter). The
|
||||
// source guards for it with `"tool_use_id" in toolResult`, so exercise that
|
||||
// path with a cast rather than pretending the type allows it.
|
||||
const { content, type } = makeBashResult("out", "", 0);
|
||||
const update = toolUpdateFromToolResult(
|
||||
{ content, type } as unknown as Parameters<typeof toolUpdateFromToolResult>[0],
|
||||
{ name: "Bash" },
|
||||
true,
|
||||
);
|
||||
|
||||
expect(update._meta).toBeUndefined();
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "```console\nout\n```" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats an empty tool_use id as no id and falls back to the result block", () => {
|
||||
const toolResult = makeBashResult("out", "", 0);
|
||||
const update = toolUpdateFromToolResult(toolResult, { id: "", name: "Bash" }, true);
|
||||
|
||||
expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]);
|
||||
expect(update._meta).toEqual({
|
||||
terminal_info: { terminal_id: "toolu_bash" },
|
||||
terminal_output: { terminal_id: "toolu_bash", data: "out" },
|
||||
terminal_exit: { terminal_id: "toolu_bash", exit_code: 0, signal: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a code block when neither id is a usable string", () => {
|
||||
// A present-but-undefined `tool_use_id` still satisfies an `in` check, and
|
||||
// stringifying it would key all three metas on the literal "undefined" — an
|
||||
// id no client ever created a terminal for, so the output strands exactly as
|
||||
// it did under the old empty-string id.
|
||||
const toolResult = {
|
||||
...makeBashResult("out", "", 0),
|
||||
tool_use_id: undefined,
|
||||
} as unknown as Parameters<typeof toolUpdateFromToolResult>[0];
|
||||
const update = toolUpdateFromToolResult(toolResult, { id: "", name: "Bash" }, true);
|
||||
|
||||
expect(update._meta).toBeUndefined();
|
||||
expect(update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "```console\nout\n```" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should include exit_code from return_code in terminal_exit", () => {
|
||||
const toolResult = makeBashResult("", "command not found", 127);
|
||||
const update = toolUpdateFromToolResult(toolResult, bashToolUse, true);
|
||||
|
||||
23
src/tools.ts
23
src/tools.ts
@@ -686,7 +686,19 @@ export function toolUpdateFromToolResult(
|
||||
|
||||
case "Bash": {
|
||||
const result = toolResult.content;
|
||||
const terminalId = "tool_use_id" in toolResult ? String(toolResult.tool_use_id) : "";
|
||||
// The terminal was announced under the tool_use's own id (see
|
||||
// `toolInfoFromToolUse`), so key the output/exit metas off that: it is the
|
||||
// id the client actually created a terminal for. `toolResult.tool_use_id`
|
||||
// is the same value whenever present — the caller looks the tool_use up by
|
||||
// it — so preferring `toolUse.id` only adds a source for the case where the
|
||||
// result block carries no id at all. Anything that isn't a non-empty
|
||||
// string is no id at all: `""` matches no terminal, and stringifying a
|
||||
// present-but-undefined field would invent the literal `"undefined"`.
|
||||
const terminalIdOf = (id: unknown): string | undefined =>
|
||||
typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
const terminalId: string | undefined =
|
||||
terminalIdOf(toolUse?.id) ??
|
||||
terminalIdOf("tool_use_id" in toolResult ? toolResult.tool_use_id : undefined);
|
||||
const isError = "is_error" in toolResult && toolResult.is_error;
|
||||
|
||||
// Extract output and exit code from either format:
|
||||
@@ -767,7 +779,14 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
}
|
||||
|
||||
if (supportsTerminalOutput) {
|
||||
// Without a terminal id there is nothing the client can reconcile these
|
||||
// metas against, and emitting them anyway strands the output: a client that
|
||||
// buffers output/exit for terminals it has not been told about (Zed keeps
|
||||
// them in `pending_terminal_output`/`pending_terminal_exit`, drained only on
|
||||
// a matching create) would hold them forever behind an id that never
|
||||
// arrives, showing an empty terminal. Fall through to the code-block
|
||||
// rendering below instead.
|
||||
if (supportsTerminalOutput && terminalId !== undefined) {
|
||||
return {
|
||||
content: [{ type: "terminal" as const, terminalId }],
|
||||
_meta: {
|
||||
|
||||
Reference in New Issue
Block a user