diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 9fbdeee03c86ce080b70f156c04bb829a0dcd3ba..17a464be2f10e410a9e304f84c8da15ef5862498 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,47 @@ 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 |\n"); + // Agents can repeat a terminal update after the call finished. + yield* progress("completed", "done"); + yield* say("| 3 | z |"); + 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 45a5cadb9a31266d4b9eed04809d326331035cf9..dde5979c0f56d364663bf07fbd47a27b6271eff2 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -824,6 +824,54 @@ 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(), 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", + "ContentDelta", + "ContentDelta", + "ToolCallUpdated", + "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 77517c44ea9bd09b6dbda3571b85e4172e373fa9..c6b93cf20546c666f46daf95dde160fffd3a00be 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,6 +336,9 @@ export const make = ( const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + // 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( Effect.mapError( (cause) => @@ -525,6 +530,7 @@ export const make = ( modeStateRef, configOptionsRef, toolCallsRef, + shownToolCallIds, assistantSegmentRef, assistantItemRuntimeId, params: notification, @@ -1178,6 +1184,7 @@ const handleSessionUpdate = ({ modeStateRef, configOptionsRef, toolCallsRef, + shownToolCallIds, assistantSegmentRef, assistantItemRuntimeId, params, @@ -1186,6 +1193,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,11 +1210,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, active } = yield* Ref.modify(toolCallsRef, (current) => { const tracked = current.get(event.toolCall.toolCallId); const previous = tracked?.state; const nextToolCall = mergeToolCallState(previous, event.toolCall); @@ -1228,11 +1232,22 @@ 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; } + // 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 (!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!); + } + // 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", toolCall: merged,