727 lines
20 KiB
TypeScript
727 lines
20 KiB
TypeScript
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,
|
|
registerWorkflowTool,
|
|
workflowCommandFromTool,
|
|
} from "./tool.ts";
|
|
import { WorkspaceValidationError } from "./workspace.ts";
|
|
|
|
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
|
|
return {
|
|
runId: "run-1",
|
|
featureName: "search-page",
|
|
request: "Build search page",
|
|
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.deepEqual(
|
|
workflowCommandFromTool(
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "dirty-feature-key",
|
|
allowDirtySource: true,
|
|
},
|
|
["read"],
|
|
),
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "dirty-feature-key",
|
|
allowDirtySource: true,
|
|
},
|
|
);
|
|
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("TUI dirty-source starts distinguish omitted, refused, and approved flags", async () => {
|
|
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
|
const commands: WorkflowCommand[] = [];
|
|
const pi = {
|
|
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
|
definition = candidate;
|
|
},
|
|
getActiveTools: () => ["read"],
|
|
} as unknown as ExtensionAPI;
|
|
registerWorkflowTool(pi, {
|
|
store: {} as never,
|
|
async engineForContext() {
|
|
return {
|
|
async execute(command: WorkflowCommand) {
|
|
commands.push(structuredClone(command));
|
|
if (command.action === "start" && !command.allowDirtySource) {
|
|
throw new WorkspaceValidationError(
|
|
"dirty-source",
|
|
"source changes are omitted",
|
|
{
|
|
sourceChanges: [{ status: "??", path: "local-notes.md" }],
|
|
sourceChangesOmitted: 2,
|
|
},
|
|
);
|
|
}
|
|
return workflowView();
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
assert.ok(definition);
|
|
let confirmation = "";
|
|
await definition.execute(
|
|
"dirty-call",
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "dirty-key",
|
|
},
|
|
new AbortController().signal,
|
|
undefined,
|
|
{
|
|
mode: "tui",
|
|
sessionManager: { getSessionId: () => "session-1" },
|
|
ui: {
|
|
custom: async (factory: (
|
|
tui: { requestRender(): void; terminal: { rows: number } },
|
|
theme: { fg(color: string, text: string): string; bg(color: string, text: string): string },
|
|
keybindings: object,
|
|
done: (value: boolean) => void,
|
|
) => { render(width: number): string[] }) => {
|
|
const component = factory(
|
|
{ requestRender() {}, terminal: { rows: 24 } },
|
|
{
|
|
fg: (_color, text) => text,
|
|
bg: (_color, text) => text,
|
|
},
|
|
{},
|
|
() => {},
|
|
);
|
|
confirmation = component.render(100).join("\n");
|
|
return true;
|
|
},
|
|
},
|
|
} as unknown as ExtensionContext,
|
|
);
|
|
assert.match(confirmation, /local-notes\.md/);
|
|
assert.match(confirmation, /2 additional paths omitted/);
|
|
assert.deepEqual(
|
|
commands.map((command) =>
|
|
command.action === "start" ? command.allowDirtySource : undefined,
|
|
),
|
|
[false, true],
|
|
);
|
|
|
|
const explicitContext = {
|
|
mode: "tui",
|
|
sessionManager: { getSessionId: () => "session-1" },
|
|
ui: {
|
|
custom: async () => {
|
|
confirmation = "unexpected prompt";
|
|
return false;
|
|
},
|
|
},
|
|
} as unknown as ExtensionContext;
|
|
confirmation = "";
|
|
await assert.rejects(
|
|
definition.execute(
|
|
"dirty-call-refused",
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "dirty-key-refused",
|
|
allowDirtySource: false,
|
|
},
|
|
new AbortController().signal,
|
|
undefined,
|
|
explicitContext,
|
|
),
|
|
/source changes are omitted/,
|
|
);
|
|
assert.equal(confirmation, "");
|
|
|
|
await definition.execute(
|
|
"dirty-call-approved",
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "dirty-key-approved",
|
|
allowDirtySource: true,
|
|
},
|
|
new AbortController().signal,
|
|
undefined,
|
|
explicitContext,
|
|
);
|
|
assert.equal(confirmation, "");
|
|
assert.deepEqual(
|
|
commands.map((command) =>
|
|
command.action === "start" ? command.allowDirtySource : undefined,
|
|
),
|
|
[false, true, false, true],
|
|
);
|
|
});
|
|
|
|
test("RPC dirty-source starts never open TUI overlays and require the flag", async () => {
|
|
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
|
const commands: WorkflowCommand[] = [];
|
|
const pi = {
|
|
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
|
definition = candidate;
|
|
},
|
|
getActiveTools: () => ["read"],
|
|
} as unknown as ExtensionAPI;
|
|
registerWorkflowTool(pi, {
|
|
store: {} as never,
|
|
async engineForContext() {
|
|
return {
|
|
async execute(command: WorkflowCommand) {
|
|
commands.push(structuredClone(command));
|
|
if (command.action === "start" && !command.allowDirtySource) {
|
|
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
|
|
}
|
|
return workflowView();
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
assert.ok(definition);
|
|
const ctx = {
|
|
mode: "rpc",
|
|
sessionManager: { getSessionId: () => "session-1" },
|
|
ui: {
|
|
custom: async () => {
|
|
throw new Error("RPC must not open a custom overlay");
|
|
},
|
|
},
|
|
} as unknown as ExtensionContext;
|
|
await assert.rejects(
|
|
definition.execute(
|
|
"rpc-dirty-rejected",
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "dirty-key-rejected",
|
|
},
|
|
new AbortController().signal,
|
|
undefined,
|
|
ctx,
|
|
),
|
|
/explicit approval required/,
|
|
);
|
|
await assert.rejects(
|
|
definition.execute(
|
|
"rpc-dirty-refused",
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "dirty-key-refused",
|
|
allowDirtySource: false,
|
|
},
|
|
new AbortController().signal,
|
|
undefined,
|
|
ctx,
|
|
),
|
|
/explicit approval required/,
|
|
);
|
|
await definition.execute(
|
|
"rpc-dirty-approved",
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "dirty-key-approved",
|
|
allowDirtySource: true,
|
|
},
|
|
new AbortController().signal,
|
|
undefined,
|
|
ctx,
|
|
);
|
|
assert.deepEqual(
|
|
commands.map((command) =>
|
|
command.action === "start" ? command.allowDirtySource : undefined,
|
|
),
|
|
[undefined, false, true],
|
|
);
|
|
});
|
|
|
|
test("start tool cancellation waits for engine settlement before rejecting", async () => {
|
|
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
|
let markStarted = () => {};
|
|
let releaseStart = () => {};
|
|
const started = new Promise<void>((resolve) => {
|
|
markStarted = resolve;
|
|
});
|
|
const blocked = new Promise<void>((resolve) => {
|
|
releaseStart = resolve;
|
|
});
|
|
let cancelled = false;
|
|
let cancellationReason = new Error("cancelled before mutation");
|
|
let laterMutation = false;
|
|
let engineOperation: Promise<WorkflowView> | undefined;
|
|
const pi = {
|
|
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
|
definition = candidate;
|
|
},
|
|
getActiveTools: () => ["read"],
|
|
} as unknown as ExtensionAPI;
|
|
registerWorkflowTool(pi, {
|
|
store: {} as never,
|
|
async engineForContext() {
|
|
return {
|
|
async cancelStart(
|
|
_idempotencyKey: string,
|
|
_abortIdempotencyKey: string,
|
|
reason: Error,
|
|
) {
|
|
cancelled = true;
|
|
cancellationReason = reason;
|
|
await engineOperation?.catch(() => {});
|
|
},
|
|
execute() {
|
|
engineOperation = (async () => {
|
|
markStarted();
|
|
await blocked;
|
|
if (cancelled) throw cancellationReason;
|
|
laterMutation = true;
|
|
return workflowView();
|
|
})();
|
|
return engineOperation;
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
assert.ok(definition);
|
|
const controller = new AbortController();
|
|
const operation = definition.execute(
|
|
"cancel-slow-start",
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "cancel-slow-start-key",
|
|
},
|
|
controller.signal,
|
|
undefined,
|
|
{
|
|
mode: "rpc",
|
|
sessionManager: { getSessionId: () => "session-1" },
|
|
} as unknown as ExtensionContext,
|
|
);
|
|
await started;
|
|
let toolSettled = false;
|
|
void operation.then(
|
|
() => {
|
|
toolSettled = true;
|
|
},
|
|
() => {
|
|
toolSettled = true;
|
|
},
|
|
);
|
|
controller.abort(new Error("cancelled by tool caller"));
|
|
await Promise.resolve();
|
|
assert.equal(toolSettled, false);
|
|
releaseStart();
|
|
|
|
await assert.rejects(operation, /cancelled by tool caller/);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(laterMutation, false);
|
|
});
|
|
|
|
test("cancellation during dirty-source confirmation cannot start a retry", async () => {
|
|
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
|
const commands: WorkflowCommand[] = [];
|
|
let worktreeCreations = 0;
|
|
let showConfirmation = () => {};
|
|
const confirmationShown = new Promise<void>((resolve) => {
|
|
showConfirmation = resolve;
|
|
});
|
|
let finishConfirmation = (_value: boolean) => {};
|
|
const confirmation = new Promise<boolean>((resolve) => {
|
|
finishConfirmation = resolve;
|
|
});
|
|
const pi = {
|
|
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
|
definition = candidate;
|
|
},
|
|
getActiveTools: () => ["read"],
|
|
} as unknown as ExtensionAPI;
|
|
registerWorkflowTool(pi, {
|
|
store: {
|
|
async getStart() {
|
|
return { runId: "not-created" };
|
|
},
|
|
async loadRun() {
|
|
throw new Error("not created");
|
|
},
|
|
} as never,
|
|
async engineForContext() {
|
|
return {
|
|
async cancelStart() {},
|
|
async execute(command: WorkflowCommand) {
|
|
commands.push(structuredClone(command));
|
|
if (command.action === "start" && command.allowDirtySource === true) {
|
|
worktreeCreations++;
|
|
return workflowView();
|
|
}
|
|
throw new WorkspaceValidationError("dirty-source", "explicit approval required");
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
assert.ok(definition);
|
|
const controller = new AbortController();
|
|
const operation = definition.execute(
|
|
"cancel-dirty-confirmation",
|
|
{
|
|
action: "start",
|
|
request: "Build search",
|
|
featureName: "search-page",
|
|
idempotencyKey: "cancel-dirty-key",
|
|
},
|
|
controller.signal,
|
|
undefined,
|
|
{
|
|
mode: "tui",
|
|
sessionManager: { getSessionId: () => "session-1" },
|
|
ui: {
|
|
custom: async () => {
|
|
showConfirmation();
|
|
return confirmation;
|
|
},
|
|
},
|
|
} as unknown as ExtensionContext,
|
|
);
|
|
await confirmationShown;
|
|
controller.abort(new Error("cancelled while confirming"));
|
|
await assert.rejects(operation, /cancelled while confirming/);
|
|
finishConfirmation(true);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(
|
|
commands.map((command) =>
|
|
command.action === "start" ? command.allowDirtySource : undefined,
|
|
),
|
|
[false],
|
|
);
|
|
assert.equal(worktreeCreations, 0);
|
|
});
|
|
|
|
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;
|
|
registerWorkflowTool(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("inspect errors retain the supplied run ID when no label can be derived", async () => {
|
|
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
|
|
const pi = {
|
|
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
|
|
definition = candidate;
|
|
},
|
|
getActiveTools: () => ["read"],
|
|
} as unknown as ExtensionAPI;
|
|
registerWorkflowTool(pi, {
|
|
store: {} as never,
|
|
async engineForContext() {
|
|
return {
|
|
async execute() {
|
|
throw new Error("not found");
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
assert.ok(definition);
|
|
await assert.rejects(
|
|
definition.execute(
|
|
"inspect-call",
|
|
{ action: "inspect", runId: "missing-run-id" },
|
|
new AbortController().signal,
|
|
undefined,
|
|
{
|
|
sessionManager: { getSessionId: () => "session-1" },
|
|
} as unknown as ExtensionContext,
|
|
),
|
|
/Workflow Run missing-run-id could not be loaded/,
|
|
);
|
|
});
|
|
|
|
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;
|
|
registerWorkflowTool(pi, {
|
|
store: {} as never,
|
|
async engineForContext() {
|
|
return {
|
|
async execute(command: WorkflowCommand) {
|
|
commands.push(structuredClone(command));
|
|
return workflowView();
|
|
},
|
|
} as never;
|
|
},
|
|
});
|
|
assert.equal(definition?.name, "workflow");
|
|
assert.ok(definition);
|
|
const result = 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"],
|
|
);
|
|
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /Workflow search-page/);
|
|
assert.equal((result.details as { runId: string }).runId, "run-1");
|
|
});
|