authorgravatar for 35393046+lnieuwenhuis@users.noreply.github.comLars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> 2026-08-25 08:48:44+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-24 23:48:44-07:00
logafc8342801d487d96732fdad25454f0c1fe96a3b
tree8260dfc1664efc4e91c2da3be4a6befc5b3153d9
parentbce680926d38915f3590242c0d1a4309e7ca1b9a
signature Signed by PGP key B5690EEEBB952194

fix(grok): bound cumulative tool output updates (#7279)


3 files changed, 470 insertions(+), 37 deletions(-)

apps/server/src/provider/acp/AcpRuntimeModel.test.ts+279
......@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
33import type * as EffectAcpSchema from "effect-acp/schema";
44
55import {
6 decideToolCallUpdateEmission,
67 extractModelConfigId,
78 mergeToolCallState,
89 parsePermissionRequest,
......@@ -10,6 +11,7 @@ import {
1011 parseSessionUpdateEvent,
1112 sessionUpdateIsReplay,
1213 syntheticLoadSessionResponseFromInitialize,
14 type AcpToolCallState,
1315} from "./AcpRuntimeModel.ts";
1416
1517describe("AcpRuntimeModel", () => {
......@@ -374,4 +376,281 @@ describe("AcpRuntimeModel", () => {
374376 },
375377 });
376378 });
379
380 it("bounds an oversized cumulative tool_call_update content buffer to a tail window", () => {
381 // Mirrors Grok's ACP CLI resending the ENTIRE accumulated terminal output on every
382 // tool_call_update notification instead of a delta (see upstream #6556).
383 const hugeText = Array.from({ length: 2_000 }, (_, i) => `line ${i}: ${"x".repeat(50)}`).join(
384 "\n",
385 );
386 expect(hugeText.length).toBeGreaterThan(60_000);
387
388 const result = parseSessionUpdateEvent({
389 sessionId: "session-1",
390 update: {
391 // Real ACP `tool_call_update` deltas typically omit `title` (already established by
392 // the initial `tool_call`); that is also the shape that surfaces raw content as detail.
393 sessionUpdate: "tool_call_update",
394 toolCallId: "tool-1",
395 kind: "other",
396 status: "in_progress",
397 content: [{ type: "content", content: { type: "text", text: hugeText } }],
398 },
399 } satisfies EffectAcpSchema.SessionNotification);
400
401 expect(result.events).toHaveLength(1);
402 const event = result.events[0];
403 if (event?._tag !== "ToolCallUpdated") {
404 throw new Error("expected a ToolCallUpdated event");
405 }
406
407 expect(event.toolCall.detail).toBeDefined();
408 const detail = event.toolCall.detail!;
409 // 8000 chars of tail plus the truncation marker, regardless of input size.
410 expect(detail.length).toBe(8_028);
411 expect(detail.startsWith("[Earlier output truncated]")).toBe(true);
412 expect(detail.endsWith(hugeText.slice(-100))).toBe(true);
413
414 // The raw payload threaded through for logging/persistence must not smuggle the full
415 // cumulative buffer back in either.
416 const rawUpdate = (
417 event.rawPayload as {
418 readonly update: {
419 readonly content: ReadonlyArray<{ readonly content: { text: string } }>;
420 };
421 }
422 ).update;
423 expect(rawUpdate.content[0]?.content.text.length).toBeLessThan(8_100);
424 expect(JSON.stringify(event).length).toBeLessThan(hugeText.length);
425 });
426
427 it("coalesces 1000 rapid cumulative tool_call_update notifications for a redrawing progress bar", () => {
428 let previous: AcpToolCallState | undefined;
429 let lastEmittedDetailLength: number | undefined;
430 let skippedSinceEmit = 0;
431 let emittedCount = 0;
432 let emittedBytes = 0;
433 let notificationBytes = 0;
434 let largestEmittedEventBytes = 0;
435 let finalDetail: string | undefined;
436 let cumulativeBuffer = "";
437
438 for (let i = 0; i < 1_000; i += 1) {
439 // Grok resends the FULL accumulated buffer, not a delta, on every redraw.
440 cumulativeBuffer += `frame ${i}: ${"#".repeat(50)}\n`;
441 const isLast = i === 999;
442
443 const notification = {
444 sessionId: "session-1",
445 update: {
446 sessionUpdate: "tool_call_update",
447 toolCallId: "tool-1",
448 kind: "other",
449 status: isLast ? "completed" : "in_progress",
450 content: [{ type: "content", content: { type: "text", text: cumulativeBuffer } }],
451 },
452 } satisfies EffectAcpSchema.SessionNotification;
453 notificationBytes += JSON.stringify(notification).length;
454
455 const { events } = parseSessionUpdateEvent(notification);
456
457 const event = events[0];
458 if (event?._tag !== "ToolCallUpdated") {
459 continue;
460 }
461
462 const merged = mergeToolCallState(previous, event.toolCall);
463 const decision = decideToolCallUpdateEmission({
464 previous,
465 next: merged,
466 lastEmittedDetailLength,
467 skippedSinceEmit,
468 });
469 previous = merged;
470 skippedSinceEmit = decision.skippedSinceEmit;
471 if (decision.emit) {
472 emittedCount += 1;
473 const eventBytes = JSON.stringify({
474 toolCall: merged,
475 rawPayload: event.rawPayload,
476 }).length;
477 emittedBytes += eventBytes;
478 largestEmittedEventBytes = Math.max(largestEmittedEventBytes, eventBytes);
479 lastEmittedDetailLength = merged.detail?.length;
480 finalDetail = merged.detail;
481 }
482 }
483
484 // The flood as the CLI sends it: 1000 cumulative redraws, ~31.6 MB of JSON.
485 expect(notificationBytes).toBeGreaterThan(31_000_000);
486
487 // 1000 cumulative redraws collapse into a fixed, small number of runtime events...
488 expect(emittedCount).toBe(114);
489 // ...each individually bounded, no matter how long the tool call runs...
490 expect(largestEmittedEventBytes).toBeLessThan(25_000);
491 // ...so the whole flooding tool call costs ~2.5 MB of runtime events instead of ~31.6 MB.
492 expect(emittedBytes).toBeLessThan(2_600_000);
493 // ...while the FINAL state (forced by the completed status) still reflects the real,
494 // latest output rather than a stale coalesced value.
495 expect(finalDetail).toBeDefined();
496 expect(finalDetail?.endsWith(`frame 999: ${"#".repeat(50)}`)).toBe(true);
497 });
498
499 it("keeps non-text tool call content entries in order when bounding oversized text", () => {
500 const hugePrefix = "x".repeat(25_000);
501 const hugeTail = "y".repeat(25_000);
502 const { events } = parseSessionUpdateEvent({
503 sessionId: "session-1",
504 update: {
505 sessionUpdate: "tool_call_update",
506 toolCallId: "tool-1",
507 kind: "edit",
508 status: "in_progress",
509 content: [
510 { type: "content", content: { type: "text", text: hugePrefix } },
511 { type: "diff", path: "/repo/file.ts", oldText: "before", newText: "after" },
512 { type: "content", content: { type: "text", text: hugeTail } },
513 { type: "diff", path: "/repo/other.ts", oldText: "old", newText: "new" },
514 { type: "content", content: { type: "text", text: " " } },
515 ],
516 },
517 } satisfies EffectAcpSchema.SessionNotification);
518
519 const event = events[0];
520 if (event?._tag !== "ToolCallUpdated") {
521 throw new Error("expected a ToolCallUpdated event");
522 }
523 const content = event.toolCall.data.content as ReadonlyArray<EffectAcpSchema.ToolCallContent>;
524 expect(content).toHaveLength(3);
525 expect(content[0]).toEqual({
526 type: "diff",
527 path: "/repo/file.ts",
528 oldText: "before",
529 newText: "after",
530 });
531 const lastEntry = content[1];
532 if (lastEntry?.type !== "content" || lastEntry.content.type !== "text") {
533 throw new Error("expected a bounded text entry");
534 }
535 expect(lastEntry.content.text.length).toBeLessThan(8_100);
536 expect(lastEntry.content.text.endsWith(hugeTail.slice(-100))).toBe(true);
537 expect(content[2]).toEqual({
538 type: "diff",
539 path: "/repo/other.ts",
540 oldText: "old",
541 newText: "new",
542 });
543 });
544
545 describe("decideToolCallUpdateEmission", () => {
546 const toolCall = (detail: string | undefined, status?: AcpToolCallState["status"]) =>
547 ({
548 toolCallId: "tool-1",
549 title: "Grok Tool",
550 ...(status ? { status } : {}),
551 ...(detail ? { detail } : {}),
552 data: {},
553 }) satisfies AcpToolCallState;
554
555 it("always emits terminal (completed/failed) status updates regardless of growth", () => {
556 expect(
557 decideToolCallUpdateEmission({
558 previous: toolCall("same", "inProgress"),
559 next: toolCall("same", "completed"),
560 lastEmittedDetailLength: 4,
561 skippedSinceEmit: 0,
562 }),
563 ).toEqual({ emit: true, skippedSinceEmit: 0 });
564
565 expect(
566 decideToolCallUpdateEmission({
567 previous: toolCall("same", "inProgress"),
568 next: toolCall("same", "failed"),
569 lastEmittedDetailLength: 4,
570 skippedSinceEmit: 3,
571 }),
572 ).toEqual({ emit: true, skippedSinceEmit: 0 });
573 });
574
575 it("skips updates whose bounded detail did not change", () => {
576 expect(
577 decideToolCallUpdateEmission({
578 previous: toolCall("frame 1", "inProgress"),
579 next: toolCall("frame 1", "inProgress"),
580 lastEmittedDetailLength: 7,
581 skippedSinceEmit: 0,
582 }),
583 ).toEqual({ emit: false, skippedSinceEmit: 0 });
584 });
585
586 it("emits immediately when the title changes, even with no growth", () => {
587 const decision = decideToolCallUpdateEmission({
588 previous: { toolCallId: "tool-1", title: "Reading file", detail: "x", data: {} },
589 next: { toolCallId: "tool-1", title: "Ran command", detail: "x", data: {} },
590 lastEmittedDetailLength: 1,
591 skippedSinceEmit: 0,
592 });
593 expect(decision).toEqual({ emit: true, skippedSinceEmit: 0 });
594 });
595
596 it("coalesces small deltas but forces an emission after the coalesce limit", () => {
597 let lastEmittedDetailLength: number | undefined = 0;
598 let skippedSinceEmit = 0;
599 const emissions: Array<boolean> = [];
600 let previous: AcpToolCallState | undefined;
601
602 for (let i = 1; i <= 12; i += 1) {
603 // Grows by 1 char per update — well under the 256-char growth threshold, so this
604 // exercises the coalesce-count fallback rather than the growth-based trigger.
605 const next = toolCall("x".repeat(i), "inProgress");
606 const decision = decideToolCallUpdateEmission({
607 previous,
608 next,
609 lastEmittedDetailLength,
610 skippedSinceEmit,
611 });
612 emissions.push(decision.emit);
613 skippedSinceEmit = decision.skippedSinceEmit;
614 if (decision.emit) {
615 lastEmittedDetailLength = next.detail?.length;
616 }
617 previous = next;
618 }
619
620 // First update always emits (no previous state yet); after that, small per-update
621 // growth should be coalesced until the coalesce limit forces a periodic emission.
622 const emittedIndices = emissions.flatMap((emitted, index) => (emitted ? [index + 1] : []));
623 expect(emittedIndices).toEqual([1, 11]);
624 });
625
626 it("retains the latest replacement snapshot when equal-length updates are coalesced", () => {
627 let previous: AcpToolCallState = toolCall("frame-a", "inProgress");
628 const lastEmittedDetailLength = previous.detail?.length;
629 let skippedSinceEmit = 0;
630
631 for (const detail of ["frame-b", "frame-c"]) {
632 const next = mergeToolCallState(previous, toolCall(detail, "inProgress"));
633 const decision = decideToolCallUpdateEmission({
634 previous,
635 next,
636 lastEmittedDetailLength,
637 skippedSinceEmit,
638 });
639 expect(decision.emit).toBe(false);
640 skippedSinceEmit = decision.skippedSinceEmit;
641 previous = next;
642 }
643
644 const completed = mergeToolCallState(previous, toolCall(undefined, "completed"));
645 expect(completed.detail).toBe("frame-c");
646 expect(
647 decideToolCallUpdateEmission({
648 previous,
649 next: completed,
650 lastEmittedDetailLength,
651 skippedSinceEmit,
652 }),
653 ).toEqual({ emit: true, skippedSinceEmit: 0 });
654 });
655 });
377656});
apps/server/src/provider/acp/AcpRuntimeModel.ts+164-17
......@@ -264,25 +264,97 @@ function extractToolCallCommand(rawInput: unknown, title: string | undefined): s
264264 return extractCommandFromTitle(title);
265265}
266266
267// Some ACP agents (observed with Grok's CLI) resend the ENTIRE accumulated tool-call
268// output on every `tool_call_update` notification instead of a delta, so a redrawing
269// terminal progress bar can balloon a single tool call to hundreds of KB per update at
270// several updates per second. Cap what we retain/emit to a bounded tail so one busy tool
271// call cannot flood runtime event ingestion. We always keep the tail: `tool_call_update`
272// deltas routinely omit `kind`, so there is no reliable way to tell a redrawing terminal
273// from another tool here, and the end is the useful part of any live-growing output.
274const TOOL_CALL_CONTENT_MAX_CHARS = 8_000;
275const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n";
276
277function boundToolCallOutputText(text: string): string {
278 if (text.length <= TOOL_CALL_CONTENT_MAX_CHARS) {
279 return text;
280 }
281 const tail = text.slice(text.length - TOOL_CALL_CONTENT_MAX_CHARS);
282 return `${TOOL_CALL_CONTENT_TRUNCATION_MARKER}${tail}`;
283}
284
285const RAW_OUTPUT_TEXT_FIELDS = ["content", "stdout", "stderr", "output"] as const;
286
287// `rawOutput` is provider-defined and, for terminal-shaped tools, mirrors the same
288// cumulative text-growth problem as `content` (see the comment above). Bound its known
289// text-bearing fields the same way so a chatty provider cannot smuggle unbounded output
290// through this field instead.
291function boundToolCallRawOutput(rawOutput: unknown): unknown {
292 if (!isRecord(rawOutput)) {
293 return rawOutput;
294 }
295 let changed = false;
296 const bounded: Record<string, unknown> = { ...rawOutput };
297 for (const field of RAW_OUTPUT_TEXT_FIELDS) {
298 const value = rawOutput[field];
299 if (typeof value === "string" && value.length > TOOL_CALL_CONTENT_MAX_CHARS) {
300 bounded[field] = boundToolCallOutputText(value);
301 changed = true;
302 }
303 }
304 return changed ? bounded : rawOutput;
305}
306
307interface ExtractedToolCallContent {
308 readonly text: string | undefined;
309 readonly content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | undefined;
310}
311
312function toolCallContentText(entry: EffectAcpSchema.ToolCallContent): string | undefined {
313 if (entry.type !== "content" || entry.content.type !== "text") {
314 return undefined;
315 }
316 return entry.content.text;
317}
318
267319function extractTextContentFromToolCallContent(
268320 content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined,
269): string | undefined {
270 if (!content) return undefined;
321): ExtractedToolCallContent {
322 if (!content) {
323 return { text: undefined, content: undefined };
324 }
271325 const chunks: Array<string> = [];
272326 for (const entry of content) {
273 if (entry.type !== "content") {
274 continue;
275 }
276 const nestedContent = entry.content;
277 if (nestedContent.type !== "text") {
278 continue;
279 }
280 const text = nestedContent.text.trim();
281 if (text.length > 0) {
327 const text = toolCallContentText(entry)?.trim();
328 if (text) {
282329 chunks.push(text);
283330 }
284331 }
285 return chunks.length > 0 ? chunks.join("\n") : undefined;
332 if (chunks.length === 0) {
333 return { text: undefined, content };
334 }
335 const joined = chunks.join("\n");
336 if (joined.length <= TOOL_CALL_CONTENT_MAX_CHARS) {
337 return { text: joined, content };
338 }
339 const bounded = boundToolCallOutputText(joined);
340 // Collapse the text entries into a single bounded one at the final contributing text entry,
341 // and leave every other entry kind (diffs, images, resource links) in its original relative
342 // order. The retained tail came from that text entry, so placing it there also preserves its
343 // ordering relative to interleaved non-text content and ignores later blank text entries.
344 const lastContributingTextIndex = content.reduce(
345 (lastIndex, entry, index) => (toolCallContentText(entry)?.trim() ? index : lastIndex),
346 -1,
347 );
348 const boundedContent = content.flatMap((entry, index) => {
349 if (toolCallContentText(entry) === undefined) {
350 return [entry];
351 }
352 if (index !== lastContributingTextIndex) {
353 return [];
354 }
355 return [{ type: "content", content: { type: "text", text: bounded } } as const];
356 });
357 return { text: bounded, content: boundedContent };
286358}
287359
288360function normalizeToolKind(kind: unknown): string | undefined {
......@@ -326,7 +398,8 @@ function makeToolCallState(
326398 }
327399 const title = input.title?.trim() || undefined;
328400 const command = extractToolCallCommand(input.rawInput, title);
329 const textContent = extractTextContentFromToolCallContent(input.content);
401 const extractedContent = extractTextContentFromToolCallContent(input.content);
402 const textContent = extractedContent.text;
330403 const normalizedTitle =
331404 title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call"
332405 ? title
......@@ -343,10 +416,10 @@ function makeToolCallState(
343416 data.rawInput = input.rawInput;
344417 }
345418 if (input.rawOutput !== undefined) {
346 data.rawOutput = input.rawOutput;
419 data.rawOutput = boundToolCallRawOutput(input.rawOutput);
347420 }
348421 if (input.content !== undefined) {
349 data.content = input.content;
422 data.content = extractedContent.content ?? input.content;
350423 }
351424 if (input.locations !== undefined) {
352425 data.locations = input.locations;
......@@ -424,6 +497,53 @@ export function mergeToolCallState(
424497 };
425498}
426499
500// Even with bounded content (see TOOL_CALL_CONTENT_MAX_CHARS above), a redrawing terminal
501// can still shift its bounded tail window on nearly every notification, which would emit
502// a runtime event per redraw. Coalesce those: only emit early when the tool call's detail
503// has grown meaningfully since the last emission, otherwise batch up to a small number of
504// skipped updates before emitting anyway, so the UI still gets periodic progress and the
505// final (completed/failed) state is always emitted immediately.
506const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256;
507const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10;
508
509export interface AcpToolCallEmitDecisionInput {
510 readonly previous: AcpToolCallState | undefined;
511 readonly next: AcpToolCallState;
512 readonly lastEmittedDetailLength: number | undefined;
513 readonly skippedSinceEmit: number;
514}
515
516export interface AcpToolCallEmitDecision {
517 readonly emit: boolean;
518 readonly skippedSinceEmit: number;
519}
520
521export function decideToolCallUpdateEmission(
522 input: AcpToolCallEmitDecisionInput,
523): AcpToolCallEmitDecision {
524 const { previous, next, lastEmittedDetailLength, skippedSinceEmit } = input;
525 if (next.status === "completed" || next.status === "failed") {
526 return { emit: true, skippedSinceEmit: 0 };
527 }
528 if (!next.detail) {
529 return { emit: false, skippedSinceEmit };
530 }
531 if (previous === undefined || previous.title !== next.title) {
532 return { emit: true, skippedSinceEmit: 0 };
533 }
534 if (previous.detail === next.detail) {
535 return { emit: false, skippedSinceEmit };
536 }
537 const grewMeaningfully =
538 lastEmittedDetailLength === undefined ||
539 Math.abs(next.detail.length - lastEmittedDetailLength) >=
540 TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS;
541 if (grewMeaningfully || skippedSinceEmit + 1 >= TOOL_CALL_UPDATE_COALESCE_LIMIT) {
542 return { emit: true, skippedSinceEmit: 0 };
543 }
544 return { emit: false, skippedSinceEmit: skippedSinceEmit + 1 };
545}
546
427547export function parsePermissionRequest(
428548 params: EffectAcpSchema.RequestPermissionRequest,
429549): AcpPermissionRequest {
......@@ -505,6 +625,33 @@ export function syntheticLoadSessionResponseFromInitialize(
505625 };
506626}
507627
628// The parsed AcpToolCallState already carries bounded content (see makeToolCallState /
629// extractTextContentFromToolCallContent above), but the raw JSON-RPC notification is also
630// threaded through as `rawPayload` for logging/debugging and ends up persisted on the
631// runtime event. Substitute the same bounded `content`/`rawOutput` there so an oversized
632// cumulative update cannot smuggle the unbounded buffer back in through the raw payload.
633function boundToolCallRawPayload(
634 params: EffectAcpSchema.SessionNotification,
635 update: AcpToolCallUpdate,
636 toolCall: AcpToolCallState,
637): unknown {
638 const boundedContent = toolCall.data.content;
639 const boundedRawOutput = toolCall.data.rawOutput;
640 const contentBounded = update.content !== undefined && boundedContent !== update.content;
641 const rawOutputBounded = update.rawOutput !== undefined && boundedRawOutput !== update.rawOutput;
642 if (!contentBounded && !rawOutputBounded) {
643 return params;
644 }
645 return {
646 ...params,
647 update: {
648 ...update,
649 ...(contentBounded ? { content: boundedContent } : {}),
650 ...(rawOutputBounded ? { rawOutput: boundedRawOutput } : {}),
651 },
652 };
653}
654
508655export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): {
509656 readonly modeId?: string;
510657 readonly events: ReadonlyArray<AcpParsedSessionEvent>;
......@@ -548,7 +695,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat
548695 events.push({
549696 _tag: "ToolCallUpdated",
550697 toolCall,
551 rawPayload: params,
698 rawPayload: boundToolCallRawPayload(params, upd, toolCall),
552699 });
553700 }
554701 break;
......@@ -559,7 +706,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat
559706 events.push({
560707 _tag: "ToolCallUpdated",
561708 toolCall,
562 rawPayload: params,
709 rawPayload: boundToolCallRawPayload(params, upd, toolCall),
563710 });
564711 }
565712 break;
apps/server/src/provider/acp/AcpSessionRuntime.ts+27-20
......@@ -23,6 +23,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell";
2323
2424import {
2525 collectSessionConfigOptionValues,
26 decideToolCallUpdateEmission,
2627 extractModelConfigId,
2728 findSessionConfigOption,
2829 mergeToolCallState,
......@@ -36,6 +37,12 @@ import {
3637 type AcpToolCallState,
3738} from "./AcpRuntimeModel.ts";
3839
40interface AcpToolCallTrackedState {
41 readonly state: AcpToolCallState;
42 readonly lastEmittedDetailLength: number | undefined;
43 readonly skippedSinceEmit: number;
44}
45
3946function formatConfigOptionValue(value: string | boolean): string {
4047 return JSON.stringify(value);
4148}
......@@ -279,7 +286,7 @@ export const make = (
279286 const runtimeScope = yield* Scope.Scope;
280287 const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>();
281288 const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined);
282 const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallState>());
289 const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallTrackedState>());
283290 const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe(
284291 Effect.mapError(
285292 (cause) =>
......@@ -851,7 +858,7 @@ const handleSessionUpdate = ({
851858}: {
852859 readonly queue: Queue.Queue<AcpSessionRuntimeEvent>;
853860 readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>;
854 readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallState>>;
861 readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>;
855862 readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>;
856863 readonly assistantItemRuntimeId: string;
857864 readonly params: EffectAcpSchema.SessionNotification;
......@@ -869,18 +876,31 @@ const handleSessionUpdate = ({
869876 queue,
870877 assistantSegmentRef,
871878 });
872 const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => {
873 const previous = current.get(event.toolCall.toolCallId);
879 const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => {
880 const tracked = current.get(event.toolCall.toolCallId);
881 const previous = tracked?.state;
874882 const nextToolCall = mergeToolCallState(previous, event.toolCall);
883 const decision = decideToolCallUpdateEmission({
884 previous,
885 next: nextToolCall,
886 lastEmittedDetailLength: tracked?.lastEmittedDetailLength,
887 skippedSinceEmit: tracked?.skippedSinceEmit ?? 0,
888 });
875889 const next = new Map(current);
876890 if (nextToolCall.status === "completed" || nextToolCall.status === "failed") {
877891 next.delete(nextToolCall.toolCallId);
878892 } else {
879 next.set(nextToolCall.toolCallId, nextToolCall);
893 next.set(nextToolCall.toolCallId, {
894 state: nextToolCall,
895 lastEmittedDetailLength: decision.emit
896 ? nextToolCall.detail?.length
897 : tracked?.lastEmittedDetailLength,
898 skippedSinceEmit: decision.skippedSinceEmit,
899 });
880900 }
881 return [{ previous, merged: nextToolCall }, next] as const;
901 return [{ merged: nextToolCall, decision }, next] as const;
882902 });
883 if (!shouldEmitToolCallUpdate(previous, merged)) {
903 if (!decision.emit) {
884904 continue;
885905 }
886906 yield* Queue.offer(queue, {
......@@ -926,19 +946,6 @@ function updateModeState(modeState: AcpSessionModeState, nextModeId: string): Ac
926946 : modeState;
927947}
928948
929function shouldEmitToolCallUpdate(
930 previous: AcpToolCallState | undefined,
931 next: AcpToolCallState,
932): boolean {
933 if (next.status === "completed" || next.status === "failed") {
934 return true;
935 }
936 if (!next.detail) {
937 return false;
938 }
939 return previous === undefined || previous.title !== next.title || previous.detail !== next.detail;
940}
941
942949const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) =>
943950 `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`;
944951