From 758698d5861f0e6b05202f628eb8f6da7ac85a60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 01:49:19 -0700 Subject: [PATCH 1/4] fix(acp): keep one answer when a running tool reports progress AcpSessionRuntime closed the active assistant segment on every parsed tool update, before deciding whether to emit it. A background command that ticked or finished while the agent was still writing split the answer into two bubbles, cutting markdown tables and links in half. A coalesced tick split it with nothing shown in between (#13133). Cursor, Grok and Antigravity all share this runtime. Close the segment only when a new tool call is emitted, which is still where a tool card lands between two answers. Co-Authored-By: adeebahmad01 <52380344+adeebahmad01@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/scripts/acp-mock-agent.ts | 40 ++++++++++++++++ .../provider/acp/AcpJsonRpcConnection.test.ts | 46 +++++++++++++++++++ .../src/provider/acp/AcpSessionRuntime.ts | 13 +++--- 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 9fbdeee03c86..5f1c4b44e51b 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -19,6 +19,8 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; +const emitBackgroundToolDuringAnswer = + process.env.T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER === "1"; const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; @@ -941,6 +943,44 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitBackgroundToolDuringAnswer) { + // A command backgrounded earlier reports progress and then finishes + // while the next answer is still streaming. + const toolCallId = "background-1"; + const say = (text: string) => + agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }, + }); + const progress = (status: "in_progress" | "completed", stdout: string) => + agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status, + rawOutput: { stdout }, + }, + }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "in_progress", + rawInput: { command: "sleep 3 && echo done" }, + }, + }); + yield* say("| a | b |\n|---|---|\n| 1 "); + yield* progress("in_progress", "."); + yield* say("| x |\n"); + yield* progress("completed", "done"); + yield* say("| 2 | y |"); + return { stopReason: "end_turn" }; + } + if (emitInterleavedAssistantToolCalls) { const toolCallId = "tool-call-1"; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 45a5cadb9a31..45545b61af4e 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -824,6 +824,52 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("keeps one answer when an earlier tool reports progress mid-stream", () => + Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + yield* runtime.start(); + yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] }); + + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7))); + // The coalesced progress tick emits nothing and the completion lands + // between deltas; neither splits the markdown table across items. + expect(notes.map((note) => note._tag)).toEqual([ + "ToolCallUpdated", + "AssistantItemStarted", + "ContentDelta", + "ContentDelta", + "ToolCallUpdated", + "ContentDelta", + "AssistantItemCompleted", + ]); + const itemIds = new Set( + notes.flatMap((note) => + note._tag === "ContentDelta" || + note._tag === "AssistantItemStarted" || + note._tag === "AssistantItemCompleted" + ? [note.itemId] + : [], + ), + ); + expect(itemIds.size).toBe(1); + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + env: { T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER: "1" }, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + Effect.provide(NodeServices.layer), + ), + ); + it.effect("emits status-only tool updates through completion", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 77517c44ea9b..89b28ff5eb2b 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1202,11 +1202,7 @@ const handleSessionUpdate = ({ } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { - yield* closeActiveAssistantSegment({ - queue, - assistantSegmentRef, - }); - const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { + const { merged, decision, isNew } = yield* Ref.modify(toolCallsRef, (current) => { const tracked = current.get(event.toolCall.toolCallId); const previous = tracked?.state; const nextToolCall = mergeToolCallState(previous, event.toolCall); @@ -1228,11 +1224,16 @@ const handleSessionUpdate = ({ skippedSinceEmit: decision.skippedSinceEmit, }); } - return [{ merged: nextToolCall, decision }, next] as const; + return [{ merged: nextToolCall, decision, isNew: tracked === undefined }, next] as const; }); if (!decision.emit) { continue; } + // A new tool call is a boundary in the prose. Progress on a call that + // is already shown, such as a background command finishing, is not. + if (isNew) { + yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); + } yield* Queue.offer(queue, { _tag: "ToolCallUpdated", toolCall: merged, From 0db758c995c686cf94ee06af503e377ee86e9dd4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 02:11:40 -0700 Subject: [PATCH 2/4] fix(acp): remember shown tool calls after they finish A repeated terminal update for a finished call looked new once its progress state was dropped, and split the answer again. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/scripts/acp-mock-agent.ts | 5 ++++- .../src/provider/acp/AcpJsonRpcConnection.test.ts | 8 +++++--- apps/server/src/provider/acp/AcpSessionRuntime.ts | 13 ++++++++++--- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 5f1c4b44e51b..17a464be2f10 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -977,7 +977,10 @@ const program = Effect.gen(function* () { yield* progress("in_progress", "."); yield* say("| x |\n"); yield* progress("completed", "done"); - yield* say("| 2 | y |"); + yield* say("| 2 | y |\n"); + // Agents can repeat a terminal update after the call finished. + yield* progress("completed", "done"); + yield* say("| 3 | z |"); return { stopReason: "end_turn" }; } diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 45545b61af4e..dde5979c0f56 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -830,9 +830,9 @@ describe("AcpSessionRuntime", () => { yield* runtime.start(); yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] }); - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7))); - // The coalesced progress tick emits nothing and the completion lands - // between deltas; neither splits the markdown table across items. + const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 9))); + // The coalesced progress tick emits nothing, and neither the completion + // nor a repeated one splits the markdown table across items. expect(notes.map((note) => note._tag)).toEqual([ "ToolCallUpdated", "AssistantItemStarted", @@ -840,6 +840,8 @@ describe("AcpSessionRuntime", () => { "ContentDelta", "ToolCallUpdated", "ContentDelta", + "ToolCallUpdated", + "ContentDelta", "AssistantItemCompleted", ]); const itemIds = new Set( diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 89b28ff5eb2b..a142caa5894c 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -334,6 +334,9 @@ export const make = ( const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + // Tool calls already shown. A late update to a finished call is not a new + // boundary in the answer, although its progress state is gone. + const shownToolCallIds = new Set(); const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -525,6 +528,7 @@ export const make = ( modeStateRef, configOptionsRef, toolCallsRef, + shownToolCallIds, assistantSegmentRef, assistantItemRuntimeId, params: notification, @@ -1178,6 +1182,7 @@ const handleSessionUpdate = ({ modeStateRef, configOptionsRef, toolCallsRef, + shownToolCallIds, assistantSegmentRef, assistantItemRuntimeId, params, @@ -1186,6 +1191,7 @@ const handleSessionUpdate = ({ readonly modeStateRef: Ref.Ref; readonly configOptionsRef: Ref.Ref>; readonly toolCallsRef: Ref.Ref>; + readonly shownToolCallIds: Set; readonly assistantSegmentRef: Ref.Ref; readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; @@ -1202,7 +1208,7 @@ const handleSessionUpdate = ({ } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { - const { merged, decision, isNew } = yield* Ref.modify(toolCallsRef, (current) => { + const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { const tracked = current.get(event.toolCall.toolCallId); const previous = tracked?.state; const nextToolCall = mergeToolCallState(previous, event.toolCall); @@ -1224,14 +1230,15 @@ const handleSessionUpdate = ({ skippedSinceEmit: decision.skippedSinceEmit, }); } - return [{ merged: nextToolCall, decision, isNew: tracked === undefined }, next] as const; + return [{ merged: nextToolCall, decision }, next] as const; }); if (!decision.emit) { continue; } // A new tool call is a boundary in the prose. Progress on a call that // is already shown, such as a background command finishing, is not. - if (isNew) { + if (!shownToolCallIds.has(merged.toolCallId)) { + shownToolCallIds.add(merged.toolCallId); yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); } yield* Queue.offer(queue, { From 748aa36ae30a19c1ffed5c1ee0e0a62d7661de83 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 02:14:18 -0700 Subject: [PATCH 3/4] fix(acp): cap remembered tool call ids per session Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/provider/acp/AcpSessionRuntime.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index a142caa5894c..4c3f9db5cf8e 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -40,6 +40,8 @@ import { type AcpToolCallState, } from "./AcpRuntimeModel.ts"; +const MAX_SHOWN_TOOL_CALL_IDS = 256; + interface AcpToolCallTrackedState { readonly state: AcpToolCallState; readonly lastEmittedDetailLength: number | undefined; @@ -334,7 +336,7 @@ export const make = ( const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); - // Tool calls already shown. A late update to a finished call is not a new + // Recently shown tool calls. A late update to a finished call is not a new // boundary in the answer, although its progress state is gone. const shownToolCallIds = new Set(); const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( @@ -1239,6 +1241,10 @@ const handleSessionUpdate = ({ // is already shown, such as a background command finishing, is not. if (!shownToolCallIds.has(merged.toolCallId)) { shownToolCallIds.add(merged.toolCallId); + // Only recent calls get late updates; keep a long session bounded. + if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) { + shownToolCallIds.delete(shownToolCallIds.values().next().value!); + } yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); } yield* Queue.offer(queue, { From d1d62657dedda2d5f54da062416842e4ef750e1a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 05:19:52 -0700 Subject: [PATCH 4/4] fix(acp): do not split an answer for a running call that aged out Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/provider/acp/AcpSessionRuntime.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 4c3f9db5cf8e..c6b93cf20546 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1210,7 +1210,7 @@ const handleSessionUpdate = ({ } for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { - const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { + const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => { const tracked = current.get(event.toolCall.toolCallId); const previous = tracked?.state; const nextToolCall = mergeToolCallState(previous, event.toolCall); @@ -1232,7 +1232,7 @@ const handleSessionUpdate = ({ skippedSinceEmit: decision.skippedSinceEmit, }); } - return [{ merged: nextToolCall, decision }, next] as const; + return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const; }); if (!decision.emit) { continue; @@ -1245,7 +1245,8 @@ const handleSessionUpdate = ({ if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) { shownToolCallIds.delete(shownToolCallIds.values().next().value!); } - yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); + // A call still running is already on screen, even if it aged out. + if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef }); } yield* Queue.offer(queue, { _tag: "ToolCallUpdated",