| author | |
| committer | |
| log | e759847f932c0de08261ec98a5fac3bae037120f |
| tree | 4d483aed954ed7f8a8e941f1f5a5f51d95a6ce1a |
| parent | 53456bc0129f61325dbaa498e2399c33b4360cb8 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; | ... | @@ -19,6 +19,8 @@ const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; |
| 19 | const emitInterleavedAssistantToolCalls = | 19 | const emitInterleavedAssistantToolCalls = |
| 20 | process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; | 20 | process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; |
| 21 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; | 21 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; |
| 22 | const emitBackgroundToolDuringAnswer = | ||
| 23 | process.env.T3_ACP_EMIT_BACKGROUND_TOOL_DURING_ANSWER === "1"; | ||
| 22 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; | 24 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; |
| 23 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; | 25 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; |
| 24 | const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; | 26 | const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; |
| ... | @@ -941,6 +943,47 @@ const program = Effect.gen(function* () { | ... | @@ -941,6 +943,47 @@ const program = Effect.gen(function* () { |
| 941 | return yield* Effect.never; | 943 | return yield* Effect.never; |
| 942 | } | 944 | } |
| 943 | 945 | ||
| 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 | |||
| 944 | if (emitInterleavedAssistantToolCalls) { | 987 | if (emitInterleavedAssistantToolCalls) { |
| 945 | const toolCallId = "tool-call-1"; | 988 | const toolCallId = "tool-call-1"; |
| 946 | 989 |
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+48| ... | @@ -824,6 +824,54 @@ describe("AcpSessionRuntime", () => { | ... | @@ -824,6 +824,54 @@ describe("AcpSessionRuntime", () => { |
| 824 | ), | 824 | ), |
| 825 | ); | 825 | ); |
| 826 | 826 | ||
| 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 | |||
| 827 | it.effect("emits status-only tool updates through completion", () => | 875 | it.effect("emits status-only tool updates through completion", () => |
| 828 | Effect.gen(function* () { | 876 | Effect.gen(function* () { |
| 829 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | 877 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; |
apps/server/src/provider/acp/AcpSessionRuntime.ts+21-6| ... | @@ -40,6 +40,8 @@ import { | ... | @@ -40,6 +40,8 @@ import { |
| 40 | type AcpToolCallState, | 40 | type AcpToolCallState, |
| 41 | } from "./AcpRuntimeModel.ts"; | 41 | } from "./AcpRuntimeModel.ts"; |
| 42 | 42 | ||
| 43 | const MAX_SHOWN_TOOL_CALL_IDS = 256; | ||
| 44 | |||
| 43 | interface AcpToolCallTrackedState { | 45 | interface AcpToolCallTrackedState { |
| 44 | readonly state: AcpToolCallState; | 46 | readonly state: AcpToolCallState; |
| 45 | readonly lastEmittedDetailLength: number | undefined; | 47 | readonly lastEmittedDetailLength: number | undefined; |
| ... | @@ -334,6 +336,9 @@ export const make = ( | ... | @@ -334,6 +336,9 @@ export const make = ( |
| 334 | const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>(); | 336 | const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>(); |
| 335 | const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined); | 337 | const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined); |
| 336 | const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallTrackedState>()); | 338 | 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>(); | ||
| 337 | const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( | 342 | const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( |
| 338 | Effect.mapError( | 343 | Effect.mapError( |
| 339 | (cause) => | 344 | (cause) => |
| ... | @@ -525,6 +530,7 @@ export const make = ( | ... | @@ -525,6 +530,7 @@ export const make = ( |
| 525 | modeStateRef, | 530 | modeStateRef, |
| 526 | configOptionsRef, | 531 | configOptionsRef, |
| 527 | toolCallsRef, | 532 | toolCallsRef, |
| 533 | shownToolCallIds, | ||
| 528 | assistantSegmentRef, | 534 | assistantSegmentRef, |
| 529 | assistantItemRuntimeId, | 535 | assistantItemRuntimeId, |
| 530 | params: notification, | 536 | params: notification, |
| ... | @@ -1178,6 +1184,7 @@ const handleSessionUpdate = ({ | ... | @@ -1178,6 +1184,7 @@ const handleSessionUpdate = ({ |
| 1178 | modeStateRef, | 1184 | modeStateRef, |
| 1179 | configOptionsRef, | 1185 | configOptionsRef, |
| 1180 | toolCallsRef, | 1186 | toolCallsRef, |
| 1187 | shownToolCallIds, | ||
| 1181 | assistantSegmentRef, | 1188 | assistantSegmentRef, |
| 1182 | assistantItemRuntimeId, | 1189 | assistantItemRuntimeId, |
| 1183 | params, | 1190 | params, |
| ... | @@ -1186,6 +1193,7 @@ const handleSessionUpdate = ({ | ... | @@ -1186,6 +1193,7 @@ const handleSessionUpdate = ({ |
| 1186 | readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>; | 1193 | readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>; |
| 1187 | readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>; | 1194 | readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>; |
| 1188 | readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>; | 1195 | readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>; |
| 1196 | readonly shownToolCallIds: Set<string>; | ||
| 1189 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | 1197 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; |
| 1190 | readonly assistantItemRuntimeId: string; | 1198 | readonly assistantItemRuntimeId: string; |
| 1191 | readonly params: EffectAcpSchema.SessionNotification; | 1199 | readonly params: EffectAcpSchema.SessionNotification; |
| ... | @@ -1202,11 +1210,7 @@ const handleSessionUpdate = ({ | ... | @@ -1202,11 +1210,7 @@ const handleSessionUpdate = ({ |
| 1202 | } | 1210 | } |
| 1203 | for (const event of parsed.events) { | 1211 | for (const event of parsed.events) { |
| 1204 | if (event._tag === "ToolCallUpdated") { | 1212 | if (event._tag === "ToolCallUpdated") { |
| 1205 | yield* closeActiveAssistantSegment({ | 1213 | const { merged, decision, active } = yield* Ref.modify(toolCallsRef, (current) => { |
| 1206 | queue, | ||
| 1207 | assistantSegmentRef, | ||
| 1208 | }); | ||
| 1209 | const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { | ||
| 1210 | const tracked = current.get(event.toolCall.toolCallId); | 1214 | const tracked = current.get(event.toolCall.toolCallId); |
| 1211 | const previous = tracked?.state; | 1215 | const previous = tracked?.state; |
| 1212 | const nextToolCall = mergeToolCallState(previous, event.toolCall); | 1216 | const nextToolCall = mergeToolCallState(previous, event.toolCall); |
| ... | @@ -1228,11 +1232,22 @@ const handleSessionUpdate = ({ | ... | @@ -1228,11 +1232,22 @@ const handleSessionUpdate = ({ |
| 1228 | skippedSinceEmit: decision.skippedSinceEmit, | 1232 | skippedSinceEmit: decision.skippedSinceEmit, |
| 1229 | }); | 1233 | }); |
| 1230 | } | 1234 | } |
| 1231 | return [{ merged: nextToolCall, decision }, next] as const; | 1235 | return [{ merged: nextToolCall, decision, active: tracked !== undefined }, next] as const; |
| 1232 | }); | 1236 | }); |
| 1233 | if (!decision.emit) { | 1237 | if (!decision.emit) { |
| 1234 | continue; | 1238 | continue; |
| 1235 | } | 1239 | } |
| 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 | } | ||
| 1236 | yield* Queue.offer(queue, { | 1251 | yield* Queue.offer(queue, { |
| 1237 | _tag: "ToolCallUpdated", | 1252 | _tag: "ToolCallUpdated", |
| 1238 | toolCall: merged, | 1253 | toolCall: merged, |