| author | |
| committer | |
| log | afc8342801d487d96732fdad25454f0c1fe96a3b |
| tree | 8260dfc1664efc4e91c2da3be4a6befc5b3153d9 |
| parent | bce680926d38915f3590242c0d1a4309e7ca1b9a |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; | ... | @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import type * as EffectAcpSchema from "effect-acp/schema"; | 3 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| 6 | decideToolCallUpdateEmission, | ||
| 6 | extractModelConfigId, | 7 | extractModelConfigId, |
| 7 | mergeToolCallState, | 8 | mergeToolCallState, |
| 8 | parsePermissionRequest, | 9 | parsePermissionRequest, |
| ... | @@ -10,6 +11,7 @@ import { | ... | @@ -10,6 +11,7 @@ import { |
| 10 | parseSessionUpdateEvent, | 11 | parseSessionUpdateEvent, |
| 11 | sessionUpdateIsReplay, | 12 | sessionUpdateIsReplay, |
| 12 | syntheticLoadSessionResponseFromInitialize, | 13 | syntheticLoadSessionResponseFromInitialize, |
| 14 | type AcpToolCallState, | ||
| 13 | } from "./AcpRuntimeModel.ts"; | 15 | } from "./AcpRuntimeModel.ts"; |
| 14 | 16 | ||
| 15 | describe("AcpRuntimeModel", () => { | 17 | describe("AcpRuntimeModel", () => { |
| ... | @@ -374,4 +376,281 @@ describe("AcpRuntimeModel", () => { | ... | @@ -374,4 +376,281 @@ describe("AcpRuntimeModel", () => { |
| 374 | }, | 376 | }, |
| 375 | }); | 377 | }); |
| 376 | }); | 378 | }); |
| 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 | }); | ||
| 377 | }); | 656 | }); |
apps/server/src/provider/acp/AcpRuntimeModel.ts+164-17| ... | @@ -264,25 +264,97 @@ function extractToolCallCommand(rawInput: unknown, title: string | undefined): s | ... | @@ -264,25 +264,97 @@ function extractToolCallCommand(rawInput: unknown, title: string | undefined): s |
| 264 | return extractCommandFromTitle(title); | 264 | return extractCommandFromTitle(title); |
| 265 | } | 265 | } |
| 266 | 266 | ||
| 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. | ||
| 274 | const TOOL_CALL_CONTENT_MAX_CHARS = 8_000; | ||
| 275 | const TOOL_CALL_CONTENT_TRUNCATION_MARKER = "[Earlier output truncated]\n\n"; | ||
| 276 | |||
| 277 | function 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 | |||
| 285 | const 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. | ||
| 291 | function 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 | |||
| 307 | interface ExtractedToolCallContent { | ||
| 308 | readonly text: string | undefined; | ||
| 309 | readonly content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | undefined; | ||
| 310 | } | ||
| 311 | |||
| 312 | function 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 | |||
| 267 | function extractTextContentFromToolCallContent( | 319 | function extractTextContentFromToolCallContent( |
| 268 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined, | 320 | content: ReadonlyArray<EffectAcpSchema.ToolCallContent> | null | undefined, |
| 269 | ): string | undefined { | 321 | ): ExtractedToolCallContent { |
| 270 | if (!content) return undefined; | 322 | if (!content) { |
| 323 | return { text: undefined, content: undefined }; | ||
| 324 | } | ||
| 271 | const chunks: Array<string> = []; | 325 | const chunks: Array<string> = []; |
| 272 | for (const entry of content) { | 326 | for (const entry of content) { |
| 273 | if (entry.type !== "content") { | 327 | const text = toolCallContentText(entry)?.trim(); |
| 274 | continue; | 328 | if (text) { |
| 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) { | ||
| 282 | chunks.push(text); | 329 | chunks.push(text); |
| 283 | } | 330 | } |
| 284 | } | 331 | } |
| 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 }; | ||
| 286 | } | 358 | } |
| 287 | 359 | ||
| 288 | function normalizeToolKind(kind: unknown): string | undefined { | 360 | function normalizeToolKind(kind: unknown): string | undefined { |
| ... | @@ -326,7 +398,8 @@ function makeToolCallState( | ... | @@ -326,7 +398,8 @@ function makeToolCallState( |
| 326 | } | 398 | } |
| 327 | const title = input.title?.trim() || undefined; | 399 | const title = input.title?.trim() || undefined; |
| 328 | const command = extractToolCallCommand(input.rawInput, title); | 400 | const command = extractToolCallCommand(input.rawInput, title); |
| 329 | const textContent = extractTextContentFromToolCallContent(input.content); | 401 | const extractedContent = extractTextContentFromToolCallContent(input.content); |
| 402 | const textContent = extractedContent.text; | ||
| 330 | const normalizedTitle = | 403 | const normalizedTitle = |
| 331 | title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" | 404 | title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" |
| 332 | ? title | 405 | ? title |
| ... | @@ -343,10 +416,10 @@ function makeToolCallState( | ... | @@ -343,10 +416,10 @@ function makeToolCallState( |
| 343 | data.rawInput = input.rawInput; | 416 | data.rawInput = input.rawInput; |
| 344 | } | 417 | } |
| 345 | if (input.rawOutput !== undefined) { | 418 | if (input.rawOutput !== undefined) { |
| 346 | data.rawOutput = input.rawOutput; | 419 | data.rawOutput = boundToolCallRawOutput(input.rawOutput); |
| 347 | } | 420 | } |
| 348 | if (input.content !== undefined) { | 421 | if (input.content !== undefined) { |
| 349 | data.content = input.content; | 422 | data.content = extractedContent.content ?? input.content; |
| 350 | } | 423 | } |
| 351 | if (input.locations !== undefined) { | 424 | if (input.locations !== undefined) { |
| 352 | data.locations = input.locations; | 425 | data.locations = input.locations; |
| ... | @@ -424,6 +497,53 @@ export function mergeToolCallState( | ... | @@ -424,6 +497,53 @@ export function mergeToolCallState( |
| 424 | }; | 497 | }; |
| 425 | } | 498 | } |
| 426 | 499 | ||
| 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. | ||
| 506 | const TOOL_CALL_UPDATE_MIN_DETAIL_GROWTH_CHARS = 256; | ||
| 507 | const TOOL_CALL_UPDATE_COALESCE_LIMIT = 10; | ||
| 508 | |||
| 509 | export interface AcpToolCallEmitDecisionInput { | ||
| 510 | readonly previous: AcpToolCallState | undefined; | ||
| 511 | readonly next: AcpToolCallState; | ||
| 512 | readonly lastEmittedDetailLength: number | undefined; | ||
| 513 | readonly skippedSinceEmit: number; | ||
| 514 | } | ||
| 515 | |||
| 516 | export interface AcpToolCallEmitDecision { | ||
| 517 | readonly emit: boolean; | ||
| 518 | readonly skippedSinceEmit: number; | ||
| 519 | } | ||
| 520 | |||
| 521 | export 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 | |||
| 427 | export function parsePermissionRequest( | 547 | export function parsePermissionRequest( |
| 428 | params: EffectAcpSchema.RequestPermissionRequest, | 548 | params: EffectAcpSchema.RequestPermissionRequest, |
| 429 | ): AcpPermissionRequest { | 549 | ): AcpPermissionRequest { |
| ... | @@ -505,6 +625,33 @@ export function syntheticLoadSessionResponseFromInitialize( | ... | @@ -505,6 +625,33 @@ export function syntheticLoadSessionResponseFromInitialize( |
| 505 | }; | 625 | }; |
| 506 | } | 626 | } |
| 507 | 627 | ||
| 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. | ||
| 633 | function 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 | |||
| 508 | export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { | 655 | export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { |
| 509 | readonly modeId?: string; | 656 | readonly modeId?: string; |
| 510 | readonly events: ReadonlyArray<AcpParsedSessionEvent>; | 657 | readonly events: ReadonlyArray<AcpParsedSessionEvent>; |
| ... | @@ -548,7 +695,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat | ... | @@ -548,7 +695,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat |
| 548 | events.push({ | 695 | events.push({ |
| 549 | _tag: "ToolCallUpdated", | 696 | _tag: "ToolCallUpdated", |
| 550 | toolCall, | 697 | toolCall, |
| 551 | rawPayload: params, | 698 | rawPayload: boundToolCallRawPayload(params, upd, toolCall), |
| 552 | }); | 699 | }); |
| 553 | } | 700 | } |
| 554 | break; | 701 | break; |
| ... | @@ -559,7 +706,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat | ... | @@ -559,7 +706,7 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat |
| 559 | events.push({ | 706 | events.push({ |
| 560 | _tag: "ToolCallUpdated", | 707 | _tag: "ToolCallUpdated", |
| 561 | toolCall, | 708 | toolCall, |
| 562 | rawPayload: params, | 709 | rawPayload: boundToolCallRawPayload(params, upd, toolCall), |
| 563 | }); | 710 | }); |
| 564 | } | 711 | } |
| 565 | break; | 712 | break; |
apps/server/src/provider/acp/AcpSessionRuntime.ts+27-20| ... | @@ -23,6 +23,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ... | @@ -23,6 +23,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 23 | 23 | ||
| 24 | import { | 24 | import { |
| 25 | collectSessionConfigOptionValues, | 25 | collectSessionConfigOptionValues, |
| 26 | decideToolCallUpdateEmission, | ||
| 26 | extractModelConfigId, | 27 | extractModelConfigId, |
| 27 | findSessionConfigOption, | 28 | findSessionConfigOption, |
| 28 | mergeToolCallState, | 29 | mergeToolCallState, |
| ... | @@ -36,6 +37,12 @@ import { | ... | @@ -36,6 +37,12 @@ import { |
| 36 | type AcpToolCallState, | 37 | type AcpToolCallState, |
| 37 | } from "./AcpRuntimeModel.ts"; | 38 | } from "./AcpRuntimeModel.ts"; |
| 38 | 39 | ||
| 40 | interface AcpToolCallTrackedState { | ||
| 41 | readonly state: AcpToolCallState; | ||
| 42 | readonly lastEmittedDetailLength: number | undefined; | ||
| 43 | readonly skippedSinceEmit: number; | ||
| 44 | } | ||
| 45 | |||
| 39 | function formatConfigOptionValue(value: string | boolean): string { | 46 | function formatConfigOptionValue(value: string | boolean): string { |
| 40 | return JSON.stringify(value); | 47 | return JSON.stringify(value); |
| 41 | } | 48 | } |
| ... | @@ -279,7 +286,7 @@ export const make = ( | ... | @@ -279,7 +286,7 @@ export const make = ( |
| 279 | const runtimeScope = yield* Scope.Scope; | 286 | const runtimeScope = yield* Scope.Scope; |
| 280 | const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>(); | 287 | const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>(); |
| 281 | const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined); | 288 | 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>()); |
| 283 | const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( | 290 | const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( |
| 284 | Effect.mapError( | 291 | Effect.mapError( |
| 285 | (cause) => | 292 | (cause) => |
| ... | @@ -851,7 +858,7 @@ const handleSessionUpdate = ({ | ... | @@ -851,7 +858,7 @@ const handleSessionUpdate = ({ |
| 851 | }: { | 858 | }: { |
| 852 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; | 859 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; |
| 853 | readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>; | 860 | readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>; |
| 854 | readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallState>>; | 861 | readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallTrackedState>>; |
| 855 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | 862 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; |
| 856 | readonly assistantItemRuntimeId: string; | 863 | readonly assistantItemRuntimeId: string; |
| 857 | readonly params: EffectAcpSchema.SessionNotification; | 864 | readonly params: EffectAcpSchema.SessionNotification; |
| ... | @@ -869,18 +876,31 @@ const handleSessionUpdate = ({ | ... | @@ -869,18 +876,31 @@ const handleSessionUpdate = ({ |
| 869 | queue, | 876 | queue, |
| 870 | assistantSegmentRef, | 877 | assistantSegmentRef, |
| 871 | }); | 878 | }); |
| 872 | const { previous, merged } = yield* Ref.modify(toolCallsRef, (current) => { | 879 | const { merged, decision } = yield* Ref.modify(toolCallsRef, (current) => { |
| 873 | const previous = current.get(event.toolCall.toolCallId); | 880 | const tracked = current.get(event.toolCall.toolCallId); |
| 881 | const previous = tracked?.state; | ||
| 874 | const nextToolCall = mergeToolCallState(previous, event.toolCall); | 882 | 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 | }); | ||
| 875 | const next = new Map(current); | 889 | const next = new Map(current); |
| 876 | if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { | 890 | if (nextToolCall.status === "completed" || nextToolCall.status === "failed") { |
| 877 | next.delete(nextToolCall.toolCallId); | 891 | next.delete(nextToolCall.toolCallId); |
| 878 | } else { | 892 | } 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 | }); | ||
| 880 | } | 900 | } |
| 881 | return [{ previous, merged: nextToolCall }, next] as const; | 901 | return [{ merged: nextToolCall, decision }, next] as const; |
| 882 | }); | 902 | }); |
| 883 | if (!shouldEmitToolCallUpdate(previous, merged)) { | 903 | if (!decision.emit) { |
| 884 | continue; | 904 | continue; |
| 885 | } | 905 | } |
| 886 | yield* Queue.offer(queue, { | 906 | yield* Queue.offer(queue, { |
| ... | @@ -926,19 +946,6 @@ function updateModeState(modeState: AcpSessionModeState, nextModeId: string): Ac | ... | @@ -926,19 +946,6 @@ function updateModeState(modeState: AcpSessionModeState, nextModeId: string): Ac |
| 926 | : modeState; | 946 | : modeState; |
| 927 | } | 947 | } |
| 928 | 948 | ||
| 929 | function 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 | |||
| 942 | const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => | 949 | const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => |
| 943 | `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; | 950 | `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; |
| 944 | 951 |