143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
|
import { stripVTControlCharacters } from "node:util";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution";
|
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import type { AgentProgress, SingleResult, TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task/types";
|
|
import type { TUI } from "@oh-my-pi/pi-tui";
|
|
|
|
function progressEntry(description: string): AgentProgress {
|
|
return {
|
|
index: 0,
|
|
id: "Anna",
|
|
agent: "explore",
|
|
agentSource: "bundled",
|
|
status: "running",
|
|
task: "investigate the auth flow",
|
|
description,
|
|
recentTools: [],
|
|
recentOutput: [],
|
|
toolCount: 0,
|
|
requests: 0,
|
|
tokens: 0,
|
|
cost: 0,
|
|
durationMs: 0,
|
|
};
|
|
}
|
|
|
|
/** A detached spawn's partial result: `async.state === "running"` plus live progress rows. */
|
|
function asyncSnapshot(description: string): {
|
|
content: Array<{ type: string; text: string }>;
|
|
details: TaskToolDetails;
|
|
} {
|
|
return {
|
|
content: [{ type: "text", text: "Background job started" }],
|
|
details: {
|
|
projectAgentsDir: null,
|
|
results: [],
|
|
totalDurationMs: 0,
|
|
progress: [progressEntry(description)],
|
|
async: { state: "running", jobId: "job-1", type: "task" },
|
|
},
|
|
};
|
|
}
|
|
|
|
function finalSnapshot(output: string): {
|
|
content: Array<{ type: string; text: string }>;
|
|
details: TaskToolDetails;
|
|
} {
|
|
const result: SingleResult = {
|
|
index: 0,
|
|
id: "Anna",
|
|
agent: "explore",
|
|
agentSource: "bundled",
|
|
task: "investigate the auth flow",
|
|
exitCode: 0,
|
|
output,
|
|
stderr: "",
|
|
truncated: false,
|
|
durationMs: 1234,
|
|
tokens: 10,
|
|
requests: 1,
|
|
};
|
|
return {
|
|
content: [{ type: "text", text: output }],
|
|
details: {
|
|
projectAgentsDir: null,
|
|
results: [result],
|
|
totalDurationMs: 1234,
|
|
async: { state: "completed", jobId: "job-1", type: "task" },
|
|
},
|
|
};
|
|
}
|
|
|
|
// Contract under test: a detached (`async.state === "running"`) task block keeps
|
|
// progress rows static, avoids a redraw driver, freezes once a later partial
|
|
// snapshot observes that it left the live region, drops further partial
|
|
// snapshots, and still applies the final (completed) snapshot.
|
|
describe("ToolExecutionComponent detached task freeze", () => {
|
|
beforeAll(async () => {
|
|
await Settings.init({ inMemory: true, cwd: process.cwd() });
|
|
await initTheme();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function makeComponent(live: () => boolean) {
|
|
const requestRender = vi.fn();
|
|
const requestComponentRender = vi.fn();
|
|
const ui = { requestRender, requestComponentRender } as unknown as TUI;
|
|
const component = new ToolExecutionComponent(
|
|
"task",
|
|
{ agent: "explore", id: "Anna", description: "scout auth", assignment: "investigate the auth flow" },
|
|
{ liveRegion: { isBlockInLiveRegion: () => live() } },
|
|
undefined,
|
|
ui,
|
|
);
|
|
return { component, requestRender, requestComponentRender };
|
|
}
|
|
|
|
it("does not drive redraws while live and keeps progress bytes static", () => {
|
|
vi.useFakeTimers();
|
|
const { component, requestRender, requestComponentRender } = makeComponent(() => true);
|
|
|
|
component.updateResult(asyncSnapshot("scouting the auth flow"), true);
|
|
requestRender.mockClear();
|
|
requestComponentRender.mockClear();
|
|
vi.advanceTimersByTime(500);
|
|
expect(requestRender).not.toHaveBeenCalled();
|
|
expect(requestComponentRender).not.toHaveBeenCalled();
|
|
|
|
vi.spyOn(Date, "now").mockReturnValue(1_000);
|
|
const frameA = component.render(100).join("\n");
|
|
vi.spyOn(Date, "now").mockReturnValue(5_000);
|
|
const frameB = component.render(100).join("\n");
|
|
expect(frameB).toBe(frameA);
|
|
expect(stripVTControlCharacters(frameA)).toContain("scouting the auth flow");
|
|
});
|
|
|
|
it("drops partial snapshots after the freeze but still applies the final result", () => {
|
|
vi.useFakeTimers();
|
|
let live = true;
|
|
const { component } = makeComponent(() => live);
|
|
|
|
component.updateResult(asyncSnapshot("scouting the auth flow"), true);
|
|
live = false;
|
|
vi.advanceTimersByTime(40);
|
|
|
|
// Frozen: later progress snapshots must not repaint commit-eligible rows.
|
|
component.updateResult(asyncSnapshot("a much newer description"), true);
|
|
const frozen = stripVTControlCharacters(component.render(100).join("\n"));
|
|
expect(frozen).toContain("scouting the auth flow");
|
|
expect(frozen).not.toContain("a much newer description");
|
|
|
|
// The terminal snapshot is not progress churn — it settles the block.
|
|
component.updateResult(finalSnapshot("found it in src/auth.ts"), false);
|
|
const final = stripVTControlCharacters(component.render(100).join("\n"));
|
|
expect(final).toContain("found it in src/auth.ts");
|
|
});
|
|
});
|