Compare commits

..

1 Commits

Author SHA1 Message Date
Ben Brandt
f5d79167a6 fix: keep a v2 session idle when the model changes between turns (#1586)
On ACP v2, changing the model of an idle session left the session
reporting `running` with no work in progress, and it never reported
`idle` again until some later turn ended. A client that refuses prompts
while the session is `running` could then never send the prompt that
would end it.

## Cause

After `setModel`, Claude Code echoes a user message that carries only
the local command's output:

```json
{
  "type": "user",
  "message": {
    "role": "user",
    "content": "<local-command-stdout>Set model to `haiku (claude-haiku-5-5)`</local-command-stdout>"
  },
  "parent_tool_use_id": null,
  "uuid": "…",
  "isReplay": true
}
```

The v2 session read every top-level echo as a prompt that Claude Code
took in, and opened a turn for it. A model change runs no turn: no
`command_lifecycle` frame, `result` or `session_state_changed` follows
the echo, so nothing closed that turn.

Captured against the bundled CLI (adapter 0.104.1): `state_update
running` went out 18 ms after `session/set_config_option`, and no `idle`
followed in the next 75 seconds.

## Fix

An echo whose content is only local command output, and whose uuid is
not one of the session's pending prompts, no longer opens a turn. The
check is `isLocalCommandOutput` in `message-filters.ts`, next to the
helpers that already recognize these markers.

The pending-prompt exception keeps a client's own prompt working with a
CLI that sends no `command_lifecycle` frames, where the echo is the only
sign that the prompt was inserted.

## Scope

- v2 only. The v1 core takes its state from `session_state_changed` and
does not open turns at echoes.
- I captured a model change. Other local commands that Claude Code runs
outside a turn and echoes the same way are covered by the same check,
but I did not capture them.

## Testing

- New case in `src/tests/v2/v2.test.ts` sends the echo as the CLI sent
it. Without the fix it fails with `expected [ 'running' ] to deeply
equal []`.
- Reran the capture against the bundled CLI with the fix: no `running`
after either model change, and the next prompt ran `running` then
`idle`.
- `npm run check` and `npm run test:run` pass.
2026-11-10 18:49:50 +00:00
3 changed files with 47 additions and 0 deletions

View File

@@ -87,6 +87,22 @@ function hasMarkerTag(text: string, tag: string): boolean {
return markerTagText(text, tag) !== undefined;
}
/**
* Whether the content of a user message is only the output of a local
* command, such as the `<local-command-stdout>Set model to …</local-command-stdout>`
* that Claude Code echoes when the model changes.
*/
export function isLocalCommandOutput(content: unknown): boolean {
if (typeof content !== "string") return false;
if (
!hasMarkerTag(content, "local-command-stdout") &&
!hasMarkerTag(content, "local-command-stderr")
) {
return false;
}
return stripMarkerTags(content).trim() === "";
}
function commandInvocationFromMarkerOnlyText(text: string): string | null {
if (hasMarkerTag(text, "local-command-stdout") || hasMarkerTag(text, "local-command-stderr")) {
return null;

View File

@@ -1012,6 +1012,30 @@ describe("ACP v2 core", () => {
});
});
it("stays idle at the echo of a model change's local command output", async () => {
const client = v2Client();
await client.connect(async (agent) => {
const { claude } = await openSession(agent);
// As the bundled CLI sends it after `setModel`, with no turn: no
// `command_lifecycle` frame, result or `session_state_changed` follows.
claude.send({
type: "user",
message: {
role: "user",
content:
"<local-command-stdout>Set model to `haiku (claude-haiku-5-5)`</local-command-stdout>",
},
parent_tool_use_id: null,
uuid: randomUUID(),
session_id: "session",
timestamp: new Date().toISOString(),
isReplay: true,
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(client.states()).toEqual([]);
});
});
it("forwards the result of a local command, which streamed no text", async () => {
const client = v2Client();
await client.connect(async (agent) => {

View File

@@ -60,6 +60,7 @@ import { Pushable } from "../utils.js";
import { v1PromptRequest } from "./prompt.js";
import { V2Background, stateUpdate as currentStateUpdate } from "./background.js";
import type { V2UpdateTranslator } from "./update-translator.js";
import { isLocalCommandOutput } from "../message-filters.js";
import { MODEL_CONFIG_ID } from "../session-model.js";
import { modelRefusalFallback, noticeUpdate } from "../session-notices.js";
import { planModeUpdate } from "../exit-plan.js";
@@ -939,6 +940,12 @@ export class V2Session implements RequestTarget {
// in. A prompt Claude Code reported `started` for is inserted already.
if ("isReplay" in message && message.isReplay) {
if (message.uuid && message.parent_tool_use_id === null) {
// The output of a local command that Claude Code ran outside a
// turn, such as the "Set model to …" of a model change, is no
// prompt: no result or `command_lifecycle` frame would end its turn.
if (!this.pending.has(message.uuid) && isLocalCommandOutput(message.message.content)) {
return;
}
// A message typed in another client of the session.
const remote = this.remoteControl.remoteMessage(message);
if (remote !== undefined) this.output.userMessage(remote, message.uuid);