| 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 | 19 | const emitInterleavedAssistantToolCalls = |
| 20 | 20 | process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; |
| 21 | 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 | 24 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; |
| 23 | 25 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; |
| 24 | 26 | const emitXAiExitPlanMode = process.env.T3_ACP_EMIT_XAI_EXIT_PLAN_MODE === "1"; |
| ... | ... | @@ -941,6 +943,47 @@ const program = Effect.gen(function* () { |
| 941 | 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 | 987 | if (emitInterleavedAssistantToolCalls) { |
| 945 | 988 | const toolCallId = "tool-call-1"; |
| 946 | 989 |
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+48| ... | ... | @@ -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 | 875 | it.effect("emits status-only tool updates through completion", () => |
| 828 | 876 | Effect.gen(function* () { |
| 829 | 877 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; |
apps/server/src/provider/acp/AcpSessionRuntime.ts+21-6| ... | ... | @@ -40,6 +40,8 @@ import { |
| 40 | 40 | type AcpToolCallState, |
| 41 | 41 | } from "./AcpRuntimeModel.ts"; |
| 42 | 42 | |
| 43 | const MAX_SHOWN_TOOL_CALL_IDS = 256; | |
| 44 | ||
| 43 | 45 | interface AcpToolCallTrackedState { |
| 44 | 46 | readonly state: AcpToolCallState; |
| 45 | 47 | readonly lastEmittedDetailLength: number | undefined; |
| ... | ... | @@ -334,6 +336,9 @@ export const make = ( |
| 334 | 336 | const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>(); |
| 335 | 337 | const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined); |
| 336 | 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 | 342 | const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( |
| 338 | 343 | Effect.mapError( |
| 339 | 344 | (cause) => |
| ... | ... | @@ -525,6 +530,7 @@ export const make = ( |
| 525 | 530 | modeStateRef, |
| 526 | 531 | configOptionsRef, |
| 527 | 532 | toolCallsRef, |
| 533 | shownToolCallIds, | |
| 528 | 534 | assistantSegmentRef, |
| 529 | 535 | assistantItemRuntimeId, |
| 530 | 536 | params: notification, |
| ... | ... | @@ -1178,6 +1184,7 @@ const handleSessionUpdate = ({ |
| 1178 | 1184 | modeStateRef, |
| 1179 | 1185 | configOptionsRef, |
| 1180 | 1186 | toolCallsRef, |
| 1187 | shownToolCallIds, | |
| 1181 | 1188 | assistantSegmentRef, |
| 1182 | 1189 | assistantItemRuntimeId, |
| 1183 | 1190 | params, |
| ... | ... | @@ -1186,6 +1193,7 @@ const handleSessionUpdate = ({ |
| 1186 | 1193 | readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>; |
| 1187 | 1194 | readonly configOptionsRef: Ref.Ref<ReadonlyArray<EffectAcpSchema.SessionConfigOption>>; |
| 1188 | 1195 | readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>; |
| 1196 | readonly shownToolCallIds: Set<string>; | |
| 1189 | 1197 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; |
| 1190 | 1198 | readonly assistantItemRuntimeId: string; |
| 1191 | 1199 | readonly params: EffectAcpSchema.SessionNotification; |
| ... | ... | @@ -1202,11 +1210,7 @@ const handleSessionUpdate = ({ |
| 1202 | 1210 | } |
| 1203 | 1211 | for (const event of parsed.events) { |
| 1204 | 1212 | 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) => { | |
| 1210 | 1214 | const tracked = current.get(event.toolCall.toolCallId); |
| 1211 | 1215 | const previous = tracked?.state; |
| 1212 | 1216 | const nextToolCall = mergeToolCallState(previous, event.toolCall); |
| ... | ... | @@ -1228,11 +1232,22 @@ const handleSessionUpdate = ({ |
| 1228 | 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 | 1237 | if (!decision.emit) { |
| 1234 | 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 | 1251 | yield* Queue.offer(queue, { |
| 1237 | 1252 | _tag: "ToolCallUpdated", |
| 1238 | 1253 | toolCall: merged, |