Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 88 additions & 3 deletions extensions/workflows/execute.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down Expand Up @@ -51,6 +55,7 @@ const { default: workflows, __setWorkflowTestAgentSessionFactory } =

type CapturedTool = {
name: string;
renderResult?: ToolDefinition["renderResult"];
execute: (
id: string,
params: Record<string, unknown>,
Expand Down Expand Up @@ -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() {},
},
Expand Down Expand Up @@ -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<void>) {
const listeners = new Set<AgentSessionEventListener>();
// The reviewer agent type requests the read-only tool surface; the child
// preflight in bindChildSessionExtensions requires all of them active.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<void>((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<WorkflowDetails>;
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;
Expand Down
39 changes: 17 additions & 22 deletions extensions/workflows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(" · "),
)}`;
Expand Down Expand Up @@ -2177,37 +2177,32 @@ 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,
);

return {
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() {},
};
Expand Down
8 changes: 6 additions & 2 deletions extensions/workflows/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading