Skip to content
Closed
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
123 changes: 121 additions & 2 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,
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-"));
Expand Down Expand Up @@ -58,6 +62,15 @@ type CapturedTool = {
onUpdate?: unknown,
ctx?: ExtensionContext,
) => unknown;
renderResult?: (
result: AgentToolResult<WorkflowDetails>,
options: { expanded: boolean; isPartial: boolean },
theme: Theme,
context: {
state: Record<string, unknown>;
invalidate: () => void;
},
) => { render(width: number): string[] };
};

type SentMessage = {
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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<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 +282,7 @@ function fakeAgentSession(output: string) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async prompt() {},
prompt,
async abort() {},
dispose() {},
getContextUsage: () => undefined,
Expand Down Expand Up @@ -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<void>((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<WorkflowDetails>;
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;
Expand Down
42 changes: 20 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 @@ -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() {},
};
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