@selesai/code 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +7 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/defaults/models.json +46 -55
- package/dist/defaults/settings.json +7 -8
- package/dist/extensions/agent-browser.test.ts +178 -2
- package/dist/extensions/agent-browser.ts +2 -0
- package/dist/extensions/copy-turn.test.ts +229 -0
- package/dist/extensions/copy-turn.ts +11 -0
- package/dist/extensions/grep-app/index.test.ts +356 -1
- package/dist/extensions/grep-app/index.ts +6 -1
- package/dist/extensions/handoff-new.test.ts +351 -161
- package/dist/extensions/handoff-new.ts +3 -0
- package/dist/extensions/inline-skills.test.ts +93 -0
- package/dist/extensions/inline-skills.ts +3 -0
- package/dist/extensions/pi-subagents/CHANGELOG.md +26 -3
- package/dist/extensions/pi-subagents/README.md +2 -0
- package/dist/extensions/pi-subagents/agents/builder.md +5 -2
- package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
- package/dist/extensions/pi-subagents/docs/configuration.md +54 -2
- package/dist/extensions/pi-subagents/docs/observability.md +1 -1
- package/dist/extensions/pi-subagents/docs/tool-reference.md +1 -1
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/extensions/pi-subagents/src/agents/agents.ts +9 -3
- package/dist/extensions/pi-subagents/src/extension/config.ts +12 -0
- package/dist/extensions/pi-subagents/src/extension/doctor.ts +40 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +104 -14
- package/dist/extensions/pi-subagents/src/extension/public-execution.ts +5 -0
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +3 -9
- package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
- package/dist/extensions/pi-subagents/src/intercom/intercom-bridge.ts +4 -1
- package/dist/extensions/pi-subagents/src/missions/lifecycle.ts +4 -7
- package/dist/extensions/pi-subagents/src/missions/store.ts +4 -4
- package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +374 -0
- package/dist/extensions/pi-subagents/src/runs/background/active-run-index.ts +9 -5
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +117 -29
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +7 -1
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +14 -5
- package/dist/extensions/pi-subagents/src/runs/background/chain-append.ts +33 -15
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +104 -0
- package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +17 -3
- package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +19 -1
- package/dist/extensions/pi-subagents/src/runs/background/retained-children.ts +2 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-id-resolver.ts +5 -2
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -3
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +84 -36
- package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +37 -2
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +135 -22
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +12 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
- package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +171 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +653 -196
- package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +13 -4
- package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +23 -0
- package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +2 -0
- package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +44 -1
- package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +280 -0
- package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +4 -2
- package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +19 -3
- package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +17 -5
- package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +108 -0
- package/dist/extensions/pi-subagents/src/shared/utils.ts +3 -1
- package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +7 -5
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +226 -13
- package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +22 -5
- package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -0
- package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +87 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +96 -6
- package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +28 -5
- package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +8 -5
- package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +83 -2
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +211 -15
- package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +41 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +314 -10
- package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +31 -1
- package/dist/extensions/pi-subagents/test/unit/active-async-capacity.test.ts +317 -0
- package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +21 -2
- package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +16 -1
- package/dist/extensions/pi-subagents/test/unit/async-resume.test.ts +6 -1
- package/dist/extensions/pi-subagents/test/unit/chain-append.test.ts +75 -4
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +18 -0
- package/dist/extensions/pi-subagents/test/unit/doctor.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +31 -5
- package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +139 -1
- package/dist/extensions/pi-subagents/test/unit/foreground-control.test.ts +10 -0
- package/dist/extensions/pi-subagents/test/unit/handoff-adoption.test.ts +103 -0
- package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +22 -0
- package/dist/extensions/pi-subagents/test/unit/intercom-bridge.test.ts +2 -1
- package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +171 -0
- package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +30 -4
- package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +14 -0
- package/dist/extensions/pi-subagents/test/unit/owned-process-tree.test.ts +69 -0
- package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +83 -0
- package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +2 -2
- package/dist/extensions/pi-subagents/test/unit/process-terminal.test.ts +55 -2
- package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +12 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +7 -7
- package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +121 -0
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +9 -0
- package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +4 -3
- package/dist/extensions/pi-subagents/test/unit/session-lineage.test.ts +73 -0
- package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +28 -0
- package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +10 -1
- package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +39 -0
- package/dist/extensions/pi-subagents/test/unit/timeout-defaults.test.ts +65 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -0
- package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +61 -0
- package/dist/extensions/ponytail/index.js +11 -0
- package/dist/extensions/ponytail/ponytail-config.cjs +2 -0
- package/dist/extensions/ponytail/ponytail-instructions.cjs +6 -0
- package/dist/extensions/ponytail/test/extension.test.js +274 -140
- package/dist/extensions/ponytail/test/helpers.test.js +280 -92
- package/dist/extensions/question/index.ts +18 -0
- package/dist/extensions/question/question-list.ts +22 -0
- package/dist/extensions/question/tests/batch.test.ts +103 -65
- package/dist/extensions/question/tests/helpers.test.ts +68 -35
- package/dist/extensions/question/tests/question-list.test.ts +506 -204
- package/dist/extensions/question/tests/row-layout.test.ts +180 -114
- package/dist/extensions/question/tests/schemas.test.ts +42 -19
- package/dist/extensions/question/tests/shortcuts.test.ts +106 -84
- package/dist/extensions/question/tests/wizard.test.ts +771 -50
- package/dist/extensions/rtk.test.ts +180 -1
- package/dist/extensions/tokenin-onboarding.ts +3 -0
- package/dist/extensions/undo.test.ts +720 -0
- package/dist/extensions/undo.ts +6 -0
- package/dist/extensions/web-agent-onboarding.test.ts +415 -0
- package/dist/extensions/workflow/extension.ts +3 -3
- package/dist/extensions/workflow/modes.ts +41 -19
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.js +48 -1
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/docs/plans/workflow-autoloop-reference.md +1 -2
- package/docs/plans/workflow-handoff-carryover.md +108 -0
- package/docs/quickstart.md +27 -43
- package/docs/settings.md +4 -0
- package/docs/usage.md +1 -0
- package/package.json +5 -1
|
@@ -55,6 +55,7 @@ describe("below-editor subagent FleetView", () => {
|
|
|
55
55
|
|
|
56
56
|
it("renders main plus active children below the editor and bounds every line", () => {
|
|
57
57
|
const state = stateForTest();
|
|
58
|
+
state.activeAsyncCapacity = { used: 2, limit: 4 };
|
|
58
59
|
const now = Date.now();
|
|
59
60
|
for (let index = 0; index < 7; index++) {
|
|
60
61
|
state.foregroundControls.set(`run-${index}`, {
|
|
@@ -98,7 +99,7 @@ describe("below-editor subagent FleetView", () => {
|
|
|
98
99
|
const component = widgetFactory!(tui, theme);
|
|
99
100
|
const compactLines = component.render(80);
|
|
100
101
|
assert.equal(compactLines.length, 1);
|
|
101
|
-
assert.ok(compactLines[0]!.includes("7 active agents"));
|
|
102
|
+
assert.ok(compactLines[0]!.includes("7 active agents · Async runs 2/4"));
|
|
102
103
|
assert.ok(compactLines[0]!.includes("↓ 13.7k tokens"));
|
|
103
104
|
assert.ok(compactLines[0]!.includes("↓/← to inspect"));
|
|
104
105
|
assert.ok(visibleWidth(compactLines[0]!) <= 80);
|
|
@@ -122,6 +123,31 @@ describe("below-editor subagent FleetView", () => {
|
|
|
122
123
|
}
|
|
123
124
|
});
|
|
124
125
|
|
|
126
|
+
it("keeps occupied capacity visible without active child rows", () => {
|
|
127
|
+
const state = stateForTest();
|
|
128
|
+
state.activeAsyncCapacity = { used: 1, limit: 2 };
|
|
129
|
+
let widgetFactory: ((tui: unknown, theme: typeof theme) => { render(width: number): string[] }) | undefined;
|
|
130
|
+
const ctx = {
|
|
131
|
+
hasUI: true,
|
|
132
|
+
ui: {
|
|
133
|
+
setWidget(_key: string, content: typeof widgetFactory | undefined) { if (content) widgetFactory = content; },
|
|
134
|
+
onTerminalInput() { return () => {}; },
|
|
135
|
+
getEditorText() { return ""; },
|
|
136
|
+
requestRender() {},
|
|
137
|
+
notify() {},
|
|
138
|
+
theme,
|
|
139
|
+
},
|
|
140
|
+
} as unknown as ExtensionContext;
|
|
141
|
+
const fleet = new SubagentFleetStatus(state, () => {}, { refreshMs: 60_000 });
|
|
142
|
+
try {
|
|
143
|
+
fleet.setContext(ctx);
|
|
144
|
+
assert.ok(widgetFactory);
|
|
145
|
+
assert.deepEqual(widgetFactory!({ requestRender() {} }, theme).render(80), [" Async runs 1/2 · ↓ 0 tokens · ↓/← to inspect"]);
|
|
146
|
+
} finally {
|
|
147
|
+
fleet.dispose();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
125
151
|
it("keeps one queued agent visible in the compact summary", () => {
|
|
126
152
|
const state = stateForTest();
|
|
127
153
|
state.asyncJobs.set("run-worker", {
|
|
@@ -596,7 +622,7 @@ describe("below-editor subagent FleetView", () => {
|
|
|
596
622
|
assert.deepEqual(statusKeys, inspectorKeys);
|
|
597
623
|
});
|
|
598
624
|
|
|
599
|
-
it("
|
|
625
|
+
it("hides tracked async task descriptions and shows per-child token totals", () => {
|
|
600
626
|
const state = stateForTest();
|
|
601
627
|
state.asyncJobs.set("async-run", {
|
|
602
628
|
asyncId: "async-run",
|
|
@@ -630,9 +656,9 @@ describe("below-editor subagent FleetView", () => {
|
|
|
630
656
|
const component = widgetFactory!(tui, theme);
|
|
631
657
|
assert.deepEqual(fleet.handleKey("\x1b[B"), { consume: true });
|
|
632
658
|
const lines = component.render(180);
|
|
633
|
-
assert.ok(lines.some((line) => line.includes("reviewer (gpt-5 · thinking medium)")
|
|
634
|
-
assert.ok(lines.some((line) => line.includes("worker")
|
|
635
|
-
assert.ok(lines.every((line) => !line.includes("Review
|
|
659
|
+
assert.ok(lines.some((line) => line.includes("reviewer (gpt-5 · thinking medium)")));
|
|
660
|
+
assert.ok(lines.some((line) => line.includes("worker")));
|
|
661
|
+
assert.ok(lines.every((line) => !line.includes("Review only authentication") && !line.includes("Implement only billing") && !line.includes("Review the authentication changes")));
|
|
636
662
|
assert.ok(lines.some((line) => line.includes("↓ 4.2k tokens")));
|
|
637
663
|
} finally {
|
|
638
664
|
fleet.dispose();
|
|
@@ -7,8 +7,11 @@ import { visibleWidth, type MarkdownTheme } from "@earendil-works/pi-tui";
|
|
|
7
7
|
import { collectFleetSnapshot, openSubagentFleet, SubagentFleetComponent } from "../../src/tui/fleet.ts";
|
|
8
8
|
import { persistForegroundRunHistory, restoreForegroundRunHistory } from "../../src/runs/foreground/foreground-history.ts";
|
|
9
9
|
import { FLEET_STATUS_WIDGET_KEY } from "../../src/tui/fleet-status.ts";
|
|
10
|
+
import { registerLivePromptAudit, rewritePromptWithGuidance } from "../../src/runs/foreground/prompt-audit.ts";
|
|
10
11
|
import { getArtifactPaths, getArtifactsDir, getProjectArtifactsDir } from "../../src/shared/artifacts.ts";
|
|
11
12
|
import type { SubagentState } from "../../src/shared/types.ts";
|
|
13
|
+
import { fauxAssistantMessage } from "@earendil-works/pi-ai/providers/faux";
|
|
14
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
12
15
|
|
|
13
16
|
function stateForTest(): SubagentState {
|
|
14
17
|
return {
|
|
@@ -92,6 +95,35 @@ const markdownTheme: MarkdownTheme = {
|
|
|
92
95
|
};
|
|
93
96
|
|
|
94
97
|
describe("native subagent fleet", () => {
|
|
98
|
+
it("rewrites an authored prompt from guidance without persistence", async () => {
|
|
99
|
+
const calls: unknown[] = [];
|
|
100
|
+
const streamFn = (_model: never, context: unknown) => {
|
|
101
|
+
calls.push(context);
|
|
102
|
+
const stream = createAssistantMessageEventStream();
|
|
103
|
+
queueMicrotask(() => stream.push({ type: "done", reason: "stop", message: fauxAssistantMessage("Rewritten task", { stopReason: "stop" }) }));
|
|
104
|
+
return stream;
|
|
105
|
+
};
|
|
106
|
+
const model = { provider: "faux", id: "rewrite", api: "faux", input: ["text"], reasoning: false, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 100 };
|
|
107
|
+
const ctx = {
|
|
108
|
+
model,
|
|
109
|
+
signal: undefined,
|
|
110
|
+
modelRegistry: {
|
|
111
|
+
async getApiKeyAndHeaders() { return { ok: true as const, apiKey: "test" }; },
|
|
112
|
+
getRegisteredProviderConfig() { return { api: "faux", streamSimple: streamFn }; },
|
|
113
|
+
},
|
|
114
|
+
} as never;
|
|
115
|
+
const rewritten = await rewritePromptWithGuidance({
|
|
116
|
+
ctx,
|
|
117
|
+
authoredTask: "Original task",
|
|
118
|
+
runtimeAdditions: "Runtime context",
|
|
119
|
+
finalEffectivePrompt: "Runtime context\n\nOriginal task",
|
|
120
|
+
guidance: "Make it narrower",
|
|
121
|
+
});
|
|
122
|
+
assert.equal(rewritten, "Rewritten task");
|
|
123
|
+
assert.match(JSON.stringify(calls), /Original task/);
|
|
124
|
+
assert.match(JSON.stringify(calls), /Make it narrower/);
|
|
125
|
+
});
|
|
126
|
+
|
|
95
127
|
it("collects current-session foreground and flattened async children", () => {
|
|
96
128
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-fleet-collect-"));
|
|
97
129
|
try {
|
|
@@ -136,6 +168,112 @@ describe("native subagent fleet", () => {
|
|
|
136
168
|
}
|
|
137
169
|
});
|
|
138
170
|
|
|
171
|
+
it("shows live prompt summaries and opens Prompt Audit with the authored prompt visible", async () => {
|
|
172
|
+
const sentinel = "PROMPT_AUDIT_SENTINEL_1021";
|
|
173
|
+
const secondSentinel = "PROMPT_AUDIT_SECOND_CHILD";
|
|
174
|
+
const state = stateForTest();
|
|
175
|
+
const control = {
|
|
176
|
+
runId: "prompt-run",
|
|
177
|
+
sessionId: "session-current",
|
|
178
|
+
mode: "parallel" as const,
|
|
179
|
+
startedAt: 10,
|
|
180
|
+
updatedAt: 20,
|
|
181
|
+
activeChildren: new Map([
|
|
182
|
+
[0, { index: 0, agent: "worker", startedAt: 10, updatedAt: 20 }],
|
|
183
|
+
[1, { index: 1, agent: "reviewer", startedAt: 11, updatedAt: 21 }],
|
|
184
|
+
]),
|
|
185
|
+
};
|
|
186
|
+
state.foregroundControls.set(control.runId, control);
|
|
187
|
+
registerLivePromptAudit(control, 0, sentinel, `[Read from: context.md]\n\n${sentinel}\n\n---\nRuntime acceptance`, { rerun: { params: { agent: "worker", task: sentinel } } });
|
|
188
|
+
registerLivePromptAudit(control, 1, secondSentinel, secondSentinel);
|
|
189
|
+
assert.doesNotMatch(JSON.stringify(control), new RegExp(sentinel), "live prompts must not enter serializable Fleet state");
|
|
190
|
+
const clipboardWrites: string[] = [];
|
|
191
|
+
const redoCalls: Array<{ runId: string; index: number; guidance: string }> = [];
|
|
192
|
+
const component = new SubagentFleetComponent(
|
|
193
|
+
{ terminal: { rows: 28, columns: 100 }, requestRender() {} } as never,
|
|
194
|
+
theme as never,
|
|
195
|
+
state,
|
|
196
|
+
() => {},
|
|
197
|
+
{
|
|
198
|
+
refreshMs: 60_000,
|
|
199
|
+
markdownTheme,
|
|
200
|
+
copyText: (text) => { clipboardWrites.push(text); },
|
|
201
|
+
actions: {
|
|
202
|
+
async steer() { return { text: "unused" }; },
|
|
203
|
+
stop() { return { text: "unused" }; },
|
|
204
|
+
async redoPrompt(input) {
|
|
205
|
+
redoCalls.push(input);
|
|
206
|
+
return { text: "Prompt redo started redo-run." };
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
try {
|
|
212
|
+
const initialRender = component.render(100).join("\n");
|
|
213
|
+
assert.match(initialRender, new RegExp(`Task: ${sentinel}`));
|
|
214
|
+
assert.match(initialRender, /Prompt audit: 2 live · 3 views · p opens/);
|
|
215
|
+
component.handleInput("p");
|
|
216
|
+
assert.doesNotMatch(component.render(100).join("\n"), /Prompt text hidden|Enter reveal|r hide/);
|
|
217
|
+
assert.match(component.render(100).join("\n"), new RegExp(sentinel));
|
|
218
|
+
component.handleInput("2");
|
|
219
|
+
assert.match(component.render(100).join("\n"), /Read from: context\.md/);
|
|
220
|
+
component.handleInput("3");
|
|
221
|
+
assert.match(component.render(100).join("\n"), /Runtime acceptance/);
|
|
222
|
+
component.handleInput("c");
|
|
223
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
224
|
+
assert.deepEqual(clipboardWrites, [`[Read from: context.md]\n\n${sentinel}\n\n---\nRuntime acceptance`]);
|
|
225
|
+
assert.match(component.render(100).join("\n"), /Copied visible prompt view/);
|
|
226
|
+
component.handleInput("g");
|
|
227
|
+
for (const char of "make narrower") component.handleInput(char);
|
|
228
|
+
component.handleInput("\r");
|
|
229
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
230
|
+
assert.deepEqual(redoCalls, [{ runId: "prompt-run", index: 0, guidance: "make narrower", control }]);
|
|
231
|
+
assert.match(component.render(100).join("\n"), /Prompt redo started redo-run/);
|
|
232
|
+
component.handleInput("j");
|
|
233
|
+
assert.match(component.render(100).join("\n"), /Selected: 2\/2/);
|
|
234
|
+
assert.match(component.render(100).join("\n"), /Agent: reviewer/);
|
|
235
|
+
assert.match(component.render(100).join("\n"), new RegExp(secondSentinel));
|
|
236
|
+
component.handleInput("k");
|
|
237
|
+
component.handleInput("1");
|
|
238
|
+
assert.match(component.render(100).join("\n"), /Selected: 1\/2/);
|
|
239
|
+
assert.match(component.render(100).join("\n"), /Agent: worker/);
|
|
240
|
+
assert.match(component.render(100).join("\n"), new RegExp(sentinel));
|
|
241
|
+
component.handleInput("\x1b");
|
|
242
|
+
assert.match(component.render(100).join("\n"), /Prompt audit: 2 live · 3 views · p opens/);
|
|
243
|
+
} finally {
|
|
244
|
+
component.dispose();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("rejects Prompt Audit for a child owned by another session", () => {
|
|
249
|
+
const state = stateForTest();
|
|
250
|
+
const control = {
|
|
251
|
+
runId: "other-run",
|
|
252
|
+
sessionId: "session-other",
|
|
253
|
+
mode: "single" as const,
|
|
254
|
+
startedAt: 10,
|
|
255
|
+
updatedAt: 20,
|
|
256
|
+
activeChildren: new Map([[0, { index: 0, agent: "worker", startedAt: 10, updatedAt: 20 }]]),
|
|
257
|
+
};
|
|
258
|
+
state.foregroundControls.set(control.runId, control);
|
|
259
|
+
registerLivePromptAudit(control, 0, "SECRET", "SECRET");
|
|
260
|
+
const component = new SubagentFleetComponent(
|
|
261
|
+
{ terminal: { rows: 28, columns: 100 }, requestRender() {} } as never,
|
|
262
|
+
theme as never,
|
|
263
|
+
state,
|
|
264
|
+
() => {},
|
|
265
|
+
{ refreshMs: 60_000, markdownTheme },
|
|
266
|
+
);
|
|
267
|
+
try {
|
|
268
|
+
component.handleInput("p");
|
|
269
|
+
const rendered = component.render(100).join("\n");
|
|
270
|
+
assert.match(rendered, /Prompt Audit is available only/);
|
|
271
|
+
assert.doesNotMatch(rendered, /SECRET/);
|
|
272
|
+
} finally {
|
|
273
|
+
component.dispose();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
139
277
|
it("keeps tracked active workflows ahead of older failed terminal history", () => {
|
|
140
278
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-fleet-history-"));
|
|
141
279
|
try {
|
|
@@ -778,7 +916,7 @@ describe("native subagent fleet", () => {
|
|
|
778
916
|
assert.ok(lines.some((line) => line.includes("reviewer")));
|
|
779
917
|
assert.ok(lines.some((line) => line.includes("foreground · live")));
|
|
780
918
|
assert.ok(lines.some((line) => line.includes("live-model · thinking high")));
|
|
781
|
-
assert.ok(lines.
|
|
919
|
+
assert.ok(lines.every((line) => !line.includes("Implement the active task") && !line.includes("Review the active task")));
|
|
782
920
|
assert.ok(lines.some((line) => line.includes("Conversation") && line.includes("assistant response")));
|
|
783
921
|
assert.ok(lines.some((line) => line.includes("Worker live result")));
|
|
784
922
|
} finally {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
settleForegroundSchedulingOwner,
|
|
10
10
|
updateForegroundChild,
|
|
11
11
|
} from "../../src/runs/foreground/foreground-control.ts";
|
|
12
|
+
import { getLivePromptAudit } from "../../src/runs/foreground/prompt-audit.ts";
|
|
12
13
|
|
|
13
14
|
function progress(index: number, agent: string, tokens: number): AgentProgress {
|
|
14
15
|
return {
|
|
@@ -43,6 +44,8 @@ describe("foreground child control", () => {
|
|
|
43
44
|
beginForegroundChild(control, {
|
|
44
45
|
index: 0,
|
|
45
46
|
agent: "reviewer",
|
|
47
|
+
authoredTask: "review",
|
|
48
|
+
effectivePrompt: "review",
|
|
46
49
|
description: "Review correctness",
|
|
47
50
|
interrupt: () => { firstInterrupts++; return true; },
|
|
48
51
|
detach: () => { firstDetaches++; return true; },
|
|
@@ -50,11 +53,17 @@ describe("foreground child control", () => {
|
|
|
50
53
|
beginForegroundChild(control, {
|
|
51
54
|
index: 1,
|
|
52
55
|
agent: "reviewer",
|
|
56
|
+
authoredTask: "review next",
|
|
57
|
+
effectivePrompt: "review next",
|
|
53
58
|
description: "Review quality",
|
|
59
|
+
rerun: { params: { agent: "reviewer", task: "review next" } },
|
|
54
60
|
interrupt: () => { secondInterrupts++; return true; },
|
|
55
61
|
});
|
|
56
62
|
|
|
57
63
|
assert.equal(control.activeChildren?.size, 2);
|
|
64
|
+
assert.equal(getLivePromptAudit(control, 1)?.authoredTask, "review next");
|
|
65
|
+
assert.deepEqual(getLivePromptAudit(control, 1)?.rerun?.params, { agent: "reviewer", task: "review next" });
|
|
66
|
+
assert.doesNotMatch(JSON.stringify(control), /review next/);
|
|
58
67
|
assert.equal(control.currentIndex, 1);
|
|
59
68
|
updateForegroundChild(control, 0, progress(0, "reviewer", 120));
|
|
60
69
|
assert.equal(control.currentIndex, 0);
|
|
@@ -72,6 +81,7 @@ describe("foreground child control", () => {
|
|
|
72
81
|
|
|
73
82
|
updateForegroundChild(control, 1, progress(1, "reviewer", 240));
|
|
74
83
|
finishForegroundChild(control, 1);
|
|
84
|
+
assert.equal(getLivePromptAudit(control, 1), undefined);
|
|
75
85
|
assert.equal(control.currentIndex, 0);
|
|
76
86
|
assert.equal(control.tokens, 120);
|
|
77
87
|
assert.deepEqual([...control.activeChildren!.keys()], [0]);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
|
|
7
|
+
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
8
|
+
|
|
9
|
+
describe("handoff workflow carry-over", () => {
|
|
10
|
+
it("adopts the previous session's active async workflows and tells the agent", () => {
|
|
11
|
+
const script = String.raw`
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import { DIRS } from "./src/shared/types.ts";
|
|
16
|
+
import registerSubagentExtension from "./index.ts";
|
|
17
|
+
const handlers = new Map();
|
|
18
|
+
const events = { listeners: new Map(), on(name, handler) { this.listeners.set(name, handler); return () => this.listeners.delete(name); }, emit(name, payload) { this.listeners.get(name)?.(payload); } };
|
|
19
|
+
const sent = [];
|
|
20
|
+
const widgets = [];
|
|
21
|
+
const pi = new Proxy({
|
|
22
|
+
events,
|
|
23
|
+
on(name, handler) { handlers.set(name, handler); },
|
|
24
|
+
registerTool() {}, registerCommand() {}, registerShortcut() {}, registerMessageRenderer() {},
|
|
25
|
+
sendMessage(message, options) { sent.push({ message, options }); }, getSessionName() { return undefined; },
|
|
26
|
+
}, { get(target, prop) { return prop in target ? target[prop] : () => undefined; } });
|
|
27
|
+
|
|
28
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "selesai-handoff-"));
|
|
29
|
+
const oldSession = path.join(tmp, "old-session.jsonl");
|
|
30
|
+
const newSession = path.join(tmp, "new-session.jsonl");
|
|
31
|
+
fs.writeFileSync(oldSession, JSON.stringify({ type: "session", version: 1, id: "old-session", timestamp: new Date().toISOString(), cwd: process.cwd() }) + "\n");
|
|
32
|
+
fs.writeFileSync(newSession, JSON.stringify({ type: "session", version: 1, id: "new-session", timestamp: new Date().toISOString(), cwd: process.cwd(), parentSession: oldSession }) + "\n");
|
|
33
|
+
|
|
34
|
+
// One active async workflow owned by the previous session.
|
|
35
|
+
const runId = "adopted-workflow-run";
|
|
36
|
+
const asyncDir = path.join(DIRS.async, runId);
|
|
37
|
+
fs.mkdirSync(asyncDir, { recursive: true });
|
|
38
|
+
fs.writeFileSync(path.join(asyncDir, "status.json"), JSON.stringify({
|
|
39
|
+
runId,
|
|
40
|
+
state: "running",
|
|
41
|
+
sessionId: oldSession,
|
|
42
|
+
mode: "workflow",
|
|
43
|
+
workflowKey: "task",
|
|
44
|
+
agent: "builder",
|
|
45
|
+
startedAt: Date.now(),
|
|
46
|
+
steps: [{ agent: "builder", status: "running", phase: "build-1", description: "Implement the carry-over plan" }],
|
|
47
|
+
}));
|
|
48
|
+
fs.mkdirSync(path.join(DIRS.async, ".active-runs"), { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(DIRS.async, ".active-runs", runId), "");
|
|
50
|
+
|
|
51
|
+
// A result file for an old-session run written during the switch window.
|
|
52
|
+
const resultRunId = "adopted-completed-run";
|
|
53
|
+
const resultPath = path.join(DIRS.results, resultRunId + ".json");
|
|
54
|
+
fs.mkdirSync(DIRS.results, { recursive: true });
|
|
55
|
+
fs.writeFileSync(resultPath, JSON.stringify({ runId: resultRunId, sessionId: oldSession, agent: "explorer", summary: "done", success: true }));
|
|
56
|
+
|
|
57
|
+
const ctx = { cwd: process.cwd(), hasUI: true, ui: { setWidget(key, value) { widgets.push([key, value]); }, requestRender() {}, onTerminalInput() { return () => {}; }, getEditorText() { return ""; }, notify() {}, theme: { fg(_name, text) { return text; }, bg(_name, text) { return text; }, bold(text) { return text; } } }, sessionManager: { getSessionId() { return "new-session"; }, getSessionFile() { return newSession; }, getHeader() { return { parentSession: oldSession }; }, getEntries() { return []; } }, modelRegistry: { getAvailable() { return []; } } };
|
|
58
|
+
|
|
59
|
+
registerSubagentExtension(pi);
|
|
60
|
+
handlers.get("session_start")({ reason: "new", previousSessionFile: oldSession }, ctx);
|
|
61
|
+
sent.length = 0;
|
|
62
|
+
|
|
63
|
+
// No handoff notice until a turn settles.
|
|
64
|
+
if (sent.length !== 0) throw new Error("handoff notice sent before the first settled turn");
|
|
65
|
+
|
|
66
|
+
handlers.get("agent_settled")();
|
|
67
|
+
const handoff = sent.filter((entry) => entry.message?.customType === "subagent-handoff-resume");
|
|
68
|
+
if (handoff.length !== 1) throw new Error(JSON.stringify(sent.map((entry) => entry.message)));
|
|
69
|
+
if (handoff[0].options?.triggerTurn !== true) throw new Error("handoff notice did not trigger a turn");
|
|
70
|
+
if (typeof handoff[0].message?.content !== "string" || !handoff[0].message.content.includes(runId) || !handoff[0].message.content.includes("carried over")) {
|
|
71
|
+
throw new Error("handoff notice lacks the adopted workflow: " + JSON.stringify(handoff[0].message));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Only once per adoption.
|
|
75
|
+
handlers.get("agent_settled")();
|
|
76
|
+
if (sent.filter((entry) => entry.message?.customType === "subagent-handoff-resume").length !== 1) throw new Error("handoff notice repeated");
|
|
77
|
+
|
|
78
|
+
// Compaction resume message now lists the live workflows.
|
|
79
|
+
handlers.get("session_before_compact")({ reason: "threshold", signal: new AbortController().signal });
|
|
80
|
+
handlers.get("session_compact")({ reason: "threshold" });
|
|
81
|
+
const compaction = sent.filter((entry) => entry.message?.customType === "subagent-compaction-resume");
|
|
82
|
+
if (compaction.length !== 1) throw new Error("compaction resume missing: " + JSON.stringify(sent.map((entry) => entry.message?.customType)));
|
|
83
|
+
if (typeof compaction[0].message?.content !== "string" || !compaction[0].message.content.includes(runId)) {
|
|
84
|
+
throw new Error("compaction resume lacks the workflow manifest: " + JSON.stringify(compaction[0].message));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Adopted old-session result gets delivered and cleaned up (batched
|
|
88
|
+
// notification debounces, so poll with a generous bound).
|
|
89
|
+
let delivered = false;
|
|
90
|
+
for (let attempt = 0; attempt < 40 && !delivered; attempt++) {
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
92
|
+
delivered = !fs.existsSync(resultPath);
|
|
93
|
+
}
|
|
94
|
+
if (!delivered) throw new Error("adopted result file was not delivered");
|
|
95
|
+
|
|
96
|
+
await handlers.get("session_shutdown")();
|
|
97
|
+
`;
|
|
98
|
+
const env = { ...process.env };
|
|
99
|
+
delete env.SELESAI_SUBAGENT_CHILD;
|
|
100
|
+
execFileSync(process.execPath, ["--experimental-strip-types", "--import", "./test/support/register-loader.mjs", "--input-type=module", "--eval", script], { cwd: projectRoot, env, stdio: "pipe" });
|
|
101
|
+
assert.ok(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -103,6 +103,28 @@ describe("subagent extension child mode", () => {
|
|
|
103
103
|
execFileSync(process.execPath, ["--experimental-strip-types", "--import", "./test/support/register-loader.mjs", "--input-type=module", "--eval", script], { cwd: projectRoot, env: parentToolEnv(), stdio: "pipe" });
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
it("declares packaged prompts through resources_discover", () => {
|
|
107
|
+
const script = String.raw`
|
|
108
|
+
import registerSubagentExtension from "./index.ts";
|
|
109
|
+
import * as fs from "node:fs";
|
|
110
|
+
const handlers = new Map();
|
|
111
|
+
const fakePi = new Proxy({
|
|
112
|
+
events: { on() { return () => {}; }, emit() {} },
|
|
113
|
+
registerTool() {}, registerCommand() {}, registerShortcut() {}, registerMessageRenderer() {}, sendMessage() {}, getSessionName() {},
|
|
114
|
+
on(event, handler) { handlers.set(event, handler); },
|
|
115
|
+
}, { get(target, prop) { return prop in target ? target[prop] : () => undefined; } });
|
|
116
|
+
registerSubagentExtension(fakePi);
|
|
117
|
+
const handler = handlers.get("resources_discover");
|
|
118
|
+
if (!handler) throw new Error("resources_discover handler not registered");
|
|
119
|
+
const result = handler({ type: "resources_discover", cwd: process.cwd(), reason: "startup" });
|
|
120
|
+
if (!result || !Array.isArray(result.promptPaths) || result.promptPaths.length !== 1) throw new Error("expected one promptPath, got " + JSON.stringify(result));
|
|
121
|
+
const promptDir = result.promptPaths[0];
|
|
122
|
+
if (!fs.existsSync(promptDir + "/review-loop.md")) throw new Error("review-loop.md not found under " + promptDir);
|
|
123
|
+
if (!fs.existsSync(promptDir + "/parallel-review.md")) throw new Error("parallel-review.md not found under " + promptDir);
|
|
124
|
+
`;
|
|
125
|
+
execFileSync(process.execPath, ["--experimental-strip-types", "--import", "./test/support/register-loader.mjs", "--input-type=module", "--eval", script], { cwd: projectRoot, env: parentToolEnv(), stdio: "pipe" });
|
|
126
|
+
});
|
|
127
|
+
|
|
106
128
|
it("shows omitted workflow async as background even when asyncByDefault is false", () => {
|
|
107
129
|
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-workflow-manifest-config-"));
|
|
108
130
|
try {
|
|
@@ -52,7 +52,8 @@ describe("resolveIntercomSessionTarget", () => {
|
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
it("uses the current pi-intercom runtime id for unnamed fallback when present", () => {
|
|
55
|
-
assert.equal(resolveIntercomSessionTarget(undefined, "session-12345678", "session-stableabcdef"), "subagent-chat-
|
|
55
|
+
assert.equal(resolveIntercomSessionTarget(undefined, "session-12345678", "session-stableabcdef"), "subagent-chat-stableabcdef");
|
|
56
|
+
assert.equal(resolveIntercomSessionTarget(undefined, "session-12345678", "session-abcdefghijklmnopqrst"), "subagent-chat-abcdefghijklmnopqr");
|
|
56
57
|
});
|
|
57
58
|
});
|
|
58
59
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
arbitrateCompletionGuardRescue,
|
|
5
|
+
createTaskMutationArbiter,
|
|
6
|
+
isCompletionGuardFailure,
|
|
7
|
+
maybeRescueCompletionGuardFailure,
|
|
8
|
+
type TaskMutationArbiter,
|
|
9
|
+
} from "../../src/runs/shared/llm-intent-arbiter.ts";
|
|
10
|
+
|
|
11
|
+
const GUARD_ERROR =
|
|
12
|
+
"Subagent completed without making edits for an implementation task.\nIt appears to have returned planning or scratchpad output instead of applying changes.";
|
|
13
|
+
|
|
14
|
+
function fakeCtx(overrides: { models?: Array<{ provider?: string; id?: string; api?: string }> } = {}) {
|
|
15
|
+
const models = overrides.models ?? [{ provider: "test", id: "model-1", api: "test-api" }];
|
|
16
|
+
return {
|
|
17
|
+
model: models[0],
|
|
18
|
+
modelRegistry: {
|
|
19
|
+
getAvailable: () => models,
|
|
20
|
+
getRegisteredProviderConfig: () => undefined,
|
|
21
|
+
},
|
|
22
|
+
} as never;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function stubArbiter(verdict: "read-only" | "implementation" | "unavailable" | "throw"): TaskMutationArbiter {
|
|
26
|
+
return async () => {
|
|
27
|
+
if (verdict === "throw") throw new Error("boom");
|
|
28
|
+
return verdict;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("arbitrateCompletionGuardRescue", () => {
|
|
33
|
+
it("rescues only a confident read-only verdict", async () => {
|
|
34
|
+
const rescued = await arbitrateCompletionGuardRescue({
|
|
35
|
+
guardTriggered: true,
|
|
36
|
+
task: "t",
|
|
37
|
+
arbiter: stubArbiter("read-only"),
|
|
38
|
+
});
|
|
39
|
+
assert.deepEqual(rescued, { triggered: false, rescued: true });
|
|
40
|
+
for (const verdict of ["implementation", "unavailable"] as const) {
|
|
41
|
+
const kept = await arbitrateCompletionGuardRescue({
|
|
42
|
+
guardTriggered: true,
|
|
43
|
+
task: "t",
|
|
44
|
+
arbiter: stubArbiter(verdict),
|
|
45
|
+
});
|
|
46
|
+
assert.deepEqual(kept, { triggered: true, rescued: false }, verdict);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps the guard verdict without an arbiter or on arbiter failure", async () => {
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
await arbitrateCompletionGuardRescue({ guardTriggered: true, task: "t" }),
|
|
53
|
+
{ triggered: true, rescued: false },
|
|
54
|
+
);
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
await arbitrateCompletionGuardRescue({ guardTriggered: true, task: "t", arbiter: stubArbiter("throw") }),
|
|
57
|
+
{ triggered: true, rescued: false },
|
|
58
|
+
);
|
|
59
|
+
assert.deepEqual(
|
|
60
|
+
await arbitrateCompletionGuardRescue({ guardTriggered: false, task: "t", arbiter: stubArbiter("read-only") }),
|
|
61
|
+
{ triggered: false, rescued: false },
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("isCompletionGuardFailure", () => {
|
|
67
|
+
it("recognizes the guard error prefix", () => {
|
|
68
|
+
assert.equal(isCompletionGuardFailure({ error: GUARD_ERROR }), true);
|
|
69
|
+
assert.equal(isCompletionGuardFailure({ error: "Some other failure." }), false);
|
|
70
|
+
assert.equal(isCompletionGuardFailure({}), false);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("maybeRescueCompletionGuardFailure", () => {
|
|
75
|
+
it("keeps the failure without an arbiter", async () => {
|
|
76
|
+
const result = { exitCode: 1, error: GUARD_ERROR };
|
|
77
|
+
const rescued = await maybeRescueCompletionGuardFailure(result, "task", undefined);
|
|
78
|
+
assert.equal(rescued, false);
|
|
79
|
+
assert.equal(result.exitCode, 1);
|
|
80
|
+
assert.equal(result.error, GUARD_ERROR);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rescues only a confident read-only verdict", async () => {
|
|
84
|
+
for (const verdict of ["implementation", "unavailable"] as const) {
|
|
85
|
+
const result = { exitCode: 1, error: GUARD_ERROR };
|
|
86
|
+
const rescued = await maybeRescueCompletionGuardFailure(result, "task", stubArbiter(verdict));
|
|
87
|
+
assert.equal(rescued, false, verdict);
|
|
88
|
+
assert.equal(result.exitCode, 1);
|
|
89
|
+
assert.equal(result.error, GUARD_ERROR);
|
|
90
|
+
}
|
|
91
|
+
const result = { exitCode: 1, error: GUARD_ERROR };
|
|
92
|
+
const rescued = await maybeRescueCompletionGuardFailure(result, "task", stubArbiter("read-only"));
|
|
93
|
+
assert.equal(rescued, true);
|
|
94
|
+
assert.equal(result.exitCode, 0);
|
|
95
|
+
assert.equal(result.error, undefined);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("fails closed when the arbiter throws", async () => {
|
|
99
|
+
const result = { exitCode: 1, error: GUARD_ERROR };
|
|
100
|
+
const rescued = await maybeRescueCompletionGuardFailure(result, "task", stubArbiter("throw"));
|
|
101
|
+
assert.equal(rescued, false);
|
|
102
|
+
assert.equal(result.exitCode, 1);
|
|
103
|
+
assert.equal(result.error, GUARD_ERROR);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not touch unrelated failures", async () => {
|
|
107
|
+
const result = { exitCode: 1, error: "Something else broke." };
|
|
108
|
+
const rescued = await maybeRescueCompletionGuardFailure(result, "task", stubArbiter("read-only"));
|
|
109
|
+
assert.equal(rescued, false);
|
|
110
|
+
assert.equal(result.exitCode, 1);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("createTaskMutationArbiter", () => {
|
|
115
|
+
it("is disabled by the env opt-out", () => {
|
|
116
|
+
const previous = process.env.SELESAI_SUBAGENTS_LLM_INTENT_ARBITER;
|
|
117
|
+
process.env.SELESAI_SUBAGENTS_LLM_INTENT_ARBITER = "0";
|
|
118
|
+
try {
|
|
119
|
+
assert.equal(createTaskMutationArbiter(fakeCtx()), undefined);
|
|
120
|
+
} finally {
|
|
121
|
+
if (previous === undefined) delete process.env.SELESAI_SUBAGENTS_LLM_INTENT_ARBITER;
|
|
122
|
+
else process.env.SELESAI_SUBAGENTS_LLM_INTENT_ARBITER = previous;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns undefined when no model can be resolved", () => {
|
|
127
|
+
assert.equal(createTaskMutationArbiter(fakeCtx({ models: [] })), undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns a working arbiter bound to the host model", async () => {
|
|
131
|
+
const arbiter = createTaskMutationArbiter(fakeCtx(), {
|
|
132
|
+
streamFn: async () => {
|
|
133
|
+
throw new Error("not reached in this seam test");
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
assert.notEqual(arbiter, undefined);
|
|
137
|
+
// Without a scripted stream the underlying Agent call cannot succeed;
|
|
138
|
+
// the arbiter must degrade to "unavailable" rather than throw.
|
|
139
|
+
const verdict = await arbiter!("task");
|
|
140
|
+
assert.equal(verdict, "unavailable");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("memoizes verdicts per task", async () => {
|
|
144
|
+
let streamCalls = 0;
|
|
145
|
+
const arbiter = createTaskMutationArbiter(fakeCtx(), {
|
|
146
|
+
streamFn: async () => {
|
|
147
|
+
streamCalls += 1;
|
|
148
|
+
throw new Error("fail");
|
|
149
|
+
},
|
|
150
|
+
})!;
|
|
151
|
+
const v1 = await arbiter("t");
|
|
152
|
+
const v2 = await arbiter("t");
|
|
153
|
+
assert.equal(v1, "unavailable");
|
|
154
|
+
assert.equal(v2, "unavailable");
|
|
155
|
+
assert.equal(streamCalls, 1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("uses the full task as the cache key", async () => {
|
|
159
|
+
let streamCalls = 0;
|
|
160
|
+
const arbiter = createTaskMutationArbiter(fakeCtx(), {
|
|
161
|
+
streamFn: async () => {
|
|
162
|
+
streamCalls += 1;
|
|
163
|
+
throw new Error("fail");
|
|
164
|
+
},
|
|
165
|
+
})!;
|
|
166
|
+
const prefix = "x".repeat(180);
|
|
167
|
+
await arbiter(`${prefix}A`);
|
|
168
|
+
await arbiter(`${prefix}B`);
|
|
169
|
+
assert.equal(streamCalls, 2);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
syncMissionFromAsyncCompletion,
|
|
12
12
|
} from "../../src/missions/lifecycle.ts";
|
|
13
13
|
import { readMission } from "../../src/missions/store.ts";
|
|
14
|
+
import { PROMPT_REDACTED } from "../../src/shared/utils.ts";
|
|
14
15
|
|
|
15
16
|
function projectFixture() {
|
|
16
17
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-mission-lifecycle-"));
|
|
@@ -52,7 +53,8 @@ describe("mission launch lifecycle", () => {
|
|
|
52
53
|
});
|
|
53
54
|
assert.ok(binding);
|
|
54
55
|
const mission = readMission(binding.location, binding.missionId);
|
|
55
|
-
assert.equal(mission.objective,
|
|
56
|
+
assert.equal(mission.objective, PROMPT_REDACTED);
|
|
57
|
+
assert.equal(mission.title, PROMPT_REDACTED);
|
|
56
58
|
assert.equal(mission.status, "active");
|
|
57
59
|
const result = attachMissionToLaunchResult({
|
|
58
60
|
binding,
|
|
@@ -80,7 +82,7 @@ describe("mission launch lifecycle", () => {
|
|
|
80
82
|
config: test.missionConfig,
|
|
81
83
|
});
|
|
82
84
|
assert.ok(parallelOnly);
|
|
83
|
-
assert.equal(readMission(parallelOnly.location, parallelOnly.missionId).objective,
|
|
85
|
+
assert.equal(readMission(parallelOnly.location, parallelOnly.missionId).objective, PROMPT_REDACTED);
|
|
84
86
|
|
|
85
87
|
assert.throws(() => prepareMissionLaunch({
|
|
86
88
|
params: { missionId: "" },
|
|
@@ -107,7 +109,7 @@ describe("mission launch lifecycle", () => {
|
|
|
107
109
|
config: { ...test.missionConfig, enabled: false },
|
|
108
110
|
});
|
|
109
111
|
assert.ok(explicit);
|
|
110
|
-
assert.equal(readMission(explicit.location, explicit.missionId).objective, "
|
|
112
|
+
assert.equal(readMission(explicit.location, explicit.missionId).objective, "Explicit mission");
|
|
111
113
|
|
|
112
114
|
const summaryAlias = prepareMissionLaunch({
|
|
113
115
|
params: { mission: { summary: "Review active backlog", labels: ["review"] }, task: "Review the current diff" },
|
|
@@ -117,7 +119,31 @@ describe("mission launch lifecycle", () => {
|
|
|
117
119
|
assert.ok(summaryAlias);
|
|
118
120
|
assert.deepEqual(readMission(summaryAlias.location, summaryAlias.missionId).labels, ["review"]);
|
|
119
121
|
assert.equal(readMission(summaryAlias.location, summaryAlias.missionId).title, "Review active backlog");
|
|
120
|
-
assert.equal(readMission(summaryAlias.location, summaryAlias.missionId).objective, "Review
|
|
122
|
+
assert.equal(readMission(summaryAlias.location, summaryAlias.missionId).objective, "Review active backlog");
|
|
123
|
+
} finally {
|
|
124
|
+
fs.rmSync(test.root, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("explains when an explicit mission belongs to another worktree", () => {
|
|
129
|
+
const test = projectFixture();
|
|
130
|
+
const worktreeRoot = path.join(test.root, "worktree");
|
|
131
|
+
fs.mkdirSync(worktreeRoot, { recursive: true });
|
|
132
|
+
try {
|
|
133
|
+
const binding = prepareMissionLaunch({
|
|
134
|
+
params: { mission: { title: "Root mission" }, task: "Prepare root work" },
|
|
135
|
+
projectRoot: test.projectRoot,
|
|
136
|
+
config: test.missionConfig,
|
|
137
|
+
});
|
|
138
|
+
assert.ok(binding);
|
|
139
|
+
const missionDir = path.join(worktreeRoot, ".pi-subagents", "missions");
|
|
140
|
+
assert.throws(() => prepareMissionLaunch({
|
|
141
|
+
params: { missionId: binding.missionId, task: "Prepare worktree work" },
|
|
142
|
+
projectRoot: worktreeRoot,
|
|
143
|
+
config: test.missionConfig,
|
|
144
|
+
}), {
|
|
145
|
+
message: `Mission '${binding.missionId}' was not found in mission directory '${missionDir}' for project root '${worktreeRoot}'. If it was created in another worktree, run the request from that worktree.`,
|
|
146
|
+
});
|
|
121
147
|
} finally {
|
|
122
148
|
fs.rmSync(test.root, { recursive: true, force: true });
|
|
123
149
|
}
|