import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { describe, it } from "node:test"; import { isSameGitRepository, resolveWorkflowChatProgress } from "../../src/workflows/chat-progress.ts"; import { renderSubagentResult } from "../../src/tui/render.ts"; import { createSubagentExecutor, foregroundResultIntercomStatus, shouldSuppressRoutineResultIntercom } from "../../src/runs/foreground/subagent-executor.ts"; import type { Details, SingleResult, SubagentState } from "../../src/shared/types.ts"; const theme = { fg(_name: string, text: string): string { return text; }, bold(text: string): string { return text; }, }; function git(cwd: string, args: string[]): string { const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" }); if (result.status !== 0) throw new Error(result.stderr.trim() || result.stdout.trim() || `git ${args.join(" ")} failed`); return result.stdout.trim(); } function createRepo(prefix: string): string { const repo = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); git(repo, ["init"]); git(repo, ["config", "user.email", "tests@example.com"]); git(repo, ["config", "user.name", "Workflow Progress Tests"]); fs.writeFileSync(path.join(repo, "tracked.txt"), "initial\n", "utf-8"); git(repo, ["add", "-A"]); git(repo, ["commit", "-m", "initial"]); return repo; } function componentText(component: unknown): string { if (typeof component !== "object" || component === null) return ""; if ("text" in component && typeof component.text === "string") return component.text; if ("children" in component && Array.isArray(component.children)) return component.children.map(componentText).filter(Boolean).join("\n"); return ""; } function createState(): SubagentState { return { baseCwd: "", currentSessionId: null, asyncJobs: new Map(), foregroundRuns: new Map(), foregroundControls: new Map(), lastForegroundControlId: null, pendingForegroundControlNotices: new Map(), cleanupTimers: new Map(), lastUiContext: null, poller: null, completionSeen: new Map(), watcher: null, watcherRestartTimer: null, resultFileCoalescer: { schedule: () => false, clear: () => {} }, }; } function createExecutor() { return createSubagentExecutor({ pi: { events: { emit() {}, on() { return () => {}; } }, getSessionName() { return "parent"; } } as any, state: createState(), config: { maxSubagentDepth: 2, control: {}, intercomBridge: {} } as any, asyncByDefault: false, tempArtifactsDir: os.tmpdir(), getSubagentSessionRoot: () => os.tmpdir(), expandTilde: (value) => value, discoverAgents: () => ({ agents: [] as any[] }), }); } function ctx(root: string) { return { cwd: root, hasUI: false, sessionManager: { getSessionId() { return "session"; }, getSessionFile() { return null; } }, modelRegistry: { getAvailable() { return []; } }, model: { provider: "test", id: "test-model" }, } as any; } describe("workflow chat progress policy", () => { it("treats managed worktrees as same repo and sibling repos as other repo", () => { const repo = createRepo("pi-workflow-progress-repo-"); const other = createRepo("pi-workflow-progress-other-"); const worktree = path.join(os.tmpdir(), `pi-workflow-progress-wt-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`); try { fs.mkdirSync(path.join(repo, "packages")); git(repo, ["worktree", "add", "-b", "chat-progress-test", worktree, "HEAD"]); assert.equal(isSameGitRepository(repo, path.join(repo, "packages")), true); assert.equal(isSameGitRepository(repo, worktree), true); assert.equal(isSameGitRepository(repo, other), false); assert.equal(resolveWorkflowChatProgress({ requested: "auto", parentCwd: repo, workflowCwd: worktree, background: false }).projection?.mode, "live-card"); assert.equal(resolveWorkflowChatProgress({ requested: "auto", parentCwd: repo, workflowCwd: worktree, background: true }).projection?.mode, "milestones"); assert.equal(resolveWorkflowChatProgress({ requested: "auto", parentCwd: repo, workflowCwd: other, background: false }).projection?.mode, "terminal"); assert.match(resolveWorkflowChatProgress({ requested: "live-card", parentCwd: repo, workflowCwd: other, background: false }).error ?? "", /same Git repository/i); } finally { try { git(repo, ["worktree", "remove", "--force", worktree]); } catch {} fs.rmSync(repo, { recursive: true, force: true }); fs.rmSync(other, { recursive: true, force: true }); fs.rmSync(worktree, { recursive: true, force: true }); } }); }); describe("workflow chat progress rendering", () => { it("emits live-card updates for same-repo watched workflowScript runs", async () => { const repo = createRepo("pi-workflow-progress-executor-"); try { const updates: Array<{ details?: Details }> = []; const result = await createExecutor().execute( "wf-live", { workflowScript: `return await runs.run("explorer", { agent: "missing-agent", task: "scan", phase: "Validation", label: "Find renderer seam" });`, async: false }, new AbortController().signal, (update) => updates.push(update), ctx(repo), ); assert.equal(result.isError, true); const liveUpdate = updates.find((update) => update.details?.chatProgress?.mode === "live-card"); assert.ok(liveUpdate, "expected a live-card update"); assert.equal(liveUpdate.details?.workflow?.trace[0]?.key, "explorer"); assert.equal(liveUpdate.details?.workflow?.trace[0]?.phase, "Validation"); assert.equal(liveUpdate.details?.workflow?.trace[0]?.label, "Find renderer seam"); } finally { fs.rmSync(repo, { recursive: true, force: true }); } }); it("renders stable rows keyed by workflow trace entries", () => { const text = componentText(renderSubagentResult({ content: [{ type: "text", text: "Workflow running." }], details: { mode: "workflow", runId: "wf_8f3a123456", results: [], chatProgress: { mode: "live-card", repoRelation: "same", repoLabel: "pi-subagents" }, workflow: { trace: [ { operation: "run", key: "explorer", state: "completed", runId: "run-explorer", phase: "Validation", label: "Found renderer seam", durationMs: 12 }, { operation: "run", key: "tests", state: "started", phase: "Validation", label: "focused integration suite" }, { operation: "run", key: "review", state: "failed", phase: "Validation", label: "fresh-context UX review", error: "needs fixes" }, ], emits: [], console: [], }, }, }, { expanded: false }, theme as any)); assert.match(text, /workflow wf_8f3a12345 .* same repo .* failed/); assert.match(text, /Repo pi-subagents/); assert.match(text, /Phase Validation/); assert.match(text, /complete\s+explorer Found renderer seam/); assert.match(text, /running\s+tests focused integration suite/); assert.match(text, /failed\s+review fresh-context UX review .* needs fixes/); }); it("suppresses only successful routine child result intercom for live-card workflows", () => { const completed = { agent: "explorer", exitCode: 0, outputState: "present" } as SingleResult; const failed = { agent: "explorer", exitCode: 1, outputState: "present" } as SingleResult; const rejected = { agent: "explorer", exitCode: 0, acceptance: { status: "rejected" }, outputState: "present" } as SingleResult; assert.equal(shouldSuppressRoutineResultIntercom({ suppressRoutineResultIntercom: true, results: [completed] }), true); assert.equal(shouldSuppressRoutineResultIntercom({ suppressRoutineResultIntercom: true, results: [failed] }), false); assert.equal(shouldSuppressRoutineResultIntercom({ suppressRoutineResultIntercom: true, results: [rejected] }), false); assert.equal(shouldSuppressRoutineResultIntercom({ suppressRoutineResultIntercom: false, results: [completed] }), false); }); it("marks acceptance-rejected foreground intercom results as failed", () => { const rejected = { agent: "explorer", exitCode: 0, acceptance: { status: "rejected" }, outputState: "present" } as SingleResult; assert.equal(foregroundResultIntercomStatus(rejected), "failed"); }); it("shows final workflow output after live-card progress completes", () => { const text = componentText(renderSubagentResult({ content: [{ type: "text", text: "Workflow completed.\n\nReturn:\nfinal answer" }], details: { mode: "workflow", runId: "wf_done", results: [], chatProgress: { mode: "live-card", repoRelation: "same", repoLabel: "pi-subagents" }, workflow: { value: "final answer", trace: [{ operation: "run", key: "explorer", state: "completed", runId: "run-explorer" }], emits: [], console: [], }, }, }, { expanded: true }, theme as any)); assert.match(text, /Workflow completed/); assert.match(text, /final answer/); assert.doesNotMatch(text, /workflow wf_done/); }); });