|
1 | | -import { describe, expect, test } from "bun:test" |
| 1 | +import { describe, expect, spyOn, test } from "bun:test" |
2 | 2 | import { |
3 | 3 | FIXTURE_BUSY_SESSION, |
4 | 4 | attachSessionBridge, |
5 | 5 | createRecordingPort, |
6 | 6 | mapReactorLike, |
| 7 | + type TaskProgressSession, |
7 | 8 | } from "./runtime-bridge" |
8 | | -import { createAppShell } from "./shell" |
| 9 | +import { appendStreamRow, createAppShell, streamRowCount } from "./shell" |
9 | 10 | import { withTestRenderer } from "./harness" |
10 | 11 | import { badgeCount } from "./session-queue" |
11 | 12 |
|
@@ -600,3 +601,113 @@ describe("parallel sub-agent dispatch on the live session bridge", () => { |
600 | 601 | ) |
601 | 602 | }) |
602 | 603 | }) |
| 604 | + |
| 605 | +describe("syncAgentProgress", () => { |
| 606 | + function taskSession(over: Partial<TaskProgressSession>): TaskProgressSession { |
| 607 | + return { |
| 608 | + id: "task-1", |
| 609 | + status: "running", |
| 610 | + currentToolName: "grep", |
| 611 | + startedAt: 0, |
| 612 | + lastActivityAt: 0, |
| 613 | + ...over, |
| 614 | + } |
| 615 | + } |
| 616 | + |
| 617 | + test("updates the dispatch row in place without appending or removing rows", async () => { |
| 618 | + await withTestRenderer( |
| 619 | + async (h) => { |
| 620 | + const shell = createAppShell(h.renderer, { |
| 621 | + terminal: { columns: 80, rows: 24 }, |
| 622 | + wireKeys: false, |
| 623 | + run: "busy", |
| 624 | + }) |
| 625 | + // Padding rows ahead of the dispatch: proves churn stays bounded by |
| 626 | + // outstanding task calls, not by transcript length. |
| 627 | + for (let i = 0; i < 40; i++) { |
| 628 | + appendStreamRow(shell, { role: "assistant", text: `filler ${i}` }) |
| 629 | + } |
| 630 | + let nowMs = 0 |
| 631 | + const bridge = attachSessionBridge(shell, createRecordingPort(), { |
| 632 | + now: () => nowMs, |
| 633 | + }) |
| 634 | + try { |
| 635 | + bridge.handle({ |
| 636 | + type: "inference.tool_call.end", |
| 637 | + data: { |
| 638 | + name: "task", |
| 639 | + callId: "task-1", |
| 640 | + arguments: { description: "Review permission gate" }, |
| 641 | + }, |
| 642 | + }) |
| 643 | + await h.renderOnce() |
| 644 | + const rowCountBefore = streamRowCount(shell) |
| 645 | + const removeSpy = spyOn(shell.transcript, "remove") |
| 646 | + |
| 647 | + nowMs = 42_000 |
| 648 | + bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]) |
| 649 | + bridge.syncAgentProgress([ |
| 650 | + taskSession({ currentToolName: "grep", lastActivityAt: nowMs }), |
| 651 | + ]) |
| 652 | + |
| 653 | + expect(streamRowCount(shell)).toBe(rowCountBefore) |
| 654 | + // One rewrite per changed tick, never proportional to the 40 padding rows. |
| 655 | + expect(removeSpy.mock.calls.length).toBeLessThanOrEqual(2) |
| 656 | + |
| 657 | + const row = shell.streamLog[rowCountBefore - 1]! |
| 658 | + expect(row.pending).toBe(true) |
| 659 | + expect(row.agentWorking).toBe(true) |
| 660 | + expect(row.stat).toContain("grep") |
| 661 | + |
| 662 | + nowMs = 72_000 |
| 663 | + bridge.syncAgentProgress([ |
| 664 | + taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }), |
| 665 | + ]) |
| 666 | + const stalledRow = shell.streamLog[rowCountBefore - 1]! |
| 667 | + expect(stalledRow.agentWorking).toBe(false) |
| 668 | + |
| 669 | + removeSpy.mockRestore() |
| 670 | + } finally { |
| 671 | + bridge.dispose() |
| 672 | + shell.dispose() |
| 673 | + } |
| 674 | + }, |
| 675 | + { width: 80, height: 24 }, |
| 676 | + ) |
| 677 | + }) |
| 678 | + |
| 679 | + test("a finished session's row is left to the tool-result path", async () => { |
| 680 | + await withTestRenderer( |
| 681 | + async (h) => { |
| 682 | + const shell = createAppShell(h.renderer, { |
| 683 | + terminal: { columns: 80, rows: 24 }, |
| 684 | + wireKeys: false, |
| 685 | + run: "busy", |
| 686 | + }) |
| 687 | + const bridge = attachSessionBridge(shell, createRecordingPort()) |
| 688 | + try { |
| 689 | + bridge.handle({ |
| 690 | + type: "inference.tool_call.end", |
| 691 | + data: { |
| 692 | + name: "task", |
| 693 | + callId: "task-1", |
| 694 | + arguments: { description: "Review mouse/paste" }, |
| 695 | + }, |
| 696 | + }) |
| 697 | + bridge.handle({ |
| 698 | + type: "tool.done", |
| 699 | + data: { result: { callId: "task-1", name: "task", content: "done", isError: false } }, |
| 700 | + }) |
| 701 | + const index = shell.streamLog.length - 1 |
| 702 | + bridge.syncAgentProgress([taskSession({ status: "done" })]) |
| 703 | + expect(shell.streamLog[index]!.pending).not.toBe(true) |
| 704 | + expect(shell.streamLog[index]!.agentWorking).toBeUndefined() |
| 705 | + } finally { |
| 706 | + bridge.dispose() |
| 707 | + shell.dispose() |
| 708 | + } |
| 709 | + }, |
| 710 | + { width: 80, height: 24 }, |
| 711 | + ) |
| 712 | + }) |
| 713 | +}) |
0 commit comments