import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stripVTControlCharacters } from "node:util";
import type {
AgentToolResult,
ExtensionAPI,
Theme,
ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import {
createEventBus,
initTheme,
ToolExecutionComponent,
} from "@earendil-works/pi-coding-agent";
import { Box, type TUI, visibleWidth } from "@earendil-works/pi-tui";
import { parse } from "yaml";
import { createToolRenderContext } from "../../../test/support/tool-render-context.ts";
import { CHILD_AGENT_PROCESS_ENV } from "../../shared/child-agent-environment.ts";
import { SUBAGENT_WORKFLOW_IDS_ENV } from "../../shared/subagent-environment.ts";
import { createToolPresentationRegistry } from "../run-subagent/tool-rendering.ts";
import workflowExtension from "./index.ts";
const PLAIN_THEME = {
bold: (value: string) => value,
fg: (_color: string, value: string) => value,
} as Theme;
const MARKED_THEME = {
bold: (value: string) => `${value}`,
fg: (color: string, value: string) => `<${color}>${value}${color}>`,
} as Theme;
/** Matches Pi's standard summary for wrapped lines hidden in collapsed mode. */
const HIDDEN_LINE_HINT =
/^\.\.\. \(\d+ more lines?, \d+ total, .* to expand\)$/;
interface WorkflowRenderingFixture {
readonly api: ExtensionAPI;
readonly handlers: ReadonlyMap<
string,
readonly ((event: unknown, context: unknown) => unknown)[]
>;
readonly tools: readonly ToolDefinition[];
}
interface ExecutedTool {
readonly args: Record;
readonly definition: ToolDefinition;
readonly result: AgentToolResult;
readonly isError: boolean;
}
const temporaryDirectories: string[] = [];
const originalSuiteDirectory = process.env["PI_AGENT_SUITE_DIR"];
const originalChildMarker = process.env[CHILD_AGENT_PROCESS_ENV];
const originalChildWorkflowIds = process.env[SUBAGENT_WORKFLOW_IDS_ENV];
/** Creates one workflow catalog whose descriptions are the user-visible names. */
async function createWorkflowSuite(
workflowDescription = "Delivery process",
implementationDescription = "Implementation stage",
reviewDescription = "Review stage",
): Promise {
const root = await mkdtemp(join(tmpdir(), "pi-workflow-rendering-"));
temporaryDirectories.push(root);
const workflowDirectory = join(root, "workflow", "workflows");
await mkdir(workflowDirectory, { recursive: true });
await writeFile(
join(workflowDirectory, "delivery.yaml"),
`description: ${JSON.stringify(workflowDescription)}\nstages:\n - id: implementation\n description: ${JSON.stringify(implementationDescription)}\n prompt: Implement the change\n initial: true\n - id: review\n description: ${JSON.stringify(reviewDescription)}\n prompt: Review the change\n final: true\ntransitions:\n - from: implementation\n to: review\n type: advance\n`,
);
process.env["PI_AGENT_SUITE_DIR"] = root;
}
/** Creates the smallest Pi runtime that preserves lifecycle and presentation events. */
async function createFixture(): Promise {
const handlers = new Map<
string,
Array<(event: unknown, context: unknown) => unknown>
>();
const tools: ToolDefinition[] = [];
const activeTools: string[] = [];
let thinkingLevel = "medium";
const events = createEventBus();
const api = {
events,
on(
eventName: string,
handler: (event: unknown, context: unknown) => unknown,
) {
const registered = handlers.get(eventName) ?? [];
registered.push(handler);
handlers.set(eventName, registered);
},
registerTool(definition: ToolDefinition) {
tools.push(definition);
activeTools.push(definition.name);
},
registerFlag(): void {},
getFlag(): undefined {
return undefined;
},
getActiveTools() {
return [...activeTools];
},
setActiveTools(names: string[]) {
activeTools.splice(0, activeTools.length, ...names);
},
getThinkingLevel() {
return thinkingLevel;
},
setThinkingLevel(level: string) {
thinkingLevel = level;
},
appendEntry(): void {},
sendMessage(): void {},
} as unknown as ExtensionAPI;
const renderingModel = {
provider: "test",
id: "current",
api: "test-api",
baseUrl: "https://example.test",
reasoning: true,
name: "test/current",
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100_000,
maxTokens: 8_192,
};
await workflowExtension(api);
for (const sessionStart of handlers.get("session_start") ?? []) {
await sessionStart({ type: "session_start" }, {
mode: "rpc",
hasUI: true,
model: renderingModel,
modelRegistry: {
find(provider: string, id: string) {
return provider === "test" && id === "current"
? renderingModel
: undefined;
},
},
sessionManager: { getBranch: () => [] },
shutdown: () => {},
} as never);
}
return { api, handlers, tools };
}
/** Runs extension handlers in registration order and merges tool-result overrides. */
async function applyToolResultHandlers(
fixture: WorkflowRenderingFixture,
event: Record,
): Promise> {
let current = { ...event };
for (const handler of fixture.handlers.get("tool_result") ?? []) {
const override = await handler(current, {});
if (typeof override === "object" && override !== null) {
current = { ...current, ...override };
}
}
return {
content: current["content"] as AgentToolResult["content"],
details: current["details"],
};
}
/** Executes one workflow tool through the same start/result event boundaries as Pi. */
async function executeTool(
fixture: WorkflowRenderingFixture,
name:
| "workflow_activate"
| "workflow_get_stage"
| "workflow_edit_stage"
| "workflow_transition"
| "workflow_create",
args: Record,
): Promise {
const definition = fixture.tools.find((candidate) => candidate.name === name);
if (definition === undefined) {
throw new Error(`missing ${name} definition`);
}
const toolCallId = `${name}-call`;
for (const handler of fixture.handlers.get("tool_execution_start") ?? []) {
await handler(
{ type: "tool_execution_start", toolCallId, toolName: name, args },
{},
);
}
let baseResult: AgentToolResult;
let isError = false;
try {
baseResult = await definition.execute(
toolCallId,
args,
undefined,
undefined,
{} as never,
);
} catch (error) {
isError = true;
baseResult = {
content: [
{
type: "text",
text: error instanceof Error ? error.message : String(error),
},
],
details: {},
};
}
const result = await applyToolResultHandlers(fixture, {
type: "tool_result",
toolCallId,
toolName: name,
input: args,
content: baseResult.content,
details: baseResult.details,
isError,
});
return { args, definition, result, isError };
}
/** Resolves the renderer used by the subagent session screen. */
function resolveSessionDefinition(
fixture: WorkflowRenderingFixture,
name:
| "workflow_activate"
| "workflow_get_stage"
| "workflow_edit_stage"
| "workflow_transition"
| "workflow_create",
): ToolDefinition {
const resolution = createToolPresentationRegistry(
"/tmp",
fixture.api.events,
).resolve(name);
if (
resolution.category !== "package" ||
resolution.definition === undefined
) {
throw new Error(`${name} did not resolve as package presentation`);
}
return resolution.definition;
}
/** Renders a completed tool row in the selected mode after restoring persisted evidence. */
function renderCompletedTool(
definition: ToolDefinition,
execution: ExecutedTool,
theme: Theme = PLAIN_THEME,
width = 100,
expanded = false,
): { readonly call: readonly string[]; readonly result: readonly string[] } {
if (
definition.renderCall === undefined ||
definition.renderResult === undefined
) {
throw new Error(`${definition.name} semantic renderers are missing`);
}
const context = createToolRenderContext({
args: execution.args,
expanded,
isError: execution.isError,
});
const result = definition.renderResult(
execution.result,
{ expanded, isPartial: false },
theme,
context,
);
return {
call: definition
.renderCall(execution.args, theme, context)
.render(width)
.map((line) => line.trimEnd()),
result: result.render(width).map((line) => line.trimEnd()),
};
}
/** Builds one complete dynamic definition whose identity is visible before execution. */
function createArguments(
id: string,
description: string,
): Record {
return {
id,
description,
prompt: "Follow delivery rules",
stages: [
{
id: "implementation",
description: "Implementation stage",
prompt: "Implement the change",
model: { thinking: "medium" },
initial: true,
},
{
id: "review",
description: "Review stage",
prompt: "Review the change",
model: { thinking: "medium" },
final: true,
},
],
transitions: [{ from: "implementation", to: "review", type: "advance" }],
};
}
/** Initializes theme and isolates main-agent fixtures from ambient child policy. */
beforeEach(() => {
delete process.env[CHILD_AGENT_PROCESS_ENV];
delete process.env[SUBAGENT_WORKFLOW_IDS_ENV];
initTheme(undefined, false);
});
afterEach(async () => {
if (originalSuiteDirectory === undefined) {
delete process.env["PI_AGENT_SUITE_DIR"];
} else {
process.env["PI_AGENT_SUITE_DIR"] = originalSuiteDirectory;
}
if (originalChildMarker === undefined) {
delete process.env[CHILD_AGENT_PROCESS_ENV];
} else {
process.env[CHILD_AGENT_PROCESS_ENV] = originalChildMarker;
}
if (originalChildWorkflowIds === undefined) {
delete process.env[SUBAGENT_WORKFLOW_IDS_ENV];
} else {
process.env[SUBAGENT_WORKFLOW_IDS_ENV] = originalChildWorkflowIds;
}
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
describe("workflow semantic tool rendering", () => {
/** Proves activation is semantic and its internal success payload stays hidden. */
test("renders activation identically in the active and subagent session screens", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
const execution = await executeTool(fixture, "workflow_activate", {
workflowId: "delivery",
});
const active = renderCompletedTool(execution.definition, execution);
const session = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_activate"),
execution,
);
expect(active).toEqual({
call: ["workflow_activate", "Workflow: delivery · Delivery process"],
result: [],
});
expect(session).toEqual(active);
expect(execution.result.content).toEqual([
{ type: "text", text: '{"success":true}' },
]);
});
/**
* Proves streaming arguments cannot freeze an empty workflow reference in renderer state.
* Input and expected output: an incomplete empty ID followed by complete Coding renders Workflow: Coding.
* Edge case: both render calls reuse the same tool-row state, matching Pi streaming behavior.
* Dependencies: Pi ToolRenderContext.argsComplete and the workflow activation renderer.
*/
test("waits for complete activation arguments before caching identity", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
const definition = fixture.tools.find(
({ name }) => name === "workflow_activate",
);
if (definition?.renderCall === undefined) {
throw new Error("workflow_activate renderer missing");
}
const context = createToolRenderContext({
args: { workflowId: "" },
expanded: false,
isError: false,
});
context.argsComplete = false;
const partial = definition
.renderCall({ workflowId: "" }, PLAIN_THEME, context)
.render(80);
expect(partial).toEqual(["workflow_activate"]);
context.argsComplete = true;
const completed = definition
.renderCall({ workflowId: "Coding" }, PLAIN_THEME, context)
.render(80)
.map((line) => line.trimEnd());
expect(completed).toEqual(["workflow_activate", "Workflow: Coding"]);
});
/**
* Proves partial workflow fields render as they arrive without persisting incomplete identity.
* Input and expected output: successive incomplete arguments add YAML and the initial stage.
* Edge case: YAML with at most three visual lines has no hidden-content hint.
* Dependencies: current render arguments, shared renderer state, and session reconstruction.
*/
test("streams bounded workflow creation YAML in active and subagent screens", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
const activeDefinition = fixture.tools.find(
({ name }) => name === "workflow_create",
);
const sessionDefinition = resolveSessionDefinition(
fixture,
"workflow_create",
);
if (
activeDefinition?.renderCall === undefined ||
sessionDefinition.renderCall === undefined
) {
throw new Error("workflow_create renderer missing");
}
const activeRenderCall = activeDefinition.renderCall;
const sessionRenderCall = sessionDefinition.renderCall;
const activeContext = createToolRenderContext({
args: {},
expanded: false,
isError: false,
});
const sessionContext = createToolRenderContext({
args: {},
expanded: false,
isError: false,
});
activeContext.argsComplete = false;
sessionContext.argsComplete = false;
const renderBoth = (args: Record) => {
const active = activeRenderCall(args, PLAIN_THEME, activeContext)
.render(80)
.map((line) => line.trimEnd());
const session = sessionRenderCall(args, PLAIN_THEME, sessionContext)
.render(80)
.map((line) => line.trimEnd());
expect(session).toEqual(active);
return active;
};
expect(renderBoth({})).toEqual(["workflow_create"]);
expect(
renderBoth({
id: "streaming-delivery",
description: "Streaming delivery process",
}),
).toEqual([
"workflow_create",
"Workflow: streaming-delivery · Streaming delivery process",
"Content:",
"description: Streaming delivery process",
]);
const partialArgs = createArguments(
"streaming-delivery",
"Streaming delivery process",
);
const collapsed = renderBoth(partialArgs);
expect(collapsed.slice(0, 4)).toEqual([
"workflow_create",
"Workflow: streaming-delivery · Streaming delivery process",
"Stage: implementation · Implementation stage",
"Content:",
]);
expect(collapsed.slice(4, -1)).toHaveLength(3);
expect(collapsed.at(-1)).toMatch(HIDDEN_LINE_HINT);
activeContext.expanded = true;
sessionContext.expanded = true;
const expanded = renderBoth(partialArgs);
const contentIndex = expanded.indexOf("--- Content ---");
const content = parse(expanded.slice(contentIndex + 1).join("\n"));
expect(content["description"]).toBe(partialArgs["description"]);
expect(typeof content["prompt"]).toBe("string");
expect(Array.isArray(content["stages"])).toBe(true);
expect(Array.isArray(content["transitions"])).toBe(true);
expect(content).not.toHaveProperty("id");
});
/**
* Proves creation shows complete semantic content in active and reconstructed session rendering.
* Input and expected output: collapsed calls show bounded YAML; expanded calls show catalog-shaped YAML.
* Edge case: an exact catalog collision retains the submitted identity and YAML preview.
* Dependencies: presentation events, package registry reconstruction, Pi keybindings, and create tool execution.
*/
test("renders workflow creation before and after execution", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
const created = await executeTool(
fixture,
"workflow_create",
createArguments("dynamic-delivery", "Dynamic delivery process"),
);
const activeCreated = renderCompletedTool(created.definition, created);
const sessionCreated = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_create"),
created,
);
expect(activeCreated.call.slice(0, 4)).toEqual([
"workflow_create",
"Workflow: dynamic-delivery · Dynamic delivery process",
"Stage: implementation · Implementation stage",
"Content:",
]);
expect(activeCreated.call.slice(4, -1)).toHaveLength(3);
expect(activeCreated.call.at(-1)).toMatch(HIDDEN_LINE_HINT);
expect(activeCreated.result).toEqual([]);
expect(sessionCreated).toEqual(activeCreated);
const activeExpanded = renderCompletedTool(
created.definition,
created,
PLAIN_THEME,
100,
true,
);
const sessionExpanded = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_create"),
created,
PLAIN_THEME,
100,
true,
);
expect(activeExpanded.result).toEqual([]);
expect(activeExpanded.call.slice(0, 6)).toEqual([
"workflow_create",
"--- Workflow ---",
"dynamic-delivery · Dynamic delivery process",
"--- Stage ---",
"implementation · Implementation stage",
"--- Content ---",
]);
const expandedContent = parse(activeExpanded.call.slice(6).join("\n"));
expect(expandedContent.description).toBe("Dynamic delivery process");
expect(typeof expandedContent.prompt).toBe("string");
expect(Array.isArray(expandedContent.stages)).toBe(true);
expect(Array.isArray(expandedContent.transitions)).toBe(true);
expect(expandedContent).not.toHaveProperty("id");
expect(sessionExpanded).toEqual(activeExpanded);
const rejected = await executeTool(
fixture,
"workflow_create",
createArguments("delivery", "Conflicting delivery process"),
);
expect(rejected.isError).toBe(true);
const activeRejected = renderCompletedTool(rejected.definition, rejected);
const sessionRejected = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_create"),
rejected,
);
expect(activeRejected.call.slice(0, 4)).toEqual([
"workflow_create",
"Workflow: delivery · Conflicting delivery process",
"Stage: implementation · Implementation stage",
"Content:",
]);
expect(activeRejected.call.slice(4, -1)).toHaveLength(3);
expect(activeRejected.call.at(-1)).toMatch(HIDDEN_LINE_HINT);
expect(activeRejected.result.join("\n")).toContain("Error:");
expect(sessionRejected).toEqual(activeRejected);
});
/**
* Proves compact reference and content labels use semantic title styling.
* Input and expected output: creation and transition calls style labels separately from values.
* Edge case: YAML remains tool output below the styled Content label.
* Dependencies: marked theme rendering and width-aware labeled text.
*/
test("styles compact workflow labels separately from values", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
const creation = await executeTool(
fixture,
"workflow_create",
createArguments("styled-delivery", "Styled delivery process"),
);
const transition = await executeTool(fixture, "workflow_transition", {
stageId: "review",
});
const renderedCreation = renderCompletedTool(
creation.definition,
creation,
MARKED_THEME,
);
const renderedTransition = renderCompletedTool(
transition.definition,
transition,
MARKED_THEME,
);
const expandedCreation = renderCompletedTool(
creation.definition,
creation,
MARKED_THEME,
100,
true,
);
expect(renderedCreation.call.slice(0, 5)).toEqual([
"workflow_create",
"Workflow: styled-delivery · Styled delivery process",
"Stage: implementation · Implementation stage",
"Content:",
"description: Styled delivery process",
]);
expect(renderedTransition.call).toEqual([
"workflow_transition",
"From: implementation · Implementation stage",
"To: review · Review stage",
]);
expect(expandedCreation.call.slice(0, 6)).toEqual([
"workflow_create",
"--- Workflow ---",
"styled-delivery · Styled delivery process",
"--- Stage ---",
"implementation · Implementation stage",
"--- Content ---",
]);
});
/**
* Proves stage inspection and editing use the same semantic rows in active and reconstructed session screens.
* Input and expected output: get shows the saved stage; edit shows replacement fields in collapsed and expanded modes.
* Edge case: successful TUI result rows hide model-visible get JSON and internal edit success JSON.
* Dependencies: presentation events, package registry reconstruction, dynamic workflow state, and YAML rendering.
*/
test("renders stage get and edit identically in active and subagent screens", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
await executeTool(
fixture,
"workflow_create",
createArguments("dynamic-delivery", "Dynamic delivery process"),
);
const get = await executeTool(fixture, "workflow_get_stage", {
stageId: "implementation",
});
const edit = await executeTool(fixture, "workflow_edit_stage", {
stageId: "implementation",
description: "Revised implementation",
prompt: "Follow the corrected implementation requirements.",
model: { thinking: "high" },
});
const getCollapsed = renderCompletedTool(
get.definition,
get,
PLAIN_THEME,
500,
);
const getSessionCollapsed = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_get_stage"),
get,
PLAIN_THEME,
500,
);
expect(getSessionCollapsed).toEqual(getCollapsed);
expect(getCollapsed.call.slice(0, -1)).toEqual([
"workflow_get_stage: implementation",
"Description: Implementation stage",
"Prompt: Implement the change",
"Thinking: medium",
"Initial: true",
]);
expect(getCollapsed.call.at(-1)).toMatch(HIDDEN_LINE_HINT);
expect(getCollapsed.result).toEqual([]);
const getExpanded = renderCompletedTool(
get.definition,
get,
PLAIN_THEME,
100,
true,
);
expect(
renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_get_stage"),
get,
PLAIN_THEME,
100,
true,
),
).toEqual(getExpanded);
expect(getExpanded).toEqual({
call: [
"workflow_get_stage: implementation",
"--- Description ---",
"Implementation stage",
"--- Prompt ---",
"Implement the change",
"--- Thinking ---",
"medium",
"--- Initial ---",
"true",
"--- Final ---",
"false",
],
result: [],
});
const editCollapsed = renderCompletedTool(
edit.definition,
edit,
PLAIN_THEME,
500,
);
expect(
renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_edit_stage"),
edit,
PLAIN_THEME,
500,
),
).toEqual(editCollapsed);
expect(editCollapsed).toEqual({
call: [
"workflow_edit_stage: implementation",
"Description: Implementation stage -> Revised implementation",
"Prompt: Implement the change -> Follow the corrected implementation requirements.",
"Thinking: medium -> high",
],
result: [],
});
const styledEdit = renderCompletedTool(
edit.definition,
edit,
MARKED_THEME,
500,
);
expect(styledEdit.call[1]).toBe(
"Description: Implementation stage -> Revised implementation",
);
expect(styledEdit.call[2]).toContain(
"Prompt:",
);
const editExpanded = renderCompletedTool(
edit.definition,
edit,
PLAIN_THEME,
100,
true,
);
expect(
renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_edit_stage"),
edit,
PLAIN_THEME,
100,
true,
),
).toEqual(editExpanded);
expect(editExpanded).toEqual({
call: [
"workflow_edit_stage: implementation",
"--- Description ---",
"Implementation stage",
"->",
"Revised implementation",
"--- Prompt ---",
"Implement the change",
"->",
"Follow the corrected implementation requirements.",
"--- Thinking ---",
"medium",
"->",
"high",
],
result: [],
});
const styledExpandedEdit = renderCompletedTool(
edit.definition,
edit,
MARKED_THEME,
100,
true,
);
expect(styledExpandedEdit.call[1]).toBe(
"--- Description ---",
);
expect(styledExpandedEdit.call[3]).toBe("->");
const unchanged = await executeTool(fixture, "workflow_edit_stage", {
stageId: "implementation",
description: "Revised implementation",
prompt: "Follow the corrected implementation requirements.",
model: { thinking: "high" },
});
expect(
renderCompletedTool(
unchanged.definition,
unchanged,
PLAIN_THEME,
100,
true,
),
).toEqual({
call: ["workflow_edit_stage: implementation", "No changes."],
result: [],
});
expect(get.result.content).toEqual([
{
type: "text",
text: '{"id":"implementation","description":"Implementation stage","prompt":"Implement the change","model":{"thinking":"medium"},"initial":true,"final":false}',
},
]);
expect(edit.result.content).toEqual([
{ type: "text", text: '{"success":true}' },
]);
});
/** Proves multiline stage values retain labels and unambiguous edit boundaries. */
test("renders multiline stage values as labeled semantic blocks", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
const args = createArguments(
"dynamic-delivery",
"Dynamic delivery process",
);
const implementation = (args["stages"] as Record[])[0];
if (implementation === undefined) {
throw new Error("implementation stage fixture is missing");
}
implementation["prompt"] = "Inspect current state.\nImplement the change.";
await executeTool(fixture, "workflow_create", args);
const get = await executeTool(fixture, "workflow_get_stage", {
stageId: "implementation",
});
const edit = await executeTool(fixture, "workflow_edit_stage", {
stageId: "implementation",
description: "Implementation stage",
prompt: "Read corrected requirements.\nImplement the corrected change.",
model: { thinking: "medium" },
});
const getExpanded = renderCompletedTool(
get.definition,
get,
PLAIN_THEME,
100,
true,
);
expect(getExpanded.call).toContain("--- Prompt ---");
expect(getExpanded.call).toContain("Inspect current state.");
expect(getExpanded.call).toContain("Implement the change.");
const editExpanded = renderCompletedTool(
edit.definition,
edit,
PLAIN_THEME,
100,
true,
);
expect(editExpanded.call).toEqual([
"workflow_edit_stage: implementation",
"--- Prompt ---",
"Inspect current state.",
"Implement the change.",
"->",
"Read corrected requirements.",
"Implement the corrected change.",
]);
const getCollapsed = renderCompletedTool(
get.definition,
get,
PLAIN_THEME,
500,
);
expect(getCollapsed.call).toContain(
"Prompt: Inspect current state. Implement the change.",
);
expect(getCollapsed.call).not.toContain(" Inspect current state.");
const editCollapsed = renderCompletedTool(
edit.definition,
edit,
PLAIN_THEME,
500,
);
expect(editCollapsed.call).toEqual([
"workflow_edit_stage: implementation",
"Prompt: Inspect current state. Implement the change. -> Read corrected requirements. Implement the corrected change.",
]);
});
/** Proves the transition snapshot keeps the source stage after state mutation. */
test("renders transition source and target identically after success", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
await executeTool(fixture, "workflow_activate", { workflowId: "delivery" });
const execution = await executeTool(fixture, "workflow_transition", {
stageId: "review",
});
const active = renderCompletedTool(execution.definition, execution);
const session = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_transition"),
execution,
);
expect(active).toEqual({
call: [
"workflow_transition",
"From: implementation · Implementation stage",
"To: review · Review stage",
],
result: [],
});
expect(session).toEqual(active);
// The session overlay uses Pi's public component around the package presentation.
const component = new ToolExecutionComponent(
"workflow_transition",
"replayed-transition",
execution.args,
{},
resolveSessionDefinition(fixture, "workflow_transition"),
{ requestRender(): void {} } as TUI,
"/tmp",
);
component.markExecutionStarted();
component.setArgsComplete();
component.updateResult({ ...execution.result, isError: false });
const componentText = stripVTControlCharacters(
component.render(100).join("\n"),
);
expect(componentText).toContain("workflow_transition");
expect(componentText).toContain(
"From: implementation · Implementation stage",
);
expect(componentText).toContain("To: review · Review stage");
expect(componentText).not.toContain('{"success":true}');
});
/** Proves both tools normalize, wrap, and summarize long references in collapsed mode. */
test("bounds collapsed workflow references in active and subagent screens", async () => {
await createWorkflowSuite(
"Delivery process with enough detail to wrap while preserving the complete workflow reference for expansion "
.repeat(3)
.trim(),
"Implementation stage with enough detail to wrap while preserving the complete source reference for expansion "
.repeat(3)
.trim(),
"Review stage with enough detail to wrap while preserving the complete target reference for expansion "
.repeat(3)
.trim(),
);
const fixture = await createFixture();
const activation = await executeTool(fixture, "workflow_activate", {
workflowId: "delivery",
});
const transition = await executeTool(fixture, "workflow_transition", {
stageId: "review",
});
const activeActivation = renderCompletedTool(
activation.definition,
activation,
PLAIN_THEME,
60,
);
const sessionActivation = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_activate"),
activation,
PLAIN_THEME,
60,
);
const activeTransition = renderCompletedTool(
transition.definition,
transition,
PLAIN_THEME,
60,
);
const sessionTransition = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_transition"),
transition,
PLAIN_THEME,
60,
);
expect(sessionActivation).toEqual(activeActivation);
expect(sessionTransition).toEqual(activeTransition);
expect(activeActivation.call).toHaveLength(6);
expect(activeActivation.call[0]).toBe("workflow_activate");
expect(activeActivation.call[1]).toStartWith(
"Workflow: delivery · Delivery",
);
expect(activeActivation.call[5]).toMatch(HIDDEN_LINE_HINT);
expect(activeTransition.call).toHaveLength(11);
expect(activeTransition.call[0]).toBe("workflow_transition");
expect(activeTransition.call[1]).toStartWith("From: implementation ·");
expect(activeTransition.call[5]).toMatch(HIDDEN_LINE_HINT);
expect(activeTransition.call[6]).toStartWith("To: review · Review stage");
expect(activeTransition.call[10]).toMatch(HIDDEN_LINE_HINT);
expect(activeActivation.call.join("\n")).not.toContain(" ");
expect(activeTransition.call.join("\n")).not.toContain(" ");
});
/** Proves both tools expose every reference under semantic section headings when expanded. */
test("renders complete expanded workflow references in active and subagent screens", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
const activation = await executeTool(fixture, "workflow_activate", {
workflowId: "delivery",
});
const transition = await executeTool(fixture, "workflow_transition", {
stageId: "review",
});
const activeActivation = renderCompletedTool(
activation.definition,
activation,
PLAIN_THEME,
100,
true,
);
const sessionActivation = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_activate"),
activation,
PLAIN_THEME,
100,
true,
);
const activeTransition = renderCompletedTool(
transition.definition,
transition,
PLAIN_THEME,
100,
true,
);
const sessionTransition = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_transition"),
transition,
PLAIN_THEME,
100,
true,
);
expect(activeActivation).toEqual({
call: [
"workflow_activate",
"--- Workflow ---",
"delivery · Delivery process",
],
result: [],
});
expect(sessionActivation).toEqual(activeActivation);
expect(activeTransition).toEqual({
call: [
"workflow_transition",
"--- From ---",
"implementation · Implementation stage",
"--- To ---",
"review · Review stage",
],
result: [],
});
expect(sessionTransition).toEqual(activeTransition);
});
/** Proves failed transitions retain semantic rows and use the approved color roles. */
test("renders transition errors with a bright label and muted evidence", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
await executeTool(fixture, "workflow_activate", { workflowId: "delivery" });
const execution = await executeTool(fixture, "workflow_transition", {
stageId: "implementation",
});
// Advance live state so the failed row can only retain its source through persisted details.
await executeTool(fixture, "workflow_transition", { stageId: "review" });
const sessionDefinition = resolveSessionDefinition(
fixture,
"workflow_transition",
);
const rendered = renderCompletedTool(
sessionDefinition,
execution,
MARKED_THEME,
);
expect(rendered.call).toEqual([
"workflow_transition",
"From: implementation · Implementation stage",
"To: implementation · Implementation stage",
]);
expect(rendered.result).toEqual([
"Error: transition to implementation is not allowed; available transitions: review",
]);
});
/** Proves semantic references, hints, and expanded YAML honor Pi's default tool-shell child width. */
test("keeps workflow rows inside the default shell width", async () => {
await createWorkflowSuite();
const fixture = await createFixture();
await executeTool(fixture, "workflow_activate", { workflowId: "delivery" });
const execution = await executeTool(fixture, "workflow_transition", {
stageId: "review",
});
const definition = resolveSessionDefinition(fixture, "workflow_transition");
const rendered = renderCompletedTool(
definition,
execution,
PLAIN_THEME,
22,
);
const creationArgs = createArguments(
"dynamic-delivery",
"Dynamic delivery process",
);
const creation = await executeTool(
fixture,
"workflow_create",
creationArgs,
);
const creationDefinition = resolveSessionDefinition(
fixture,
"workflow_create",
);
const collapsedCreation = renderCompletedTool(
creationDefinition,
creation,
PLAIN_THEME,
22,
);
const expandedCreation = renderCompletedTool(
creationDefinition,
creation,
PLAIN_THEME,
20,
true,
);
const stageGet = await executeTool(fixture, "workflow_get_stage", {
stageId: "implementation",
});
const narrowStageGet = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_get_stage"),
stageGet,
PLAIN_THEME,
20,
true,
);
const stageEdit = await executeTool(fixture, "workflow_edit_stage", {
stageId: "implementation",
description: "A revised implementation description",
prompt: "A revised implementation prompt that wraps",
model: { thinking: "high" },
});
const narrowStageEdit = renderCompletedTool(
resolveSessionDefinition(fixture, "workflow_edit_stage"),
stageEdit,
PLAIN_THEME,
20,
true,
);
const contentIndex = expandedCreation.call.indexOf("--- Content ---");
const yamlLines = expandedCreation.call.slice(contentIndex + 1);
const { description, stages, transitions } = creationArgs;
const parsedContent = parse(yamlLines.join("\n"));
expect(parsedContent["description"]).toEqual(description);
expect(typeof parsedContent["prompt"]).toBe("string");
expect(parsedContent["stages"]).toEqual(stages);
expect(parsedContent["transitions"]).toEqual(transitions);
for (const line of yamlLines) {
expect(visibleWidth(stripVTControlCharacters(line))).toBeLessThanOrEqual(
20,
);
}
const shell = new Box(1, 1);
for (const line of [
...rendered.call,
...rendered.result,
...collapsedCreation.call,
...expandedCreation.call,
...narrowStageGet.call,
...narrowStageEdit.call,
]) {
shell.addChild({ render: () => [line], invalidate: () => {} });
}
for (const line of shell.render(24)) {
expect(visibleWidth(stripVTControlCharacters(line))).toBeLessThanOrEqual(
24,
);
}
});
});