diff --git a/extensions/workflows/execute.e2e.test.ts b/extensions/workflows/execute.e2e.test.ts index aab075fd..c012068d 100644 --- a/extensions/workflows/execute.e2e.test.ts +++ b/extensions/workflows/execute.e2e.test.ts @@ -19,11 +19,15 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import type { + AgentToolResult, AgentSession, AgentSessionEventListener, ExtensionAPI, ExtensionContext, + Theme, } from "@earendil-works/pi-coding-agent"; +import { SPINNER_INTERVAL_MS } from "../shared/spinner.ts"; +import type { WorkflowDetails } from "./model.ts"; import type { WorkflowAgentSessionFactory } from "./runner.ts"; const agentDir = mkdtempSync(join(tmpdir(), "my-pi-setup-wf-e2e-")); @@ -58,6 +62,15 @@ type CapturedTool = { onUpdate?: unknown, ctx?: ExtensionContext, ) => unknown; + renderResult?: ( + result: AgentToolResult, + options: { expanded: boolean; isPartial: boolean }, + theme: Theme, + context: { + state: Record; + invalidate: () => void; + }, + ) => { render(width: number): string[] }; }; type SentMessage = { @@ -127,6 +140,16 @@ const ctx = { }, } as unknown as ExtensionContext; +const renderTheme = new Proxy( + {}, + { + get: (_target, property) => + property === "fg" || property === "bg" + ? (_color: string, text: string) => text + : (text: string) => text, + }, +) as Theme; + for (const [runId, status] of [ ["wf_1e9acd0e", "completed"], ["wf_1e9acbad", "running"], @@ -211,7 +234,7 @@ async function waitFor( } /** A minimal AgentSession stand-in for one successful child agent call. */ -function fakeAgentSession(output: string) { +function fakeAgentSession(output: string, prompt = async () => {}) { const listeners = new Set(); // The reviewer agent type requests the read-only tool surface; the child // preflight in bindChildSessionExtensions requires all of them active. @@ -259,7 +282,7 @@ function fakeAgentSession(output: string) { listeners.add(listener); return () => listeners.delete(listener); }, - async prompt() {}, + prompt, async abort() {}, dispose() {}, getContextUsage: () => undefined, @@ -387,6 +410,102 @@ test("background runs deliver a follow-up that triggers a turn only when idle", }); }); +test("a detached workflow freezes its settled transcript card while the run stays active", async (t) => { + let releasePrompt = () => {}; + const promptGate = new Promise((resolve) => { + releasePrompt = resolve; + }); + __setWorkflowTestAgentSessionFactory(async () => ({ + session: fakeAgentSession("finished after release", () => promptGate), + })); + modelIdle = false; + + try { + const launched = (await workflow.execute( + "e2e-detached-render", + { + script: + 'export const meta = { name: "detached-render" };\n' + + 'return await agent("wait for release", { agent_type: "reviewer" });', + background: true, + }, + undefined, + undefined, + ctx, + )) as AgentToolResult; + assert.equal(launched.details?.status, "running"); + assert.ok(workflow.renderResult); + + let invalidations = 0; + const context = { + state: {}, + invalidate: () => { + invalidations += 1; + }, + }; + const launchTime = launched.details?.startedAt; + assert.ok(typeof launchTime === "number"); + t.mock.timers.enable({ + apis: ["Date", "setInterval"], + now: launchTime + 1_000, + }); + const component = workflow.renderResult( + launched, + { expanded: false, isPartial: false }, + renderTheme, + context, + ); + const launchRows = component.render(100); + + t.mock.timers.tick(SPINNER_INTERVAL_MS * 2); + assert.equal( + invalidations, + 0, + "a settled launch receipt must not repaint transcript history", + ); + t.mock.timers.tick(5_000); + const rebuilt = workflow.renderResult( + launched, + { expanded: false, isPartial: false }, + renderTheme, + { state: {}, invalidate: context.invalidate }, + ); + assert.deepEqual( + rebuilt.render(100), + launchRows, + "reconstructing the renderer must preserve the committed transcript", + ); + t.mock.timers.reset(); + + releasePrompt(); + await waitFor( + () => readWorkflowJson(launched.details?.runId).status === "completed", + "detached render workflow settlement", + ); + modelIdle = true; + for (const handler of handlers.get("agent_settled") ?? []) { + await handler({}, ctx); + } + await waitFor( + () => + sentMessages.some( + (sent) => sent.message.details?.runId === launched.details?.runId, + ), + "detached render workflow delivery", + ); + assert.deepEqual( + component.render(100), + launchRows, + "later global renders must not rewrite a settled transcript card", + ); + } finally { + t.mock.timers.reset(); + releasePrompt(); + __setWorkflowTestAgentSessionFactory(undefined); + modelIdle = true; + } +}); + test("failed completion delivery remains durable and retries once with the same id", async () => { sentMessages.length = 0; modelIdle = true; diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 9b5208b9..2c6d8cd3 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -203,7 +203,7 @@ function runHeader( ) { const { done, failed, uncertain } = countStates(details); const settled = done + failed; - const elapsed = formatElapsed(details.startedAt, details.finishedAt); + const elapsed = formatElapsed(details.startedAt, details.finishedAt, now); // A just-launched run has no agents and a 0s clock; the metrics join in // once there is something real to report. const counts = @@ -264,7 +264,7 @@ function buildCollapsedRows( ? percent === undefined ? undefined : `${percent}%` - : formatElapsed(agent.startedAt, agent.finishedAt); + : formatElapsed(agent.startedAt, agent.finishedAt, now); const left = ` ${stateGlyph(agent.state, theme, now)} ${theme.fg( "accent", sanitizeWorkflowDisplayLine(agent.label), @@ -366,7 +366,7 @@ function buildExpandedWorkflow( sanitizeWorkflowDisplayLine(agent.label), )} ${theme.fg( "dim", - [context, formatElapsed(agent.startedAt, agent.finishedAt)] + [context, formatElapsed(agent.startedAt, agent.finishedAt, now)] .filter(Boolean) .join(" ยท "), )}`; @@ -2158,37 +2158,35 @@ export default function workflows(pi: ExtensionAPI) { 0, ); } + // A settled tool result is committed transcript history. Keep it + // immutable even while a detached run continues in activeRuns: changing + // rows above Pi regular mode's viewport can force a full redraw that + // clears terminal scrollback. Live progress remains available through + // the below-editor strip, dashboard, and completion follow-up. const currentDetails = () => - activeRuns.get(details.runId)?.details ?? - settledRuns.get(details.runId) ?? - details; + isPartial + ? (activeRuns.get(details.runId)?.details ?? + settledRuns.get(details.runId) ?? + details) + : details; + const settledRenderTime = details.finishedAt ?? details.startedAt; syncWorkflowSpinner( context.state as WorkflowRenderState, - () => - currentDetails().status === "running" && - (isPartial || activeRuns.has(details.runId)), + () => isPartial && currentDetails().status === "running", context.invalidate, ); return { render(width: number) { const current = currentDetails(); + const now = isPartial ? Date.now() : settledRenderTime; const totals = formatUsage(aggregateUsage(current.agents)); if (!expanded) { - return buildCollapsedRows( - current, - theme, - width, - Date.now(), - totals, - ); + return buildCollapsedRows(current, theme, width, now, totals); } - return buildExpandedWorkflow( - current, - theme, - Date.now(), - totals, - ).render(width); + return buildExpandedWorkflow(current, theme, now, totals).render( + width, + ); }, invalidate() {}, }; diff --git a/extensions/workflows/model.ts b/extensions/workflows/model.ts index 45a78370..e5bd48ae 100644 --- a/extensions/workflows/model.ts +++ b/extensions/workflows/model.ts @@ -432,10 +432,14 @@ export function agentContext(agent: AgentRecord): string { }); } -export function formatElapsed(startedAt: number, finishedAt?: number): string { +export function formatElapsed( + startedAt: number, + finishedAt?: number, + now = Date.now(), +): string { const totalSeconds = Math.max( 0, - Math.round(((finishedAt ?? Date.now()) - startedAt) / 1000), + Math.round(((finishedAt ?? now) - startedAt) / 1000), ); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60;