mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 03:41:17 +00:00
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.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user