@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
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
// Tests for /handoff-new. These import runtime values from "@selesai/code",
|
|
2
|
-
// which is this package itself and only resolves against a built dist
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import test from "node:test";
|
|
6
|
-
import assert from "node:assert/strict";
|
|
2
|
+
// which is this package itself and only resolves against a built dist
|
|
3
|
+
// (vitest.config.ts aliases @selesai/code -> dist/index.js).
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
5
|
|
|
6
|
+
vi.mock("@selesai/code", async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof import("@selesai/code")>();
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
BorderedLoader: vi.fn().mockImplementation(function (this: any, _tui: any, _theme: any, _label: string) {
|
|
11
|
+
this.signal = new AbortController().signal;
|
|
12
|
+
this.onAbort = null;
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
vi.mock("@earendil-works/pi-ai/compat", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("@earendil-works/pi-ai/compat")>();
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
complete: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
import { complete } from "@earendil-works/pi-ai/compat";
|
|
8
26
|
import handoffNewExtension, {
|
|
9
27
|
DEFAULT_GOAL,
|
|
10
28
|
buildAiContext,
|
|
@@ -28,179 +46,351 @@ function createPiHarness() {
|
|
|
28
46
|
return { commands };
|
|
29
47
|
}
|
|
30
48
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
describe("handoff-new extension", () => {
|
|
50
|
+
it("registers handoff-new only", () => {
|
|
51
|
+
const { commands } = createPiHarness();
|
|
52
|
+
expect(commands.has("handoff-new")).toBe(true);
|
|
53
|
+
expect(commands.has("handover-new")).toBe(false);
|
|
54
|
+
});
|
|
36
55
|
|
|
37
|
-
// ---- pure helpers ----
|
|
56
|
+
// ---- pure helpers ----
|
|
38
57
|
|
|
39
|
-
function userEntry(text: string, id = "u1"): SessionEntry {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
58
|
+
function userEntry(text: string, id = "u1"): SessionEntry {
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
type: "message",
|
|
62
|
+
message: { role: "user", content: [{ type: "text", text }], timestamp: 100 },
|
|
63
|
+
parentId: "root",
|
|
64
|
+
timestamp: 100,
|
|
65
|
+
} as any as SessionEntry;
|
|
66
|
+
}
|
|
48
67
|
|
|
49
|
-
function compactionEntry(id: string, firstKeptEntryId: string): SessionEntry {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
68
|
+
function compactionEntry(id: string, firstKeptEntryId: string): SessionEntry {
|
|
69
|
+
return {
|
|
70
|
+
id,
|
|
71
|
+
type: "compaction",
|
|
72
|
+
summary: "compacted summary",
|
|
73
|
+
tokensBefore: 1000,
|
|
74
|
+
firstKeptEntryId,
|
|
75
|
+
timestamp: 50,
|
|
76
|
+
} as any as SessionEntry;
|
|
77
|
+
}
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
});
|
|
79
|
+
it("entryToMessage: message entry -> message", () => {
|
|
80
|
+
const e = userEntry("hi");
|
|
81
|
+
expect(entryToMessage(e)).toEqual((e as any).message);
|
|
82
|
+
});
|
|
64
83
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
84
|
+
it("entryToMessage: compaction entry -> compactionSummary", () => {
|
|
85
|
+
const e = compactionEntry("c1", "k1");
|
|
86
|
+
const m: any = entryToMessage(e);
|
|
87
|
+
expect(m.role).toBe("compactionSummary");
|
|
88
|
+
expect(m.summary).toBe("compacted summary");
|
|
89
|
+
});
|
|
71
90
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
91
|
+
it("entryToMessage: other entry types -> undefined", () => {
|
|
92
|
+
expect(entryToMessage({ type: "label", id: "l1" } as any as SessionEntry)).toBeUndefined();
|
|
93
|
+
});
|
|
75
94
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
});
|
|
95
|
+
it("getHandoffMessages: no compaction returns all messages", () => {
|
|
96
|
+
const branch = [userEntry("a", "1"), userEntry("b", "2")];
|
|
97
|
+
expect(getHandoffMessages(branch)).toHaveLength(2);
|
|
98
|
+
});
|
|
80
99
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
});
|
|
100
|
+
it("getHandoffMessages: compaction prepends summary + kept slice + after", () => {
|
|
101
|
+
const branch = [
|
|
102
|
+
userEntry("old-kept", "k1"),
|
|
103
|
+
compactionEntry("c1", "k1"),
|
|
104
|
+
userEntry("after", "a1"),
|
|
105
|
+
];
|
|
106
|
+
const msgs = getHandoffMessages(branch);
|
|
107
|
+
expect(msgs).toHaveLength(3);
|
|
108
|
+
expect((msgs[0] as any).role).toBe("compactionSummary");
|
|
109
|
+
expect((msgs[1] as any).content[0].text).toBe("old-kept");
|
|
110
|
+
expect((msgs[2] as any).content[0].text).toBe("after");
|
|
111
|
+
});
|
|
93
112
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
it("getHandoffMessages: compaction with unknown firstKeptEntryId keeps only summary + after", () => {
|
|
114
|
+
const branch = [
|
|
115
|
+
userEntry("old-kept", "k1"),
|
|
116
|
+
compactionEntry("c1", "missing-id"),
|
|
117
|
+
userEntry("after", "a1"),
|
|
118
|
+
];
|
|
119
|
+
const msgs = getHandoffMessages(branch);
|
|
120
|
+
expect(msgs).toHaveLength(2);
|
|
121
|
+
expect((msgs[0] as any).role).toBe("compactionSummary");
|
|
122
|
+
expect((msgs[1] as any).content[0].text).toBe("after");
|
|
123
|
+
});
|
|
102
124
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
it("buildAiContext: embeds conversation + goal into one user turn", () => {
|
|
126
|
+
const ctx = buildAiContext("conv-text", "my-goal");
|
|
127
|
+
expect(ctx.systemPrompt.length).toBeGreaterThan(0);
|
|
128
|
+
expect(ctx.messages).toHaveLength(1);
|
|
129
|
+
const text = (ctx.messages[0].content[0] as any).text;
|
|
130
|
+
expect(text).toContain("conv-text");
|
|
131
|
+
expect(text).toContain("my-goal");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---- handler ----
|
|
135
|
+
|
|
136
|
+
function createCtx(opts: {
|
|
137
|
+
branch?: SessionEntry[];
|
|
138
|
+
model?: any;
|
|
139
|
+
customResult?: string | null;
|
|
140
|
+
mode?: string;
|
|
141
|
+
} = {}) {
|
|
142
|
+
const calls: { newSession: any; userMessage: string | null; notify: { msg: string; kind: string }[] } = {
|
|
143
|
+
newSession: null,
|
|
144
|
+
userMessage: null,
|
|
145
|
+
notify: [],
|
|
146
|
+
};
|
|
147
|
+
const ctx: any = {
|
|
148
|
+
mode: opts.mode ?? "tui",
|
|
149
|
+
model: opts.model === undefined ? { provider: "test", id: "m" } : opts.model,
|
|
150
|
+
sessionManager: {
|
|
151
|
+
getBranch: () => (opts.branch === undefined ? [userEntry("we decided to build X")] : opts.branch),
|
|
152
|
+
getSessionFile: () => "/tmp/old.json",
|
|
126
153
|
},
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
modelRegistry: {
|
|
155
|
+
async getApiKeyAndHeaders() {
|
|
156
|
+
return { ok: true, apiKey: "key", headers: {} };
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
ui: {
|
|
160
|
+
notify: (msg: string, kind: string) => calls.notify.push({ msg, kind }),
|
|
161
|
+
custom: async <T>(_factory: any): Promise<T> => (opts.customResult === undefined ? "HANDOFF PROMPT" : opts.customResult) as unknown as T,
|
|
162
|
+
},
|
|
163
|
+
async sendUserMessage(text: string) {
|
|
164
|
+
calls.userMessage = text;
|
|
165
|
+
},
|
|
166
|
+
async newSession(options: any) {
|
|
167
|
+
calls.newSession = options;
|
|
168
|
+
if (options?.withSession) {
|
|
169
|
+
await options.withSession(ctx);
|
|
170
|
+
}
|
|
171
|
+
return { cancelled: false };
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
return { ctx, calls };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
it("happy path: newSession called with parentSession, handoff submitted", async () => {
|
|
178
|
+
const { commands } = createPiHarness();
|
|
179
|
+
const { ctx, calls } = createCtx();
|
|
180
|
+
await commands.get("handoff-new")!.handler("continue the fix", ctx);
|
|
181
|
+
expect(calls.newSession.parentSession).toBe("/tmp/old.json");
|
|
182
|
+
expect(calls.userMessage).toBe("HANDOFF PROMPT");
|
|
183
|
+
expect(calls.newSession.cancelled).toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("no goal arg uses DEFAULT_GOAL", async () => {
|
|
187
|
+
const { commands } = createPiHarness();
|
|
188
|
+
const { ctx, calls } = createCtx();
|
|
189
|
+
await commands.get("handoff-new")!.handler(" ", ctx);
|
|
190
|
+
// happy path still completes; default goal only affects the AI prompt text.
|
|
191
|
+
expect(calls.userMessage).toBe("HANDOFF PROMPT");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("non-tui mode fails cleanly, no new session", async () => {
|
|
195
|
+
const { commands } = createPiHarness();
|
|
196
|
+
const { ctx, calls } = createCtx({ mode: "rpc" });
|
|
197
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
198
|
+
expect(calls.newSession).toBeNull();
|
|
199
|
+
expect(calls.notify.some((n) => /interactive mode/.test(n.msg))).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("no model fails cleanly", async () => {
|
|
203
|
+
const { commands } = createPiHarness();
|
|
204
|
+
const { ctx, calls } = createCtx({ model: null });
|
|
205
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
206
|
+
expect(calls.newSession).toBeNull();
|
|
207
|
+
expect(calls.notify.some((n) => /No model/.test(n.msg))).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("empty conversation fails cleanly", async () => {
|
|
211
|
+
const { commands } = createPiHarness();
|
|
212
|
+
const { ctx, calls } = createCtx({ branch: [] });
|
|
213
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
214
|
+
expect(calls.newSession).toBeNull();
|
|
215
|
+
expect(calls.notify.some((n) => /No conversation/.test(n.msg))).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("custom null (cancelled) does not open new session", async () => {
|
|
219
|
+
const { commands } = createPiHarness();
|
|
220
|
+
const { ctx, calls } = createCtx({ customResult: null });
|
|
221
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
222
|
+
expect(calls.newSession).toBeNull();
|
|
223
|
+
expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("empty generated handoff does not open a blank session", async () => {
|
|
227
|
+
const { commands } = createPiHarness();
|
|
228
|
+
const { ctx, calls } = createCtx({ customResult: " " });
|
|
229
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
230
|
+
expect(calls.newSession).toBeNull();
|
|
231
|
+
expect(calls.notify.some((n) => /returned no text/.test(n.msg))).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("new session cancelled notifies", async () => {
|
|
235
|
+
const { commands } = createPiHarness();
|
|
236
|
+
const { ctx, calls } = createCtx();
|
|
237
|
+
ctx.newSession = async (options: any) => {
|
|
136
238
|
calls.newSession = options;
|
|
137
|
-
if (options?.withSession)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
};
|
|
143
|
-
return { ctx, calls };
|
|
144
|
-
}
|
|
239
|
+
if (options?.withSession) await options.withSession(ctx);
|
|
240
|
+
return { cancelled: true };
|
|
241
|
+
};
|
|
242
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
243
|
+
expect(calls.notify.some((n) => /New session cancelled/.test(n.msg))).toBe(true);
|
|
244
|
+
});
|
|
145
245
|
|
|
146
|
-
|
|
147
|
-
const { commands } = createPiHarness();
|
|
148
|
-
const { ctx, calls } = createCtx();
|
|
149
|
-
await commands.get("handoff-new")!.handler("continue the fix", ctx);
|
|
150
|
-
assert.equal(calls.newSession.parentSession, "/tmp/old.json");
|
|
151
|
-
assert.equal(calls.userMessage, "HANDOFF PROMPT");
|
|
152
|
-
assert.equal(calls.newSession.cancelled, undefined);
|
|
153
|
-
});
|
|
246
|
+
// ---- custom factory (BorderedLoader + complete) ----
|
|
154
247
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
248
|
+
function createFactoryCtx(opts: {
|
|
249
|
+
auth?: { ok: boolean; apiKey?: string; error?: string };
|
|
250
|
+
completeResult?: any;
|
|
251
|
+
completeError?: Error;
|
|
252
|
+
}) {
|
|
253
|
+
const calls: { newSession: any; userMessage: string | null; notify: { msg: string; kind: string }[] } = {
|
|
254
|
+
newSession: null,
|
|
255
|
+
userMessage: null,
|
|
256
|
+
notify: [],
|
|
257
|
+
};
|
|
258
|
+
let capturedFactory: ((tui: any, theme: any, kb: any, done: (v: unknown) => void) => unknown) | null = null;
|
|
259
|
+
const ctx: any = {
|
|
260
|
+
mode: "tui",
|
|
261
|
+
model: { provider: "test", id: "m" },
|
|
262
|
+
sessionManager: {
|
|
263
|
+
getBranch: () => [userEntry("we decided to build X")],
|
|
264
|
+
getSessionFile: () => "/tmp/old.json",
|
|
265
|
+
},
|
|
266
|
+
modelRegistry: {
|
|
267
|
+
async getApiKeyAndHeaders() {
|
|
268
|
+
return opts.auth ?? { ok: true, apiKey: "key", headers: {} };
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
ui: {
|
|
272
|
+
notify: (msg: string, kind: string) => calls.notify.push({ msg, kind }),
|
|
273
|
+
custom: async <T>(factory: any): Promise<T> => {
|
|
274
|
+
capturedFactory = factory;
|
|
275
|
+
return new Promise<T>((resolve) => {
|
|
276
|
+
const loader = factory({}, {}, {}, resolve);
|
|
277
|
+
if (opts.completeError) {
|
|
278
|
+
// The factory's async IIFE catches errors and calls done(null).
|
|
279
|
+
// complete is mocked to reject; the catch handler runs synchronously
|
|
280
|
+
// after the microtask queue drains.
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
async sendUserMessage(text: string) {
|
|
286
|
+
calls.userMessage = text;
|
|
287
|
+
},
|
|
288
|
+
async newSession(options: any) {
|
|
289
|
+
calls.newSession = options;
|
|
290
|
+
if (options?.withSession) {
|
|
291
|
+
await options.withSession(ctx);
|
|
292
|
+
}
|
|
293
|
+
return { cancelled: false };
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
return { ctx, calls, getFactory: () => capturedFactory };
|
|
297
|
+
}
|
|
162
298
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
299
|
+
it("factory: complete returns text content and submits the handoff", async () => {
|
|
300
|
+
(complete as any).mockResolvedValueOnce({
|
|
301
|
+
stopReason: "done",
|
|
302
|
+
content: [
|
|
303
|
+
{ type: "text", text: "Handoff part one" },
|
|
304
|
+
{ type: "image", mimeType: "image/png" },
|
|
305
|
+
{ type: "text", text: "Handoff part two" },
|
|
306
|
+
],
|
|
307
|
+
});
|
|
308
|
+
const { commands } = createPiHarness();
|
|
309
|
+
const { ctx, calls } = createFactoryCtx({});
|
|
310
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
311
|
+
expect(calls.userMessage).toBe("Handoff part one\nHandoff part two");
|
|
312
|
+
expect(calls.newSession).not.toBeNull();
|
|
313
|
+
});
|
|
170
314
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
315
|
+
it("factory: aborted stop reason returns null and notifies Cancelled", async () => {
|
|
316
|
+
(complete as any).mockResolvedValueOnce({ stopReason: "aborted", content: [] });
|
|
317
|
+
const { commands } = createPiHarness();
|
|
318
|
+
const { ctx, calls } = createFactoryCtx({});
|
|
319
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
320
|
+
expect(calls.newSession).toBeNull();
|
|
321
|
+
expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
|
|
322
|
+
});
|
|
178
323
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
});
|
|
324
|
+
it("factory: missing API key throws and notifies Cancelled", async () => {
|
|
325
|
+
const { commands } = createPiHarness();
|
|
326
|
+
const { ctx, calls } = createFactoryCtx({ auth: { ok: true, error: "no key" } });
|
|
327
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
328
|
+
expect(calls.newSession).toBeNull();
|
|
329
|
+
expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
|
|
330
|
+
});
|
|
186
331
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
});
|
|
332
|
+
it("factory: auth error throws and notifies Cancelled", async () => {
|
|
333
|
+
const { commands } = createPiHarness();
|
|
334
|
+
const { ctx, calls } = createFactoryCtx({ auth: { ok: false, error: "auth failed" } });
|
|
335
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
336
|
+
expect(calls.newSession).toBeNull();
|
|
337
|
+
expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
|
|
338
|
+
});
|
|
194
339
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
340
|
+
it("factory: complete rejection is caught and notifies Cancelled", async () => {
|
|
341
|
+
(complete as any).mockRejectedValueOnce(new Error("network down"));
|
|
342
|
+
const { commands } = createPiHarness();
|
|
343
|
+
const { ctx, calls } = createFactoryCtx({});
|
|
344
|
+
await commands.get("handoff-new")!.handler("goal", ctx);
|
|
345
|
+
expect(calls.newSession).toBeNull();
|
|
346
|
+
expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
|
|
347
|
+
});
|
|
202
348
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
});
|
|
349
|
+
it("factory: loader onAbort resolves null", async () => {
|
|
350
|
+
(complete as any).mockResolvedValueOnce({ stopReason: "done", content: [{ type: "text", text: "x" }] });
|
|
351
|
+
const { commands } = createPiHarness();
|
|
352
|
+
const { ctx, calls } = createFactoryCtx({});
|
|
353
|
+
// Drive the factory manually: capture it, call onAbort, then let the
|
|
354
|
+
// async IIFE finish.
|
|
355
|
+
let factory: any = null;
|
|
356
|
+
const customCtx: any = {
|
|
357
|
+
mode: "tui",
|
|
358
|
+
model: { provider: "test", id: "m" },
|
|
359
|
+
sessionManager: {
|
|
360
|
+
getBranch: () => [userEntry("we decided to build X")],
|
|
361
|
+
getSessionFile: () => "/tmp/old.json",
|
|
362
|
+
},
|
|
363
|
+
modelRegistry: {
|
|
364
|
+
async getApiKeyAndHeaders() {
|
|
365
|
+
return { ok: true, apiKey: "key", headers: {} };
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
ui: {
|
|
369
|
+
notify: (msg: string, kind: string) => calls.notify.push({ msg, kind }),
|
|
370
|
+
custom: async <T>(f: any): Promise<T> => {
|
|
371
|
+
factory = f;
|
|
372
|
+
return new Promise<T>((resolve) => {
|
|
373
|
+
const loader = f({}, {}, {}, resolve);
|
|
374
|
+
loader.onAbort();
|
|
375
|
+
});
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
async sendUserMessage(text: string) {
|
|
379
|
+
calls.userMessage = text;
|
|
380
|
+
},
|
|
381
|
+
async newSession(options: any) {
|
|
382
|
+
calls.newSession = options;
|
|
383
|
+
if (options?.withSession) await options.withSession(customCtx);
|
|
384
|
+
return { cancelled: false };
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
await commands.get("handoff-new")!.handler("goal", customCtx);
|
|
388
|
+
expect(calls.newSession).toBeNull();
|
|
389
|
+
expect(calls.notify.some((n) => /Cancelled/.test(n.msg))).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// sanity: DEFAULT_GOAL exported and non-empty (used by handler)
|
|
393
|
+
it("DEFAULT_GOAL is non-empty", () => {
|
|
394
|
+
expect(DEFAULT_GOAL.length).toBeGreaterThan(0);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
@@ -56,6 +56,9 @@ export function getHandoffMessages(branch: SessionEntry[]): AgentMessage[] {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const compaction = branch[compactionIndex];
|
|
59
|
+
// compactionIndex is only set when branch[i].type === "compaction", so the
|
|
60
|
+
// else branch is unreachable.
|
|
61
|
+
/* v8 ignore next 1 */
|
|
59
62
|
const firstKeptIndex =
|
|
60
63
|
compaction.type === "compaction" ? branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId) : -1;
|
|
61
64
|
const compactedBranch = [
|
|
@@ -108,4 +108,97 @@ describe("inline-skills", () => {
|
|
|
108
108
|
expect(result).toMatchObject({ action: "transform", text: expect.stringMatching(/<skill[\s\S]*Use \$research now$/) });
|
|
109
109
|
expect(handlers.get("input")!({ text: "$research", source: "extension" })).toEqual({ action: "continue" });
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
it("delegates applyCompletion and shouldTriggerFileCompletion to the base provider", async () => {
|
|
113
|
+
const pi = createPi([skill("research", "/skills/research/SKILL.md")]);
|
|
114
|
+
const base = {
|
|
115
|
+
getSuggestions: vi.fn(async () => null),
|
|
116
|
+
applyCompletion: vi.fn(() => "applied"),
|
|
117
|
+
shouldTriggerFileCompletion: vi.fn(() => false),
|
|
118
|
+
} as any;
|
|
119
|
+
const provider = createInlineSkillAutocompleteProvider(pi, base);
|
|
120
|
+
|
|
121
|
+
expect(provider.applyCompletion(["a"], 0, 1, {} as any, "$")).toBe("applied");
|
|
122
|
+
expect(base.applyCompletion).toHaveBeenCalledWith(["a"], 0, 1, {}, "$");
|
|
123
|
+
expect(provider.shouldTriggerFileCompletion(["a"], 0, 1)).toBe(false);
|
|
124
|
+
expect(base.shouldTriggerFileCompletion).toHaveBeenCalledWith(["a"], 0, 1);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("shouldTriggerFileCompletion defaults to true when the base provider lacks it", () => {
|
|
128
|
+
const pi = createPi([skill("research", "/skills/research/SKILL.md")]);
|
|
129
|
+
const base = { getSuggestions: vi.fn(async () => null), applyCompletion: vi.fn() } as any;
|
|
130
|
+
const provider = createInlineSkillAutocompleteProvider(pi, base);
|
|
131
|
+
|
|
132
|
+
expect(provider.shouldTriggerFileCompletion(["a"], 0, 1)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("falls back to the base provider when no $ token is present", async () => {
|
|
136
|
+
const pi = createPi([skill("research", "/skills/research/SKILL.md")]);
|
|
137
|
+
const base = { getSuggestions: vi.fn(async () => "base-result"), applyCompletion: vi.fn() } as any;
|
|
138
|
+
const provider = createInlineSkillAutocompleteProvider(pi, base);
|
|
139
|
+
|
|
140
|
+
expect(await provider.getSuggestions(["plain text"], 0, 5, {} as any)).toBe("base-result");
|
|
141
|
+
expect(base.getSuggestions).toHaveBeenCalledWith(["plain text"], 0, 5, {});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("handles a missing line and a bare $ token", async () => {
|
|
145
|
+
const pi = createPi([skill("research", "/skills/research/SKILL.md")]);
|
|
146
|
+
const base = { getSuggestions: vi.fn(async () => null), applyCompletion: vi.fn() } as any;
|
|
147
|
+
const provider = createInlineSkillAutocompleteProvider(pi, base);
|
|
148
|
+
|
|
149
|
+
// cursorLine beyond the array → lines[cursorLine] is undefined → "" slice.
|
|
150
|
+
expect(await provider.getSuggestions([], 3, 0, {} as any)).toBeNull();
|
|
151
|
+
// Bare "$" at end of line → empty token → all skills match (includes("")).
|
|
152
|
+
const bare = await provider.getSuggestions(["draft $"], 0, 7, {} as any);
|
|
153
|
+
expect(bare).toEqual({
|
|
154
|
+
prefix: "$",
|
|
155
|
+
items: [{ value: "$research", label: "$research", description: "research description" }],
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("falls back to the base provider when no skills match the token", async () => {
|
|
160
|
+
const pi = createPi([skill("research", "/skills/research/SKILL.md")]);
|
|
161
|
+
const base = { getSuggestions: vi.fn(async () => "base-result"), applyCompletion: vi.fn() } as any;
|
|
162
|
+
const provider = createInlineSkillAutocompleteProvider(pi, base);
|
|
163
|
+
|
|
164
|
+
expect(await provider.getSuggestions(["$zzz"], 0, 4, {} as any)).toBe("base-result");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("skips skills whose files fail to read", () => {
|
|
168
|
+
const pi = createPi([skill("missing", "/nonexistent/SKILL.md")]);
|
|
169
|
+
const text = expandInlineSkills("Use $missing now", pi);
|
|
170
|
+
expect(text).toBe("Use $missing now");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("session_start provider callback wraps the current provider", async () => {
|
|
174
|
+
const dir = mkdtempSync(join(tmpdir(), "inline-skills-"));
|
|
175
|
+
tempDirs.push(dir);
|
|
176
|
+
const file = join(dir, "research.md");
|
|
177
|
+
writeFileSync(file, "---\ndescription: Research\n---\nResearch instructions.\n");
|
|
178
|
+
const handlers = new Map<string, Function>();
|
|
179
|
+
const pi = {
|
|
180
|
+
getCommands: vi.fn(() => [skill("research", file)]),
|
|
181
|
+
on: vi.fn((event: string, handler: Function) => handlers.set(event, handler)),
|
|
182
|
+
} as unknown as ExtensionAPI;
|
|
183
|
+
let captured: any = null;
|
|
184
|
+
inlineSkillsExtension(pi);
|
|
185
|
+
handlers.get("session_start")!({}, { ui: { addAutocompleteProvider: (cb: any) => (captured = cb) } } as unknown as ExtensionContext);
|
|
186
|
+
|
|
187
|
+
const base = { getSuggestions: vi.fn(async () => null), applyCompletion: vi.fn() } as any;
|
|
188
|
+
const provider = captured(base);
|
|
189
|
+
expect(await provider.getSuggestions(["$res"], 0, 4, {} as any)).toEqual({
|
|
190
|
+
prefix: "$res",
|
|
191
|
+
items: [{ value: "$research", label: "$research", description: "research description" }],
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("input without skill tokens passes through unchanged", () => {
|
|
196
|
+
const handlers = new Map<string, Function>();
|
|
197
|
+
const pi = {
|
|
198
|
+
getCommands: vi.fn(() => []),
|
|
199
|
+
on: vi.fn((event: string, handler: Function) => handlers.set(event, handler)),
|
|
200
|
+
} as unknown as ExtensionAPI;
|
|
201
|
+
inlineSkillsExtension(pi);
|
|
202
|
+
expect(handlers.get("input")!({ text: "plain text", source: "interactive" })).toEqual({ action: "continue" });
|
|
203
|
+
});
|
|
111
204
|
});
|