From 1e59d763a941ac952aec64775885544d85d3af7e Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Mon, 24 Aug 2026 15:44:07 +0800 Subject: [PATCH] fix(workflows): freeze settled launch cards --- extensions/workflows/execute.e2e.test.ts | 91 +++++++++++++++++++++++- extensions/workflows/index.ts | 39 +++++----- extensions/workflows/model.ts | 8 ++- 3 files changed, 111 insertions(+), 27 deletions(-) diff --git a/extensions/workflows/execute.e2e.test.ts b/extensions/workflows/execute.e2e.test.ts index aab075fd..2701b2de 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, + ToolDefinition, } 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-")); @@ -51,6 +55,7 @@ const { default: workflows, __setWorkflowTestAgentSessionFactory } = type CapturedTool = { name: string; + renderResult?: ToolDefinition["renderResult"]; execute: ( id: string, params: Record, @@ -121,7 +126,10 @@ const ctx = { model: undefined, modelRegistry: { find: () => undefined }, ui: { - theme: { fg: (_color: string, text: string) => text }, + theme: { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + }, setStatus() {}, setWidget() {}, }, @@ -211,7 +219,7 @@ async function waitFor( } /** A minimal AgentSession stand-in for one successful child agent call. */ -function fakeAgentSession(output: string) { +function fakeAgentSession(output: string, promptGate?: Promise) { 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 +267,9 @@ function fakeAgentSession(output: string) { listeners.add(listener); return () => listeners.delete(listener); }, - async prompt() {}, + async prompt() { + await promptGate; + }, async abort() {}, dispose() {}, getContextUsage: () => undefined, @@ -387,6 +397,81 @@ test("background runs deliver a follow-up that triggers a turn only when idle", }); }); +test("a settled launch card does not repaint while its detached run stays active", async () => { + modelIdle = true; + sentMessages.length = 0; + let releasePrompt = () => {}; + const promptGate = new Promise((resolve) => { + releasePrompt = resolve; + }); + __setWorkflowTestAgentSessionFactory(async () => ({ + session: fakeAgentSession("detached output", promptGate), + })); + + let runId: unknown; + try { + const launch = (await workflow.execute( + "e2e-detached-render", + { + script: + 'export const meta = { name: "detached-render" };\n' + + 'return await agent("wait for release", { agent_type: "reviewer" });', + }, + undefined, + undefined, + ctx, + )) as AgentToolResult; + runId = launch.details.runId; + assert.equal(launch.details.status, "running"); + + const renderResult = workflow.renderResult; + assert.ok(renderResult); + let invalidations = 0; + const component = renderResult( + launch, + { expanded: false, isPartial: false }, + ctx.ui.theme, + { + args: {}, + toolCallId: "call-detached-render", + invalidate: () => { + invalidations += 1; + }, + lastComponent: undefined, + state: {}, + cwd: repoDir, + executionStarted: true, + argsComplete: true, + isPartial: false, + expanded: false, + showImages: false, + isError: false, + }, + ); + const first = component.render(100); + + await new Promise((resolve) => + setTimeout(resolve, SPINNER_INTERVAL_MS * 3), + ); + assert.equal(invalidations, 0); + assert.deepEqual(component.render(100), first); + } finally { + releasePrompt(); + if (runId !== undefined) { + await waitFor( + () => readWorkflowJson(runId).status === "completed", + "detached render workflow settlement", + ); + await waitFor( + () => + sentMessages.some((sent) => sent.message.details?.runId === runId), + "detached render workflow delivery", + ); + } + __setWorkflowTestAgentSessionFactory(undefined); + } +}); + 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 4f7bbf89..8702e46b 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(" ยท "), )}`; @@ -2177,15 +2177,18 @@ export default function workflows(pi: ExtensionAPI) { 0, ); } + // A settled Pi tool result is committed transcript history. Keep its + // launch snapshot stable; live run state belongs to the strip/dashboard. + const settledAt = Date.now(); const currentDetails = () => - activeRuns.get(details.runId)?.details ?? - settledRuns.get(details.runId) ?? - details; + isPartial + ? (activeRuns.get(details.runId)?.details ?? + settledRuns.get(details.runId) ?? + details) + : details; syncWorkflowSpinner( context.state as WorkflowRenderState, - () => - currentDetails().status === "running" && - (isPartial || activeRuns.has(details.runId)), + () => isPartial && currentDetails().status === "running", context.invalidate, ); @@ -2193,21 +2196,13 @@ export default function workflows(pi: ExtensionAPI) { render(width: number) { const current = currentDetails(); const totals = formatUsage(aggregateUsage(current.agents)); + const now = isPartial ? Date.now() : settledAt; 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;