591 lines
15 KiB
TypeScript
591 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "vitest";
|
|
import type { ProgressContext } from "../headless-ui.js";
|
|
import {
|
|
extractAssistantPreviewDelta,
|
|
formatCostLine,
|
|
formatHeadlessHeartbeat,
|
|
formatProgress,
|
|
formatPromptTraceLines,
|
|
formatThinkingLine,
|
|
summarizeToolArgs,
|
|
} from "../headless-ui.js";
|
|
|
|
// Tests run with NO_COLOR or non-TTY stderr, so ANSI codes are empty strings.
|
|
// We test content, not escape sequences.
|
|
|
|
function ctx(overrides: Partial<ProgressContext> = {}): ProgressContext {
|
|
return { verbose: true, ...overrides };
|
|
}
|
|
|
|
describe("formatProgress", () => {
|
|
describe("formatHeadlessHeartbeat", () => {
|
|
it("shows liveness details during quiet headless waits", () => {
|
|
const result = formatHeadlessHeartbeat({
|
|
elapsedMs: 125_000,
|
|
quietMs: 61_000,
|
|
totalEvents: 12,
|
|
toolCallCount: 3,
|
|
eventDelta: 4,
|
|
toolCallDelta: 1,
|
|
openToolCount: 1,
|
|
activeUnit: "execute-task M001/S01/T01",
|
|
activeModel: "zai/glm-5",
|
|
lastEventType: "agent_start",
|
|
});
|
|
|
|
assert.ok(result.includes("still running 2m5s"));
|
|
assert.ok(result.includes("quiet 1m1s"));
|
|
assert.ok(result.includes("last=agent_start"));
|
|
assert.ok(result.includes("events=12"));
|
|
assert.ok(result.includes("tools=3"));
|
|
assert.ok(result.includes("activity=+4 events, +1 tools"));
|
|
assert.ok(result.includes("openTools=1"));
|
|
assert.ok(result.includes("unit=execute-task M001/S01/T01"));
|
|
assert.ok(result.includes("model=zai/glm-5"));
|
|
});
|
|
|
|
it("shows no new events when the heartbeat is idle", () => {
|
|
const result = formatHeadlessHeartbeat({
|
|
elapsedMs: 300_000,
|
|
quietMs: 180_000,
|
|
totalEvents: 10,
|
|
toolCallCount: 2,
|
|
eventDelta: 0,
|
|
toolCallDelta: 0,
|
|
});
|
|
|
|
assert.ok(result.includes("activity=no new events"));
|
|
assert.ok(!result.includes("openTools="));
|
|
});
|
|
|
|
it("shows active tool identity when a quiet wait has an open tool", () => {
|
|
const result = formatHeadlessHeartbeat({
|
|
elapsedMs: 300_000,
|
|
quietMs: 180_000,
|
|
totalEvents: 10,
|
|
toolCallCount: 2,
|
|
openToolCount: 1,
|
|
openToolDetails: ["subagent:review M010 2m4s"],
|
|
});
|
|
|
|
assert.ok(result.includes("openTools=1[subagent:review M010 2m4s]"));
|
|
});
|
|
});
|
|
|
|
describe("formatPromptTraceLines", () => {
|
|
it("shows capped prompt preview with session path", () => {
|
|
const lines = formatPromptTraceLines(
|
|
"sf-auto",
|
|
"# Mission\nDo the work\nKeep going",
|
|
"/tmp/session.jsonl",
|
|
{ maxChars: 17, maxLines: 3 },
|
|
);
|
|
|
|
assert.ok(lines[0].includes("sf-auto instructions"));
|
|
assert.ok(lines[0].includes("/tmp/session.jsonl"));
|
|
assert.ok(lines.some((line) => line.includes("# Mission")));
|
|
assert.ok(lines.at(-1)?.includes("truncated"));
|
|
});
|
|
});
|
|
|
|
describe("tool_execution_start", () => {
|
|
it("shows tool name and summarized args in verbose mode", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "tool_execution_start",
|
|
toolName: "bash",
|
|
args: { command: "npm run build" },
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("bash"));
|
|
assert.ok(result.includes("npm run build"));
|
|
});
|
|
|
|
it("shows Read with file path", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "tool_execution_start",
|
|
toolName: "Read",
|
|
args: { path: "src/main.ts" },
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("Read"));
|
|
assert.ok(result.includes("src/main.ts"));
|
|
});
|
|
|
|
it("returns null in non-verbose mode", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "tool_execution_start",
|
|
toolName: "bash",
|
|
args: { command: "npm run build" },
|
|
},
|
|
ctx({ verbose: false }),
|
|
);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
it("shows tool name alone when no args", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "tool_execution_start",
|
|
toolName: "unknown_tool",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("unknown_tool"));
|
|
});
|
|
});
|
|
|
|
describe("tool_execution_end", () => {
|
|
it("shows error with duration in verbose mode", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "tool_execution_end",
|
|
toolName: "bash",
|
|
},
|
|
ctx({ isError: true, toolDuration: 1500 }),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("bash"));
|
|
assert.ok(result.includes("error"));
|
|
assert.ok(result.includes("1.5s"));
|
|
});
|
|
|
|
it("shows done with duration in verbose mode", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "tool_execution_end",
|
|
toolName: "read",
|
|
},
|
|
ctx({ toolDuration: 50 }),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("done"));
|
|
assert.ok(result.includes("50ms"));
|
|
});
|
|
|
|
it("returns null in non-verbose mode", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "tool_execution_end",
|
|
toolName: "bash",
|
|
isError: false,
|
|
},
|
|
ctx({ verbose: false }),
|
|
);
|
|
assert.equal(result, null);
|
|
});
|
|
});
|
|
|
|
describe("agent lifecycle", () => {
|
|
it("shows agent_start", () => {
|
|
const result = formatProgress({ type: "agent_start" }, ctx());
|
|
assert.ok(result);
|
|
assert.ok(result.includes("Session started"));
|
|
});
|
|
|
|
it("shows agent_end", () => {
|
|
const result = formatProgress({ type: "agent_end" }, ctx());
|
|
assert.ok(result);
|
|
assert.ok(result.includes("Session ended"));
|
|
});
|
|
|
|
it("shows agent_end with cost", () => {
|
|
const result = formatProgress(
|
|
{ type: "agent_end" },
|
|
ctx({
|
|
lastCost: { costUsd: 0.42, inputTokens: 10000, outputTokens: 500 },
|
|
}),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("Session ended"));
|
|
assert.ok(result.includes("$0.42"));
|
|
assert.ok(result.includes("10500 tokens"));
|
|
});
|
|
});
|
|
|
|
describe("extension_error", () => {
|
|
it("shows extension path, event, and error message", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "extension_error",
|
|
extensionPath: "/tmp/extensions/sf/index.js",
|
|
event: "startup",
|
|
error: { message: "boom" },
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("Extension error"));
|
|
assert.ok(result.includes("/tmp/extensions/sf/index.js"));
|
|
assert.ok(result.includes("startup"));
|
|
assert.ok(result.includes("boom"));
|
|
});
|
|
});
|
|
|
|
describe("extension_ui_request", () => {
|
|
it("shows notify with message", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "notify",
|
|
message: "Autonomous mode started",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("Autonomous mode started"));
|
|
});
|
|
|
|
it("bolds important notifications", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "notify",
|
|
message: "Committed: fix auth flow",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("Committed: fix auth flow"));
|
|
});
|
|
|
|
it("suppresses empty notify", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "notify",
|
|
message: "",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
it("suppresses empty setStatus", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "setStatus",
|
|
statusKey: "",
|
|
message: "",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
it("shows setStatus with statusKey as phase", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "setStatus",
|
|
statusKey: "milestone:M001",
|
|
message: "Hello World CLI",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.ok(result);
|
|
assert.ok(result.includes("Milestone"));
|
|
assert.ok(result.includes("M001"));
|
|
});
|
|
|
|
it("suppresses setWidget (TUI-only)", () => {
|
|
const result = formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "setWidget",
|
|
widgetKey: "progress",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
it("suppresses TUI footer widget setStatus keys", () => {
|
|
for (const key of [
|
|
"0-emoji",
|
|
"0-color-band",
|
|
"authority",
|
|
"ollama",
|
|
"sf-fast",
|
|
"sf-auto",
|
|
]) {
|
|
const r = formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "setStatus",
|
|
statusKey: key,
|
|
message: "⏳",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.equal(r, null, `expected null for ${key}`);
|
|
}
|
|
});
|
|
|
|
it("suppresses bare unknown statusKey with no message", () => {
|
|
const r = formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "setStatus",
|
|
statusKey: "something-new",
|
|
message: "",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.equal(r, null);
|
|
});
|
|
|
|
it("keeps workflow phases with colon prefix", () => {
|
|
const r = formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "setStatus",
|
|
statusKey: "phase:discuss",
|
|
message: "starting",
|
|
},
|
|
ctx(),
|
|
);
|
|
assert.ok(r && r.includes("Phase: discuss"));
|
|
});
|
|
});
|
|
|
|
describe("tag prefix alignment", () => {
|
|
it("pads all tag prefixes to the same column width", () => {
|
|
const prefixes = [
|
|
formatProgress({ type: "agent_start" }, ctx())!,
|
|
formatProgress(
|
|
{
|
|
type: "extension_ui_request",
|
|
method: "notify",
|
|
message: "hi",
|
|
},
|
|
ctx(),
|
|
)!,
|
|
formatCostLine(0.01, 100, 50),
|
|
formatThinkingLine("x"),
|
|
];
|
|
// Under NO_COLOR / non-TTY, prefixes begin with "[tag]" followed by
|
|
// spaces to column 11, then content.
|
|
for (const p of prefixes) {
|
|
const m = p.match(/^\[[a-z]+\]\s+/);
|
|
assert.ok(m, `prefix missing on: ${JSON.stringify(p)}`);
|
|
assert.equal(m![0].length, 11, `wrong width on: ${JSON.stringify(p)}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("unknown events", () => {
|
|
it("returns null", () => {
|
|
assert.equal(formatProgress({ type: "some_random_event" }, ctx()), null);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("summarizeToolArgs", () => {
|
|
it("extracts path for Read", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("Read", { path: "src/index.ts" }),
|
|
"src/index.ts",
|
|
);
|
|
});
|
|
|
|
it("extracts path for write", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("write", { path: "/tmp/out.json" }),
|
|
"/tmp/out.json",
|
|
);
|
|
});
|
|
|
|
it("extracts file_path alias", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("read", { file_path: "src/foo.ts" }),
|
|
"src/foo.ts",
|
|
);
|
|
});
|
|
|
|
it("prefers path over file_path when both present", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("read", { path: "real.ts", file_path: "legacy.ts" }),
|
|
"real.ts",
|
|
);
|
|
});
|
|
|
|
it("extracts command for bash", () => {
|
|
assert.equal(summarizeToolArgs("bash", { command: "ls -la" }), "ls -la");
|
|
});
|
|
|
|
it("truncates long bash commands", () => {
|
|
const longCmd = "a".repeat(100);
|
|
const result = summarizeToolArgs("bash", { command: longCmd });
|
|
assert.ok(result.endsWith("..."));
|
|
assert.ok(result.length < 100);
|
|
});
|
|
|
|
it("extracts command for async_bash", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("async_bash", { command: "npm run build" }),
|
|
"npm run build",
|
|
);
|
|
});
|
|
|
|
it("extracts jobs for await_job", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("await_job", { jobs: ["bg_abc", "bg_def"] }),
|
|
"bg_abc, bg_def",
|
|
);
|
|
});
|
|
|
|
it("extracts pattern for grep", () => {
|
|
const result = summarizeToolArgs("grep", { pattern: "TODO", glob: "*.ts" });
|
|
assert.equal(result, "TODO *.ts");
|
|
});
|
|
|
|
it("extracts pattern and path for find", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("find", { pattern: "*.ts", path: "src" }),
|
|
"*.ts in src",
|
|
);
|
|
});
|
|
|
|
it("extracts action and file for lsp", () => {
|
|
const result = summarizeToolArgs("lsp", {
|
|
action: "definition",
|
|
file: "src/main.ts",
|
|
symbol: "foo",
|
|
});
|
|
assert.equal(result, "definition src/main.ts foo");
|
|
});
|
|
|
|
it("extracts path for ls", () => {
|
|
assert.equal(summarizeToolArgs("ls", { path: "src/utils" }), "src/utils");
|
|
});
|
|
|
|
it("summarizes sf tool with milestone/slice/task IDs", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("sf_task_complete", {
|
|
milestoneId: "M001",
|
|
sliceId: "S01",
|
|
taskId: "T01",
|
|
oneLiner: "Built the thing",
|
|
}),
|
|
"M001/S01/T01 Built the thing",
|
|
);
|
|
});
|
|
|
|
it("summarizes sf_plan_milestone with milestone ID", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("sf_plan_milestone", { milestoneId: "M002" }),
|
|
"M002",
|
|
);
|
|
});
|
|
|
|
it("summarizes sf_decision_save with decision text", () => {
|
|
const result = summarizeToolArgs("sf_decision_save", {
|
|
decision: "Use SQLite for persistence",
|
|
});
|
|
assert.equal(result, "Use SQLite for persistence");
|
|
});
|
|
|
|
it("returns first string value for unknown tools", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("custom_tool", { someKey: "hello" }),
|
|
"hello",
|
|
);
|
|
});
|
|
|
|
it("returns empty string for no args", () => {
|
|
assert.equal(summarizeToolArgs("unknown", {}), "");
|
|
});
|
|
|
|
it("extracts path for edit", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("edit", { path: "src/config.ts" }),
|
|
"src/config.ts",
|
|
);
|
|
});
|
|
|
|
it("extracts path for hashline_edit", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("hashline_edit", { path: "src/main.ts" }),
|
|
"src/main.ts",
|
|
);
|
|
});
|
|
|
|
it("extracts agent and task for subagent", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("subagent", {
|
|
agent: "scout",
|
|
task: "Find auth patterns",
|
|
}),
|
|
"scout: Find auth patterns",
|
|
);
|
|
});
|
|
|
|
it("extracts url for browser_navigate", () => {
|
|
assert.equal(
|
|
summarizeToolArgs("browser_navigate", { url: "http://localhost:3000" }),
|
|
"http://localhost:3000",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("formatThinkingLine", () => {
|
|
it("formats short text", () => {
|
|
const result = formatThinkingLine("Analyzing the codebase");
|
|
assert.ok(result.includes("[thinking]"));
|
|
assert.ok(result.includes("Analyzing the codebase"));
|
|
});
|
|
|
|
it("truncates long text to ~120 chars", () => {
|
|
const longText = "word ".repeat(50); // 250 chars
|
|
const result = formatThinkingLine(longText);
|
|
assert.ok(result.includes("..."));
|
|
});
|
|
|
|
it("collapses whitespace", () => {
|
|
const result = formatThinkingLine("line one\n\nline two\ttab");
|
|
assert.ok(result.includes("line one line two tab"));
|
|
});
|
|
});
|
|
|
|
describe("extractAssistantPreviewDelta", () => {
|
|
it("separates assistant text deltas from thinking deltas", () => {
|
|
assert.deepEqual(
|
|
extractAssistantPreviewDelta({
|
|
type: "text_delta",
|
|
delta: "I will edit the file.",
|
|
}),
|
|
{ kind: "text", text: "I will edit the file." },
|
|
);
|
|
assert.deepEqual(
|
|
extractAssistantPreviewDelta({
|
|
type: "thinking_delta",
|
|
delta: "Need inspect first.",
|
|
}),
|
|
{ kind: "thinking", text: "Need inspect first." },
|
|
);
|
|
});
|
|
|
|
it("ignores non-preview assistant events", () => {
|
|
assert.equal(extractAssistantPreviewDelta({ type: "text_start" }), null);
|
|
assert.equal(extractAssistantPreviewDelta({ type: "thinking_end" }), null);
|
|
assert.equal(extractAssistantPreviewDelta(null), null);
|
|
});
|
|
});
|
|
|
|
describe("formatCostLine", () => {
|
|
it("formats cost with token count", () => {
|
|
const result = formatCostLine(0.0523, 4200, 1100);
|
|
assert.ok(result.includes("$0.0523"));
|
|
assert.ok(result.includes("5300 tokens"));
|
|
});
|
|
});
|