add persisted pi workflow extension
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
|
||||
import {
|
||||
compactWorkflowDetails,
|
||||
registerPiWorkflowTool,
|
||||
workflowCommandFromTool,
|
||||
} from "./tool.ts";
|
||||
|
||||
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
|
||||
return {
|
||||
runId: "run-1",
|
||||
revision: 4,
|
||||
status: "waiting",
|
||||
workspace: {
|
||||
state: "ready",
|
||||
sourceRoot: "/repo",
|
||||
root: "/repo/.worktrees/run-1",
|
||||
branch: "feature/search",
|
||||
baseHead: "abc123",
|
||||
},
|
||||
uiImpactEvidence: [],
|
||||
affectedUiSurfaces: [],
|
||||
stages: [],
|
||||
assignments: [],
|
||||
mutatorProfileNames: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
...overrides,
|
||||
background: overrides.background ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
test("tool commands derive active tools for feature and analysis scripts", () => {
|
||||
assert.deepEqual(
|
||||
workflowCommandFromTool(
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "feature-key",
|
||||
workflow: {
|
||||
mode: "feature-script",
|
||||
source: "return null",
|
||||
args: '{"scope":"routes"}',
|
||||
insertionPoint: "before-design",
|
||||
},
|
||||
},
|
||||
["read", "grep", "bash"],
|
||||
),
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "feature-key",
|
||||
script: {
|
||||
source: "return null",
|
||||
args: { scope: "routes" },
|
||||
insertionPoint: "before-design",
|
||||
activeToolNames: ["read", "grep", "bash"],
|
||||
},
|
||||
},
|
||||
);
|
||||
const analysis = workflowCommandFromTool(
|
||||
{
|
||||
action: "start",
|
||||
request: "Audit persistence",
|
||||
workflow: {
|
||||
mode: "analysis-script",
|
||||
source: "return await assign('review', 'Inspect')",
|
||||
},
|
||||
idempotencyKey: "analysis-key",
|
||||
},
|
||||
["read", "ls"],
|
||||
);
|
||||
assert.equal(analysis.action, "start-analysis");
|
||||
assert.deepEqual(
|
||||
analysis.action === "start-analysis" ? analysis.activeToolNames : [],
|
||||
["read", "ls"],
|
||||
);
|
||||
});
|
||||
|
||||
test("tool commands reject open or incomplete action shapes", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
workflowCommandFromTool(
|
||||
{
|
||||
action: "start",
|
||||
request: "Build search",
|
||||
featureName: "search-page",
|
||||
idempotencyKey: "feature-key",
|
||||
workflow: { mode: "feature-script", source: "return null" },
|
||||
},
|
||||
["read"],
|
||||
),
|
||||
/requires workflow.source and workflow.insertionPoint/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
workflowCommandFromTool(
|
||||
{
|
||||
action: "decide",
|
||||
runId: "run-1",
|
||||
gateId: "gate-1",
|
||||
expectedRevision: 2,
|
||||
idempotencyKey: "decision-key",
|
||||
decision: undefined,
|
||||
},
|
||||
["read"],
|
||||
),
|
||||
/decision is required/,
|
||||
);
|
||||
});
|
||||
|
||||
test("compact tool details expose only valid continuation intents", () => {
|
||||
const details = compactWorkflowDetails(
|
||||
"inspect",
|
||||
workflowView({
|
||||
openGate: {
|
||||
id: "gate-1",
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
revision: 4,
|
||||
openedAt: 1,
|
||||
},
|
||||
planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" },
|
||||
implementationContext: {
|
||||
gateId: "gate-1",
|
||||
runRevision: 3,
|
||||
planRevision: 2,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(details.continuation, {
|
||||
command: "/workflow continue",
|
||||
runId: "run-1",
|
||||
});
|
||||
assert.deepEqual(details.openGate?.payload, {
|
||||
preparationId: "prep-1",
|
||||
planRevision: 2,
|
||||
contextRevision: 3,
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
outcome: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
assert.equal(
|
||||
compactWorkflowDetails(
|
||||
"inspect",
|
||||
workflowView({
|
||||
openGate: {
|
||||
id: "gate-1",
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
revision: 4,
|
||||
openedAt: 1,
|
||||
},
|
||||
planArtifact: { path: "/state/plan.md", revision: 2, digest: "digest" },
|
||||
implementationContext: {
|
||||
gateId: "gate-1",
|
||||
runRevision: 3,
|
||||
planRevision: 2,
|
||||
preparationId: "prep-1",
|
||||
activeParentSessionId: "session-1",
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
},
|
||||
}),
|
||||
"session-2",
|
||||
).continuation,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
compactWorkflowDetails("inspect", workflowView()).continuation,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("tool cancellation aborts its active Workflow Run before rejecting", async () => {
|
||||
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
||||
const commands: WorkflowCommand[] = [];
|
||||
let finishDecision = (_view: WorkflowView) => {};
|
||||
const decision = new Promise<WorkflowView>((resolve) => {
|
||||
finishDecision = resolve;
|
||||
});
|
||||
const pi = {
|
||||
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
||||
definition = candidate;
|
||||
},
|
||||
getActiveTools: () => ["read"],
|
||||
} as unknown as ExtensionAPI;
|
||||
registerPiWorkflowTool(pi, {
|
||||
store: {
|
||||
async loadRun() {
|
||||
return { runId: "run-1", revision: 4, status: "running" };
|
||||
},
|
||||
} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
if (command.action === "abort") {
|
||||
const aborted = workflowView({ status: "aborted", revision: 5 });
|
||||
finishDecision(aborted);
|
||||
return aborted;
|
||||
}
|
||||
return decision;
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.ok(definition);
|
||||
const controller = new AbortController();
|
||||
const operation = definition.execute(
|
||||
"cancel-call",
|
||||
{
|
||||
action: "decide",
|
||||
runId: "run-1",
|
||||
gateId: "gate-1",
|
||||
expectedRevision: 4,
|
||||
idempotencyKey: "decision-key",
|
||||
decision: { kind: "recover", resolution: "inspect" },
|
||||
},
|
||||
controller.signal,
|
||||
undefined,
|
||||
{
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
} as unknown as ExtensionContext,
|
||||
);
|
||||
controller.abort(new Error("cancelled by test"));
|
||||
await assert.rejects(operation, /cancelled by test/);
|
||||
assert.equal(commands.some((command) => command.action === "abort"), true);
|
||||
});
|
||||
|
||||
test("registered tool executes through the context engine and injects host tools", async () => {
|
||||
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const pi = {
|
||||
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
||||
definition = candidate;
|
||||
},
|
||||
getActiveTools() {
|
||||
return ["read", "smart_compact"];
|
||||
},
|
||||
} as unknown as ExtensionAPI;
|
||||
registerPiWorkflowTool(pi, {
|
||||
store: {} as never,
|
||||
async engineForContext() {
|
||||
return {
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(structuredClone(command));
|
||||
return workflowView();
|
||||
},
|
||||
} as never;
|
||||
},
|
||||
});
|
||||
assert.equal(definition?.name, "pi_workflow");
|
||||
assert.ok(definition);
|
||||
await definition.execute(
|
||||
"call-1",
|
||||
{
|
||||
action: "decide",
|
||||
runId: "run-1",
|
||||
gateId: "gate-1",
|
||||
expectedRevision: 4,
|
||||
idempotencyKey: "context-report",
|
||||
decision: {
|
||||
kind: "report-context-preparation",
|
||||
preparationId: "prep-1",
|
||||
planRevision: 2,
|
||||
method: "smart_compact",
|
||||
outcome: "succeeded",
|
||||
},
|
||||
},
|
||||
new AbortController().signal,
|
||||
undefined,
|
||||
{
|
||||
sessionManager: { getSessionId: () => "session-1" },
|
||||
} as unknown as ExtensionContext,
|
||||
);
|
||||
assert.deepEqual(
|
||||
commands[0]?.action === "decide" ? commands[0].activeToolNames : undefined,
|
||||
["read", "smart_compact"],
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user