feat(pi-workflow): improve workflow usability and recovery

This commit is contained in:
jb
2026-08-26 00:20:24 -04:00
parent c291ca083c
commit ea74dbc6a7
23 changed files with 4422 additions and 913 deletions
+393
View File
@@ -10,6 +10,7 @@ import {
registerPiWorkflowTool,
workflowCommandFromTool,
} from "./tool.ts";
import { WorkspaceValidationError } from "./workspace.ts";
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
return {
@@ -79,6 +80,25 @@ test("tool commands derive active tools for feature and analysis scripts", () =>
},
["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 : [],
@@ -189,6 +209,379 @@ test("compact tool details expose only valid continuation intents", () => {
);
});
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;
registerPiWorkflowTool(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;
registerPiWorkflowTool(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;
registerPiWorkflowTool(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;
registerPiWorkflowTool(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[] = [];