From 0f6010270da2079847a3d4667ed73e2db6327ac2 Mon Sep 17 00:00:00 2001 From: kesku Date: Tue, 10 Nov 2026 03:41:37 +0000 Subject: [PATCH] fix(acp-registry): ACP v2 agents other than Claude no longer echo the prompt or drop their own work On ACP v2, a session/prompt response only acknowledges the prompt. The agent reports when work finishes, and when it starts work on its own, through state_update. The ACP Registry flavor followed that state only for Claude Agent, so any other registry or local agent that negotiates v2 got v1 handling: its user_message echo of the submitted prompt became a second user message, and work it started outside a T3 turn was dropped. Every ACP Registry flavor now sets followAgentWorkState. The adapter already enables it only after initialize negotiates protocol v2, so v1 agents behave as before. Claude's subagent extractors stay Claude-only. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 73 +++++++++++++++++++ docs/user/providers-acp.md | 4 +- .../src/server/adapter.ts | 4 +- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index ffff68eea97ca..a68db79fac667 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1949,6 +1949,79 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("follows the work state of any agent that negotiates ACP v2", () => + Effect.gen(function* () { + const gate = makeProviderReplayGate(["elsewhere"]); + const { update, idle, prompt } = claudeFrames; + const reply = (messageId: string, text: string) => + update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "agent_message_chunk", + messageId, + content: { type: "text", text }, + }); + const session = yield* openClaudeReplaySession( + [ + ...claudeFrames.open, + ...prompt(1), + reply("reply-1", "Working on it."), + idle("end_turn"), + claudeMarker("foreground idle"), + { + ...update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "user_message", + messageId: "elsewhere-1", + content: [{ type: "text", text: "Sent from another client" }], + }), + label: "elsewhere", + }, + update(CLAUDE_ROOT_SESSION, { sessionUpdate: "state_update", state: "running" }), + reply("reply-2", "Answered elsewhere."), + idle("end_turn"), + claudeMarker("elsewhere idle"), + ], + gate, + true, + "fixture-agent", + ); + yield* session.runtime.startTurn(session.turnInput(1)); + const foreground = yield* session.until((event) => event.type === "turn.terminal"); + const terminal = foreground.at(-1)!; + if (terminal.type !== "turn.terminal") throw new Error("Missing terminal"); + assert.equal(terminal.status, "completed"); + assert.isTrue( + foreground.some( + (event) => event.type === "message.updated" && event.message.text === "Working on it.", + ), + "an accepted prompt is not the end of the turn; idle is", + ); + assert.isFalse( + foreground.some( + (event) => event.type === "message.updated" && event.message.text === "test prompt", + ), + "the agent's echo of the submitted prompt only signals acceptance", + ); + yield* session.throughMarker("foreground idle"); + + gate.release("elsewhere"); + yield* Queue.take(session.offered); + yield* session.throughMarker("elsewhere idle"); + assert.lengthOf(session.offers, 1); + yield* session.offers[0]!.dispatchIfCurrent!(Effect.void); + yield* session.runtime.startTurn(session.turnInput(2, { continuation: true })); + const continuation = yield* session.until((event) => event.type === "turn.terminal"); + const continued = continuation.at(-1)!; + if (continued.type !== "turn.terminal") throw new Error("Missing continuation terminal"); + assert.equal(continued.status, "completed"); + assert.isTrue( + continuation.some( + (event) => + event.type === "message.updated" && event.message.text === "Answered elsewhere.", + ), + "work the agent started on its own reaches the thread", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("keeps stopped child tools interrupted when a late output chunk arrives", () => Effect.gen(function* () { const { update } = claudeFrames; diff --git a/docs/user/providers-acp.md b/docs/user/providers-acp.md index 701d5638b3fd8..afa76e74cb550 100644 --- a/docs/user/providers-acp.md +++ b/docs/user/providers-acp.md @@ -12,7 +12,9 @@ Pi, and [Antigravity](./providers-antigravity.md), don't appear in the Registry T3 Code prefers the current ACP v2 preview protocol and uses its richer messages, usage, plans, configuration, compaction, and agent-terminal updates when the agent supports them. It also negotiates ACP v1 for Registry agents that have not migrated yet, so they continue to work through -the same generic integration. +the same generic integration. On ACP v2, T3 Code follows the agent's reported work state: a turn +ends when the agent goes idle, and work the agent starts on its own, such as answering a message +sent from another client, appears in the thread. Claude Agent uses the ACP v2 preview by default for Registry and local `claude-agent-acp` commands. If you encounter preview issues, set `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=0` in the diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 06ca0971ed324..03ad1519faaf3 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -228,9 +228,11 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu ...(registryAgentId === "mistral-vibe" ? { registerExtensions: registerMistralVibeAcpExtensions } : {}), + // ACP v2 reports turn completion, and work the agent starts on its own, through + // `state_update`; the adapter follows it only once an agent negotiates v2. + followAgentWorkState: true, ...(isClaude ? { - followAgentWorkState: true, extractSubagentUpdate: extractClaudeSubagentUpdate, isProvisionalSubagentTool: isClaudeProvisionalSubagentTool, extractSubagentSessionEvent: extractClaudeSubagentSessionEvent,