|
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 |
|
@@ -432,3 +433,113 @@ describe("committed inference retry", () => { |
432 | 433 | ) |
433 | 434 | }) |
434 | 435 | }) |
| 436 | + |
| 437 | +describe("syncAgentProgress", () => { |
| 438 | + function taskSession(over: Partial<TaskProgressSession>): TaskProgressSession { |
| 439 | + return { |
| 440 | + id: "task-1", |
| 441 | + status: "running", |
| 442 | + currentToolName: "grep", |
| 443 | + startedAt: 0, |
| 444 | + lastActivityAt: 0, |
| 445 | + ...over, |
| 446 | + } |
| 447 | + } |
| 448 | + |
| 449 | + test("updates the dispatch row in place without appending or removing rows", async () => { |
| 450 | + await withTestRenderer( |
| 451 | + async (h) => { |
| 452 | + const shell = createAppShell(h.renderer, { |
| 453 | + terminal: { columns: 80, rows: 24 }, |
| 454 | + wireKeys: false, |
| 455 | + run: "busy", |
| 456 | + }) |
| 457 | + // Padding rows ahead of the dispatch: proves churn stays bounded by |
| 458 | + // outstanding task calls, not by transcript length. |
| 459 | + for (let i = 0; i < 40; i++) { |
| 460 | + appendStreamRow(shell, { role: "assistant", text: `filler ${i}` }) |
| 461 | + } |
| 462 | + let nowMs = 0 |
| 463 | + const bridge = attachSessionBridge(shell, createRecordingPort(), { |
| 464 | + now: () => nowMs, |
| 465 | + }) |
| 466 | + try { |
| 467 | + bridge.handle({ |
| 468 | + type: "inference.tool_call.end", |
| 469 | + data: { |
| 470 | + name: "task", |
| 471 | + callId: "task-1", |
| 472 | + arguments: { description: "Review permission gate" }, |
| 473 | + }, |
| 474 | + }) |
| 475 | + await h.renderOnce() |
| 476 | + const rowCountBefore = streamRowCount(shell) |
| 477 | + const removeSpy = spyOn(shell.transcript, "remove") |
| 478 | + |
| 479 | + nowMs = 42_000 |
| 480 | + bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]) |
| 481 | + bridge.syncAgentProgress([ |
| 482 | + taskSession({ currentToolName: "grep", lastActivityAt: nowMs }), |
| 483 | + ]) |
| 484 | + |
| 485 | + expect(streamRowCount(shell)).toBe(rowCountBefore) |
| 486 | + // One rewrite per changed tick, never proportional to the 40 padding rows. |
| 487 | + expect(removeSpy.mock.calls.length).toBeLessThanOrEqual(2) |
| 488 | + |
| 489 | + const row = shell.streamLog[rowCountBefore - 1]! |
| 490 | + expect(row.pending).toBe(true) |
| 491 | + expect(row.agentWorking).toBe(true) |
| 492 | + expect(row.stat).toContain("grep") |
| 493 | + |
| 494 | + nowMs = 72_000 |
| 495 | + bridge.syncAgentProgress([ |
| 496 | + taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }), |
| 497 | + ]) |
| 498 | + const stalledRow = shell.streamLog[rowCountBefore - 1]! |
| 499 | + expect(stalledRow.agentWorking).toBe(false) |
| 500 | + |
| 501 | + removeSpy.mockRestore() |
| 502 | + } finally { |
| 503 | + bridge.dispose() |
| 504 | + shell.dispose() |
| 505 | + } |
| 506 | + }, |
| 507 | + { width: 80, height: 24 }, |
| 508 | + ) |
| 509 | + }) |
| 510 | + |
| 511 | + test("a finished session's row is left to the tool-result path", async () => { |
| 512 | + await withTestRenderer( |
| 513 | + async (h) => { |
| 514 | + const shell = createAppShell(h.renderer, { |
| 515 | + terminal: { columns: 80, rows: 24 }, |
| 516 | + wireKeys: false, |
| 517 | + run: "busy", |
| 518 | + }) |
| 519 | + const bridge = attachSessionBridge(shell, createRecordingPort()) |
| 520 | + try { |
| 521 | + bridge.handle({ |
| 522 | + type: "inference.tool_call.end", |
| 523 | + data: { |
| 524 | + name: "task", |
| 525 | + callId: "task-1", |
| 526 | + arguments: { description: "Review mouse/paste" }, |
| 527 | + }, |
| 528 | + }) |
| 529 | + bridge.handle({ |
| 530 | + type: "tool.done", |
| 531 | + data: { result: { callId: "task-1", name: "task", content: "done", isError: false } }, |
| 532 | + }) |
| 533 | + const index = shell.streamLog.length - 1 |
| 534 | + bridge.syncAgentProgress([taskSession({ status: "done" })]) |
| 535 | + expect(shell.streamLog[index]!.pending).not.toBe(true) |
| 536 | + expect(shell.streamLog[index]!.agentWorking).toBeUndefined() |
| 537 | + } finally { |
| 538 | + bridge.dispose() |
| 539 | + shell.dispose() |
| 540 | + } |
| 541 | + }, |
| 542 | + { width: 80, height: 24 }, |
| 543 | + ) |
| 544 | + }) |
| 545 | +}) |
0 commit comments