authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-24 13:20:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 13:20:28-07:00
loge759847f932c0de08261ec98a5fac3bae037120f
tree4d483aed954ed7f8a8e941f1f5a5f51d95a6ce1a
parent53456bc0129f61325dbaa498e2399c33b4360cb8
signature Signed by PGP key B5690EEEBB952194

fix(acp): keep one answer when a running tool reports progress (#13386)

Co-authored-by: adeebahmad01 <52380344+adeebahmad01@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 112 insertions(+), 6 deletions(-)

apps/server/scripts/acp-mock-agent.ts+43
......@@ -19,6 +19,8 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
1919const emitInterleavedAssistantToolCalls =
2020 process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
2121const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
22const emitBackgroundToolDuringAnswer =
23 process.env.T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER === "1";
2224const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
2325const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1";
2426const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1";
......@@ -941,6 +943,47 @@ const program = Effect.gen(function* () {
941943 return yield* Effect.never;
942944 }
943945
946 if (emitBackgroundToolDuringAnswer) {
947 // A command backgrounded earlier reports progress and then finishes
948 // while the next answer is still streaming.
949 const toolCallId = "background-1";
950 const say = (text: string) =>
951 agent.client.sessionUpdate({
952 sessionId: requestedSessionId,
953 update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
954 });
955 const progress = (status: "in_progress" | "completed", stdout: string) =>
956 agent.client.sessionUpdate({
957 sessionId: requestedSessionId,
958 update: {
959 sessionUpdate: "tool_call_update",
960 toolCallId,
961 status,
962 rawOutput: { stdout },
963 },
964 });
965 yield* agent.client.sessionUpdate({
966 sessionId: requestedSessionId,
967 update: {
968 sessionUpdate: "tool_call",
969 toolCallId,
970 title: "Terminal",
971 kind: "execute",
972 status: "in_progress",
973 rawInput: { command: "sleep 3 && echo done" },
974 },
975 });
976 yield* say("| a | b |\n|---|---|\n| 1 ");
977 yield* progress("in_progress", ".");
978 yield* say("| x |\n");
979 yield* progress("completed", "done");
980 yield* say("| 2 | y |\n");
981 // Agents can repeat a terminal update after the call finished.
982 yield* progress("completed", "done");
983 yield* say("| 3 | z |");
984 return { stopReason: "end_turn" };
985 }
986
944987 if (emitInterleavedAssistantToolCalls) {
945988 const toolCallId = "tool-call-1";
946989
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+48
......@@ -824,6 +824,54 @@ describe("AcpSessionRuntime", () => {
824824 ),
825825 );
826826
827 it.effect("keeps one answer when an earlier tool reports progress mid-stream", () =>
828 Effect.gen(function* () {
829 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
830 yield* runtime.start();
831 yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] });
832
833 const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 9)));
834 // The coalesced progress tick emits nothing, and neither the completion
835 // nor a repeated one splits the markdown table across items.
836 expect(notes.map((note) => note._tag)).toEqual([
837 "ToolCallUpdated",
838 "AssistantItemStarted",
839 "ContentDelta",
840 "ContentDelta",
841 "ToolCallUpdated",
842 "ContentDelta",
843 "ToolCallUpdated",
844 "ContentDelta",
845 "AssistantItemCompleted",
846 ]);
847 const itemIds = new Set(
848 notes.flatMap((note) =>
849 note._tag === "ContentDelta" ||
850 note._tag === "AssistantItemStarted" ||
851 note._tag === "AssistantItemCompleted"
852 ? [note.itemId]
853 : [],
854 ),
855 );
856 expect(itemIds.size).toBe(1);
857 }).pipe(
858 Effect.provide(
859 AcpSessionRuntime.layer({
860 spawn: {
861 command: mockAgentCommand,
862 args: mockAgentArgs,
863 env: { T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER: "1" },
864 },
865 cwd: process.cwd(),
866 clientInfo: { name: "t3-test", version: "0.0.0" },
867 authMethodId: "test",
868 }),
869 ),
870 Effect.scoped,
871 Effect.provide(NodeServices.layer),
872 ),
873 );
874
827875 it.effect("emits status-only tool updates through completion", () =>
828876 Effect.gen(function* () {
829877 const runtime = yield* AcpSessionRuntime.AcpSessionRuntime;
apps/server/src/provider/acp/AcpSessionRuntime.ts+21-6
......@@ -40,6 +40,8 @@ import {
4040 type AcpToolCallState,
4141} from "./AcpRuntimeModel.ts";
4242
43const MAX_SHOWN_TOOL_CALL_IDS = 256;
44
4345interface AcpToolCallTrackedState {
4446 readonly state: AcpToolCallState;
4547 readonly lastEmittedDetailLength: number | undefined;
......@@ -334,6 +336,9 @@ export const make = (
334336 const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>();
335337 const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined);
336338 const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallTrackedState>());
339 // Recently shown tool calls. A late update to a finished call is not a new
340 // boundary in the answer, although its progress state is gone.
341 const shownToolCallIds = new Set<string>();
337342 const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe(
338343 Effect.mapError(
339344 (cause) =>
......@@ -525,6 +530,7 @@ export const make = (
525530 modeStateRef,
526531 configOptionsRef,
527532 toolCallsRef,
533 shownToolCallIds,
528534 assistantSegmentRef,
529535 assistantItemRuntimeId,
530536 params: notification,
......@@ -1178,6 +1184,7 @@ const handleSessionUpdate = ({
11781184 modeStateRef,
11791185 configOptionsRef,
11801186 toolCallsRef,
1187 shownToolCallIds,
11811188 assistantSegmentRef,
11821189 assistantItemRuntimeId,
11831190 params,
......@@ -1186,6 +1193,7 @@ const handleSessionUpdate = ({
11861193 readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
11871194 readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>;
11881195 readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>;
1196 readonly shownToolCallIds: Set<string>;
11891197 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
11901198 readonly assistantItemRuntimeId: string;
11911199 readonly params: EffectAcpSchema.SessionNotification;
......@@ -1202,11 +1210,7 @@ const handleSessionUpdate = ({
12021210 }
12031211 for (const event of parsed.events) {
12041212 if (event._tag === "ToolCallUpdated") {
1205 yield* closeActiveAssistantSegment({
1206 queue,
1207 assistantSegmentRef,
1208 });
1209 const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => {
1213 const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => {
12101214 const tracked = current.get(event.toolCall.toolCallId);
12111215 const previous = tracked?.state;
12121216 const nextToolCall = mergeToolCallState(previous, event.toolCall);
......@@ -1228,11 +1232,22 @@ const handleSessionUpdate = ({
12281232 skippedSinceEmit: decision.skippedSinceEmit,
12291233 });
12301234 }
1231 return [{ merged: nextToolCall, decision }, next] as const;
1235 return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const;
12321236 });
12331237 if (!decision.emit) {
12341238 continue;
12351239 }
1240 // A new tool call is a boundary in the prose. Progress on a call that
1241 // is already shown, such as a background command finishing, is not.
1242 if (!shownToolCallIds.has(merged.toolCallId)) {
1243 shownToolCallIds.add(merged.toolCallId);
1244 // Only recent calls get late updates; keep a long session bounded.
1245 if (shownToolCallIds.size > MAX_SHOWN_TOOL_CALL_IDS) {
1246 shownToolCallIds.delete(shownToolCallIds.values().next().value!);
1247 }
1248 // A call still running is already on screen, even if it aged out.
1249 if (!active) yield* closeActiveAssistantSegment({ queue, assistantSegmentRef });
1250 }
12361251 yield* Queue.offer(queue, {
12371252 _tag: "ToolCallUpdated",
12381253 toolCall: merged,