feat(pi-workflow): improve workflow usability and recovery
This commit is contained in:
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user