From 9e4260986362e30c8569cc895d45aa1ced94e767 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 24 Aug 2026 09:06:06 +0800 Subject: [PATCH 1/2] fix(workflows): freeze settled transcript cards Detached runs kept their settled tool cards bound to live state and wall clock animation. That changed historical rows on every global repaint, which can force Pi regular mode to clear terminal scrollback. Keep partial cards live while freezing settled cards and cover the detached lifecycle end to end. --- extensions/workflows/execute.e2e.test.ts | 105 ++++++++++++++++++++++- extensions/workflows/index.ts | 42 +++++---- extensions/workflows/model.ts | 8 +- 3 files changed, 129 insertions(+), 26 deletions(-) diff --git a/extensions/workflows/execute.e2e.test.ts b/extensions/workflows/execute.e2e.test.ts index aab075fd..a96e32a9 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,84 @@ 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 () => { + 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 component = workflow.renderResult( + launched, + { expanded: false, isPartial: false }, + renderTheme, + context, + ); + const launchRows = component.render(100); + + await new Promise((resolve) => + setTimeout(resolve, SPINNER_INTERVAL_MS * 2 + 20), + ); + assert.equal( + invalidations, + 0, + "a settled launch receipt must not repaint transcript history", + ); + + 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 { + 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..5fb9fb73 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 = Date.now(); 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; From 35f24089363269f8b5e156ec87ffcda1b4e60aaa Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 24 Aug 2026 09:28:44 +0800 Subject: [PATCH 2/2] test(workflows): freeze renderer reconstruction --- extensions/workflows/execute.e2e.test.ts | 26 ++++++++++++++++++++---- extensions/workflows/index.ts | 2 +- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/extensions/workflows/execute.e2e.test.ts b/extensions/workflows/execute.e2e.test.ts index a96e32a9..c012068d 100644 --- a/extensions/workflows/execute.e2e.test.ts +++ b/extensions/workflows/execute.e2e.test.ts @@ -410,7 +410,7 @@ 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 () => { +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; @@ -443,6 +443,12 @@ test("a detached workflow freezes its settled transcript card while the run stay 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 }, @@ -451,14 +457,25 @@ test("a detached workflow freezes its settled transcript card while the run stay ); const launchRows = component.render(100); - await new Promise((resolve) => - setTimeout(resolve, SPINNER_INTERVAL_MS * 2 + 20), - ); + 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( @@ -482,6 +499,7 @@ test("a detached workflow freezes its settled transcript card while the run stay "later global renders must not rewrite a settled transcript card", ); } finally { + t.mock.timers.reset(); releasePrompt(); __setWorkflowTestAgentSessionFactory(undefined); modelIdle = true; diff --git a/extensions/workflows/index.ts b/extensions/workflows/index.ts index 5fb9fb73..2c6d8cd3 100644 --- a/extensions/workflows/index.ts +++ b/extensions/workflows/index.ts @@ -2169,7 +2169,7 @@ export default function workflows(pi: ExtensionAPI) { settledRuns.get(details.runId) ?? details) : details; - const settledRenderTime = Date.now(); + const settledRenderTime = details.finishedAt ?? details.startedAt; syncWorkflowSpinner( context.state as WorkflowRenderState, () => isPartial && currentDetails().status === "running",