feat(pi-workflow): add lifecycle slash commands
This commit is contained in:
@@ -43,10 +43,16 @@ function fakeApi() {
|
||||
let command: CommandHandler | undefined;
|
||||
let shutdown: ShutdownHandler | undefined;
|
||||
let start: StartHandler | undefined;
|
||||
const commandNames: string[] = [];
|
||||
const userMessages: string[] = [];
|
||||
const api = {
|
||||
registerCommand(_name: string, options: { handler: CommandHandler }) {
|
||||
registerCommand(name: string, options: { handler: CommandHandler }) {
|
||||
commandNames.push(name);
|
||||
command = options.handler;
|
||||
},
|
||||
sendUserMessage(message: string) {
|
||||
userMessages.push(message);
|
||||
},
|
||||
on(event: string, handler: ShutdownHandler | StartHandler) {
|
||||
if (event === "session_shutdown") shutdown = handler as ShutdownHandler;
|
||||
if (event === "session_start") start = handler as StartHandler;
|
||||
@@ -54,6 +60,8 @@ function fakeApi() {
|
||||
} as unknown as ExtensionAPI;
|
||||
return {
|
||||
api,
|
||||
commandNames,
|
||||
userMessages,
|
||||
command: () => {
|
||||
assert.ok(command);
|
||||
return command;
|
||||
@@ -117,7 +125,10 @@ function commandContext(options: {
|
||||
sessionFile: string;
|
||||
compact?: ExtensionCommandContext["compact"];
|
||||
newSession?: ExtensionCommandContext["newSession"];
|
||||
sent?: unknown[];
|
||||
mode?: ExtensionCommandContext["mode"];
|
||||
hasUI?: boolean;
|
||||
input?: (title: string, placeholder?: string) => Promise<string | undefined>;
|
||||
select?: (title: string, options: string[]) => Promise<string | undefined>;
|
||||
}) {
|
||||
const notifications: string[] = [];
|
||||
const context = {
|
||||
@@ -126,10 +137,14 @@ function commandContext(options: {
|
||||
getSessionId: () => options.sessionId,
|
||||
getSessionFile: () => options.sessionFile,
|
||||
},
|
||||
mode: options.mode ?? "print",
|
||||
hasUI: options.hasUI ?? false,
|
||||
ui: {
|
||||
notify(message: string) {
|
||||
notifications.push(message);
|
||||
},
|
||||
input: options.input ?? (async () => undefined),
|
||||
select: options.select ?? (async () => undefined),
|
||||
},
|
||||
async waitForIdle() {},
|
||||
compact: options.compact ?? (() => {}),
|
||||
@@ -167,6 +182,331 @@ test("/workflow routes non-continuation arguments to the dashboard", async () =>
|
||||
assert.deepEqual(opened, ["run-1"]);
|
||||
});
|
||||
|
||||
test("/workflow start routes the exact request through the assistant", async () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => {
|
||||
throw new Error("start should route through the assistant");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
await registered.command()(
|
||||
"start Build searchable workflow cards",
|
||||
commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
}).context,
|
||||
);
|
||||
assert.equal(registered.userMessages.length, 1);
|
||||
assert.match(registered.userMessages[0]!, /invoke pi_workflow/);
|
||||
assert.ok(
|
||||
registered.userMessages[0]!.endsWith("\n\nBuild searchable workflow cards"),
|
||||
);
|
||||
});
|
||||
|
||||
test("the misspelled /worlflow alias is not registered", () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => {
|
||||
throw new Error("command registration should not create an engine");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
assert.deepEqual(registered.commandNames, ["workflow"]);
|
||||
});
|
||||
|
||||
test("/workflow start prompts in the TUI and requires a message otherwise", async () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => {
|
||||
throw new Error("start should route through the assistant");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
let prompt = "";
|
||||
await registered.command()(
|
||||
"start",
|
||||
commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
mode: "tui",
|
||||
hasUI: true,
|
||||
input: async (title) => {
|
||||
prompt = title;
|
||||
return "Add workflow lifecycle commands";
|
||||
},
|
||||
}).context,
|
||||
);
|
||||
assert.equal(prompt, "What do you want to start working on?");
|
||||
assert.ok(
|
||||
registered.userMessages[0]!.endsWith("\n\nAdd workflow lifecycle commands"),
|
||||
);
|
||||
|
||||
const fallback = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
});
|
||||
await registered.command()("start", fallback.context);
|
||||
assert.ok(fallback.notifications.some((message) => message.includes("required")));
|
||||
});
|
||||
|
||||
test("/workflow continue lists only valid runs and resume is an alias", async () => {
|
||||
const validRun = (runId: string, activeParentSessionId = "session-1") =>
|
||||
({
|
||||
runId,
|
||||
revision: 10,
|
||||
status: "waiting",
|
||||
request: `Request ${runId}`,
|
||||
parentSessionIds: [activeParentSessionId],
|
||||
lease: { activeParentSessionId },
|
||||
planArtifact: { path: "/plan.md", revision: 1, digest: "digest" },
|
||||
implementationContext: {
|
||||
gateId: `gate-${runId}`,
|
||||
runRevision: 10,
|
||||
planRevision: 1,
|
||||
preparationId: `prep-${runId}`,
|
||||
activeParentSessionId,
|
||||
strategy: "fresh-session",
|
||||
status: "handoff-pending",
|
||||
method: "fresh-session",
|
||||
},
|
||||
gates: [
|
||||
{
|
||||
id: `gate-${runId}`,
|
||||
kind: "implementation-context",
|
||||
status: "open",
|
||||
revision: 10,
|
||||
openedAt: 1,
|
||||
},
|
||||
],
|
||||
assignments: [],
|
||||
}) as unknown as WorkflowRun;
|
||||
const selectedTitles: string[] = [];
|
||||
const selectedOptions: string[][] = [];
|
||||
const inspected: string[] = [];
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: {
|
||||
listRuns: async () => [
|
||||
validRun("run-1"),
|
||||
validRun("run-2"),
|
||||
validRun("historical", "session-2"),
|
||||
],
|
||||
},
|
||||
engineForContext: () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
if (command.action === "inspect") {
|
||||
inspected.push(command.runId);
|
||||
return {
|
||||
runId: command.runId,
|
||||
revision: 1,
|
||||
status: "running",
|
||||
background: false,
|
||||
workspace: { state: "ready", sourceRoot: "/repo" },
|
||||
stages: [],
|
||||
assignments: [],
|
||||
mutatorProfileNames: [],
|
||||
reviewCycles: 0,
|
||||
verificationCycles: 0,
|
||||
uiImpactEvidence: [],
|
||||
affectedUiSurfaces: [],
|
||||
} as WorkflowView;
|
||||
}
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
},
|
||||
}) as WorkflowEngine,
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const interactive = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
mode: "tui",
|
||||
hasUI: true,
|
||||
select: async (title, options) => {
|
||||
selectedTitles.push(title);
|
||||
selectedOptions.push(options);
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
await registered.command()("continue", interactive.context);
|
||||
await registered.command()("resume", interactive.context);
|
||||
assert.deepEqual(selectedTitles, [
|
||||
"Continue Workflow Run",
|
||||
"Continue Workflow Run",
|
||||
]);
|
||||
assert.equal(selectedOptions[0]?.length, 2);
|
||||
assert.equal(selectedOptions[1]?.length, 2);
|
||||
assert.ok(
|
||||
selectedOptions.every((options) =>
|
||||
options.every((label) => !label.includes("historical")),
|
||||
),
|
||||
);
|
||||
|
||||
await registered.command()("resume run-2", interactive.context);
|
||||
assert.deepEqual(inspected, ["run-2"]);
|
||||
});
|
||||
|
||||
test("/workflow continue without a run ID gives actionable non-UI guidance", async () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: { listRuns: async () => [] },
|
||||
engineForContext: () => {
|
||||
throw new Error("non-UI fallback should not create an engine");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
});
|
||||
await registered.command()("continue", command.context);
|
||||
assert.ok(
|
||||
command.notifications.some((message) =>
|
||||
message.includes("/workflow continue <run-id>"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("/workflow stop selects among active runs owned by the current session", async () => {
|
||||
const run = (
|
||||
runId: string,
|
||||
status: WorkflowRun["status"],
|
||||
activeParentSessionId: string,
|
||||
) =>
|
||||
({
|
||||
runId,
|
||||
revision: 4,
|
||||
status,
|
||||
request: `Request ${runId}`,
|
||||
parentSessionIds: [activeParentSessionId],
|
||||
lease: { activeParentSessionId },
|
||||
}) as unknown as WorkflowRun;
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: {
|
||||
listRuns: async () => [
|
||||
run("run-1", "running", "session-1"),
|
||||
run("run-2", "waiting", "session-1"),
|
||||
run("completed", "completed", "session-1"),
|
||||
run("historical", "running", "session-2"),
|
||||
],
|
||||
},
|
||||
engineForContext: () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(command);
|
||||
if (command.action !== "inspect" && command.action !== "abort") {
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
}
|
||||
return {
|
||||
runId: command.runId,
|
||||
revision: 4,
|
||||
status: command.action === "abort" ? "aborted" : "waiting",
|
||||
} as WorkflowView;
|
||||
},
|
||||
}) as WorkflowEngine,
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
let choices: string[] = [];
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
mode: "tui",
|
||||
hasUI: true,
|
||||
select: async (title, options) => {
|
||||
assert.equal(title, "Stop Workflow Run");
|
||||
choices = options;
|
||||
return options[1];
|
||||
},
|
||||
});
|
||||
await registered.command()("stop", command.context);
|
||||
assert.equal(choices.length, 2);
|
||||
assert.ok(choices.every((label) => !label.includes("completed")));
|
||||
assert.deepEqual(
|
||||
commands.map((entry) =>
|
||||
`${entry.action}:${"runId" in entry ? entry.runId : ""}`,
|
||||
),
|
||||
["inspect:run-2", "abort:run-2"],
|
||||
);
|
||||
const abort = commands[1];
|
||||
assert.match(
|
||||
abort?.action === "abort" ? abort.idempotencyKey : "",
|
||||
/^workflow-stop-run-2-4-/,
|
||||
);
|
||||
});
|
||||
|
||||
test("/workflow stop immediately stops the only active run", async () => {
|
||||
const commands: WorkflowCommand[] = [];
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: {
|
||||
listRuns: async () => [
|
||||
{
|
||||
runId: "run-1",
|
||||
revision: 2,
|
||||
status: "running",
|
||||
request: "Only active run",
|
||||
lease: { activeParentSessionId: "session-1" },
|
||||
} as unknown as WorkflowRun,
|
||||
],
|
||||
},
|
||||
engineForContext: () =>
|
||||
({
|
||||
async execute(command: WorkflowCommand) {
|
||||
commands.push(command);
|
||||
if (command.action !== "inspect" && command.action !== "abort") {
|
||||
throw new Error(`Unexpected command ${command.action}`);
|
||||
}
|
||||
return {
|
||||
runId: command.runId,
|
||||
revision: 2,
|
||||
status: command.action === "abort" ? "aborted" : "running",
|
||||
} as WorkflowView;
|
||||
},
|
||||
}) as WorkflowEngine,
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
});
|
||||
await registered.command()("stop", command.context);
|
||||
assert.deepEqual(
|
||||
commands.map((entry) => entry.action),
|
||||
["inspect", "abort"],
|
||||
);
|
||||
assert.ok(command.notifications.some((message) => message.includes("stopped")));
|
||||
});
|
||||
|
||||
test("/workflow stop requires a run ID for multiple active runs without UI", async () => {
|
||||
const registered = fakeApi();
|
||||
registerWorkflowContextTransitions(registered.api, {
|
||||
store: {
|
||||
listRuns: async () =>
|
||||
["run-1", "run-2"].map(
|
||||
(runId) =>
|
||||
({
|
||||
runId,
|
||||
status: "running",
|
||||
request: runId,
|
||||
lease: { activeParentSessionId: "session-1" },
|
||||
}) as unknown as WorkflowRun,
|
||||
),
|
||||
},
|
||||
engineForContext: () => {
|
||||
throw new Error("selection fallback should not create an engine");
|
||||
},
|
||||
} as unknown as ContextTransitionRuntime);
|
||||
const command = commandContext({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/sessions/source.jsonl",
|
||||
});
|
||||
await registered.command()("stop", command.context);
|
||||
assert.ok(
|
||||
command.notifications.some((message) =>
|
||||
message.includes("/workflow stop <run-id>"),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("/workflow continue invokes and fences normal compaction callbacks", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const planPath = path.join(directory, "plan.md");
|
||||
|
||||
Reference in New Issue
Block a user