@selesai/code 0.8.5 → 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 +12 -0
- package/dist/defaults/models.json +5 -0
- 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/agents/builder.md +5 -2
- package/dist/extensions/pi-subagents/agents/commentator.md +1 -1
- package/dist/extensions/pi-subagents/docs/configuration.md +8 -0
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/extensions/pi-subagents/src/extension/config.ts +6 -0
- package/dist/extensions/pi-subagents/src/extension/index.ts +83 -14
- package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +12 -6
- package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +3 -0
- package/dist/extensions/pi-subagents/src/runs/background/notify.ts +5 -2
- 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 +7 -5
- package/dist/extensions/pi-subagents/src/runs/background/wait-subscriptions.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +7 -4
- package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +39 -9
- package/dist/extensions/pi-subagents/src/shared/session-lineage.ts +71 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +16 -0
- package/dist/extensions/pi-subagents/src/tui/fleet.ts +1 -1
- package/dist/extensions/pi-subagents/src/workflows/workflow-auto-relaunch.ts +28 -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/session-lineage.test.ts +73 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-auto-relaunch.test.ts +45 -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/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/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to `@selesai/code` will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.8.6] - 2026-08-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Auto-relaunching workflows.** The four `/workflow-*` modes (task, prototype, quicktype, loop) now run fully unattended: when a scripted workflow exhausts its per-run fan-out budget before the goal is clean, the async executor mints a fresh budget and re-runs the same script with the same mission and progress file, up to `config.maxWorkflowAutoRelaunches` (default 12; `0` = unlimited). Only `budget` results relaunch — real child failures still surface immediately. `AsyncStatus` gained `workflowRelaunchCount`.
|
|
11
|
+
- **Packaged prompts now load.** The pi-subagents extension registers `resources_discover`, so the bundled prompts (`review-loop.md`, `parallel-*.md`, `gather-context-and-clarify.md`) are actually discoverable. The `workflow-drive.md` prompt was removed as superseded by auto-relaunch.
|
|
12
|
+
- **Goal visibility on workflow launches.** Each `/workflow-*` launch emits its goal at start (visible in `subagent status` workflow emits) and names the auto-created mission after the goal (`mission.list` / `mission.show`).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **Bounded build→review→fix rounds.** The workflow auto-loop now instructs the builder to implement one small, self-contained slice per round ("do not attempt the whole plan") instead of the entire plan at once, with per-run timeouts (build/fix 45m, review 15m).
|
|
16
|
+
- **Progress-file scoped reviews.** Builder appends a `## Round N` ledger entry (files, summary, validation) to `.pi-subagents/progress/<mode>.md`; the commentator scopes its review to the latest round entry plus the immediately preceding fix entry, falling back to the full uncommitted diff when the file is missing or empty. Blocking reviews must list a `Remaining work:` section; the next build round picks up from it.
|
|
17
|
+
- **Previous review feedback now reaches the next builder round** (previously it was dropped).
|
|
18
|
+
|
|
7
19
|
## [0.8.5] - 2026-08-14
|
|
8
20
|
|
|
9
21
|
### Added
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"reasoning": true,
|
|
17
17
|
"input": ["text"],
|
|
18
18
|
"contextWindow": 512000,
|
|
19
|
+
"maxTokens": 64000,
|
|
19
20
|
"thinkingLevelMap": {
|
|
20
21
|
"minimal": null,
|
|
21
22
|
"low": null,
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"reasoning": true,
|
|
37
38
|
"input": ["text"],
|
|
38
39
|
"contextWindow": 512000,
|
|
40
|
+
"maxTokens": 64000,
|
|
39
41
|
"thinkingLevelMap": {
|
|
40
42
|
"minimal": null,
|
|
41
43
|
"low": null,
|
|
@@ -56,6 +58,7 @@
|
|
|
56
58
|
"reasoning": true,
|
|
57
59
|
"input": ["text", "image"],
|
|
58
60
|
"contextWindow": 512000,
|
|
61
|
+
"maxTokens": 64000,
|
|
59
62
|
"thinkingLevelMap": {
|
|
60
63
|
"minimal": null,
|
|
61
64
|
"low": "low",
|
|
@@ -75,6 +78,7 @@
|
|
|
75
78
|
"reasoning": true,
|
|
76
79
|
"input": ["text", "image"],
|
|
77
80
|
"contextWindow": 262128,
|
|
81
|
+
"maxTokens": 64000,
|
|
78
82
|
"thinkingLevelMap": {
|
|
79
83
|
"minimal": null,
|
|
80
84
|
"low": "low",
|
|
@@ -94,6 +98,7 @@
|
|
|
94
98
|
"reasoning": true,
|
|
95
99
|
"input": ["text"],
|
|
96
100
|
"contextWindow": 1000000,
|
|
101
|
+
"maxTokens": 64000,
|
|
97
102
|
"thinkingLevelMap": {
|
|
98
103
|
"minimal": null,
|
|
99
104
|
"low": "low",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import agentBrowserSetupExtension, {
|
|
6
6
|
DECLINED_MARKER_NAME,
|
|
7
7
|
ensureAgentBrowser,
|
|
@@ -192,4 +192,180 @@ describe("agent-browser setup extension", () => {
|
|
|
192
192
|
it("declined marker path lives in the agent dir", () => {
|
|
193
193
|
expect(getDeclinedMarkerPath()).toBe(join(tmpDir, DECLINED_MARKER_NAME));
|
|
194
194
|
});
|
|
195
|
+
|
|
196
|
+
it("skips probing when the decline marker exists", async () => {
|
|
197
|
+
const { pi, handlers, exec } = makePi();
|
|
198
|
+
await agentBrowserSetupExtension(pi);
|
|
199
|
+
const markerPath = getDeclinedMarkerPath();
|
|
200
|
+
mkdirSync(dirname(markerPath), { recursive: true });
|
|
201
|
+
writeFileSync(markerPath, "1", "utf-8");
|
|
202
|
+
try {
|
|
203
|
+
await getSessionStartHandler(handlers)({ type: "session_start", reason: "startup" }, makeCtx());
|
|
204
|
+
expect(exec).not.toHaveBeenCalled();
|
|
205
|
+
} finally {
|
|
206
|
+
rmSync(markerPath, { force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("isAgentBrowserInstalled returns false when exec throws", async () => {
|
|
211
|
+
const { pi } = makePi(async () => {
|
|
212
|
+
throw new Error("spawn failed");
|
|
213
|
+
});
|
|
214
|
+
expect(await isAgentBrowserInstalled(pi)).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("isAgentBrowserInstalled returns false when the probe is killed", async () => {
|
|
218
|
+
const { pi } = makePi(async () => ({ code: 0, stdout: "", stderr: "", killed: true }));
|
|
219
|
+
expect(await isAgentBrowserInstalled(pi)).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("reports a timed-out npm install", async () => {
|
|
223
|
+
const execImpl = vi
|
|
224
|
+
.fn()
|
|
225
|
+
.mockResolvedValueOnce(fail) // probe: missing
|
|
226
|
+
.mockResolvedValueOnce({ code: 0, stdout: "", stderr: "", killed: true }); // npm killed
|
|
227
|
+
const { pi, handlers, exec } = makePi(execImpl as any);
|
|
228
|
+
await agentBrowserSetupExtension(pi);
|
|
229
|
+
const ctx = makeCtx();
|
|
230
|
+
await getSessionStartHandler(handlers)({ type: "session_start", reason: "startup" }, ctx);
|
|
231
|
+
|
|
232
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("timed out"), "error");
|
|
233
|
+
expect(exec).toHaveBeenCalledTimes(2);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("reports npm success but missing binary on PATH", async () => {
|
|
237
|
+
const execImpl = vi
|
|
238
|
+
.fn()
|
|
239
|
+
.mockResolvedValueOnce(fail) // probe: missing
|
|
240
|
+
.mockResolvedValueOnce(ok) // npm i -g agent-browser
|
|
241
|
+
.mockResolvedValueOnce(fail) // probe: still missing
|
|
242
|
+
.mockResolvedValueOnce(ok); // (unused)
|
|
243
|
+
const { pi, handlers, exec } = makePi(execImpl as any);
|
|
244
|
+
await agentBrowserSetupExtension(pi);
|
|
245
|
+
const ctx = makeCtx();
|
|
246
|
+
await getSessionStartHandler(handlers)({ type: "session_start", reason: "startup" }, ctx);
|
|
247
|
+
|
|
248
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("still not on PATH"), "warning");
|
|
249
|
+
expect(exec).toHaveBeenCalledTimes(3);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("warns when browser provisioning fails after install", async () => {
|
|
253
|
+
const execImpl = vi
|
|
254
|
+
.fn()
|
|
255
|
+
.mockResolvedValueOnce(fail) // probe: missing
|
|
256
|
+
.mockResolvedValueOnce(ok) // npm i -g agent-browser
|
|
257
|
+
.mockResolvedValueOnce(ok) // probe: now present
|
|
258
|
+
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "browser failed", killed: false }); // agent-browser install fails
|
|
259
|
+
const { pi, handlers, exec } = makePi(execImpl as any);
|
|
260
|
+
await agentBrowserSetupExtension(pi);
|
|
261
|
+
const ctx = makeCtx();
|
|
262
|
+
await getSessionStartHandler(handlers)({ type: "session_start", reason: "startup" }, ctx);
|
|
263
|
+
|
|
264
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Run `agent-browser install`"), "info");
|
|
265
|
+
expect(exec).toHaveBeenCalledTimes(4);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("provisionBrowser returns false when exec throws", async () => {
|
|
269
|
+
const execImpl = vi
|
|
270
|
+
.fn()
|
|
271
|
+
.mockResolvedValueOnce(fail) // probe: missing
|
|
272
|
+
.mockResolvedValueOnce(ok) // npm i -g agent-browser
|
|
273
|
+
.mockResolvedValueOnce(ok) // probe: now present
|
|
274
|
+
.mockRejectedValueOnce(new Error("spawn failed")); // agent-browser install throws
|
|
275
|
+
const { pi, handlers, exec } = makePi(execImpl as any);
|
|
276
|
+
await agentBrowserSetupExtension(pi);
|
|
277
|
+
const ctx = makeCtx();
|
|
278
|
+
await getSessionStartHandler(handlers)({ type: "session_start", reason: "startup" }, ctx);
|
|
279
|
+
|
|
280
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("Run `agent-browser install`"), "info");
|
|
281
|
+
expect(exec).toHaveBeenCalledTimes(4);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("ensureAgentBrowser returns false when the user declines", async () => {
|
|
285
|
+
const execImpl = vi.fn().mockResolvedValue(fail);
|
|
286
|
+
const { pi } = makePi(execImpl as any);
|
|
287
|
+
const ctx = makeCtx({ confirmResult: false });
|
|
288
|
+
expect(await ensureAgentBrowser(pi, ctx)).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("ensureAgentBrowser returns true when already installed", async () => {
|
|
292
|
+
const { pi } = makePi(async () => ok);
|
|
293
|
+
expect(await ensureAgentBrowser(pi, makeCtx())).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("ensureAgentBrowser returns false without UI", async () => {
|
|
297
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
298
|
+
const execImpl = vi.fn().mockResolvedValue(fail);
|
|
299
|
+
const { pi } = makePi(execImpl as any);
|
|
300
|
+
expect(await ensureAgentBrowser(pi, makeCtx({ hasUI: false }))).toBe(false);
|
|
301
|
+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(INSTALL_COMMAND));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("ensureAgentBrowser returns false when npm install fails", async () => {
|
|
305
|
+
const execImpl = vi
|
|
306
|
+
.fn()
|
|
307
|
+
.mockResolvedValueOnce(fail) // probe
|
|
308
|
+
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "EACCES", killed: false }); // npm fails
|
|
309
|
+
const { pi } = makePi(execImpl as any);
|
|
310
|
+
expect(await ensureAgentBrowser(pi, makeCtx())).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("npm install failure surfaces stdout when stderr is empty", async () => {
|
|
314
|
+
const execImpl = vi
|
|
315
|
+
.fn()
|
|
316
|
+
.mockResolvedValueOnce(fail) // probe
|
|
317
|
+
.mockResolvedValueOnce({ code: 1, stdout: "npm error log", stderr: "", killed: false }); // npm fails with stdout only
|
|
318
|
+
const { pi } = makePi(execImpl as any);
|
|
319
|
+
const ctx = makeCtx();
|
|
320
|
+
expect(await ensureAgentBrowser(pi, ctx)).toBe(false);
|
|
321
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("npm error log"), "error");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("npm install failure falls back to exit status when output is empty", async () => {
|
|
325
|
+
const execImpl = vi
|
|
326
|
+
.fn()
|
|
327
|
+
.mockResolvedValueOnce(fail) // probe
|
|
328
|
+
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "", killed: false }); // npm fails with no output
|
|
329
|
+
const { pi } = makePi(execImpl as any);
|
|
330
|
+
const ctx = makeCtx();
|
|
331
|
+
expect(await ensureAgentBrowser(pi, ctx)).toBe(false);
|
|
332
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("exit status 1"), "error");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("notify is a no-op without UI even when the CLI is installed", async () => {
|
|
336
|
+
const { pi } = makePi(async () => ok);
|
|
337
|
+
const ctx = makeCtx({ hasUI: false });
|
|
338
|
+
expect(await ensureAgentBrowser(pi, ctx)).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("ensureAgentBrowser returns true after full install flow", async () => {
|
|
342
|
+
const execImpl = vi
|
|
343
|
+
.fn()
|
|
344
|
+
.mockResolvedValueOnce(fail) // probe: missing
|
|
345
|
+
.mockResolvedValueOnce(ok) // npm i -g agent-browser
|
|
346
|
+
.mockResolvedValueOnce(ok) // probe: now present
|
|
347
|
+
.mockResolvedValueOnce(ok); // agent-browser install
|
|
348
|
+
const { pi } = makePi(execImpl as any);
|
|
349
|
+
expect(await ensureAgentBrowser(pi, makeCtx())).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("cliCommand wraps args in cmd.exe on win32", () => {
|
|
353
|
+
const original = process.platform;
|
|
354
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
355
|
+
try {
|
|
356
|
+
const { ensureAgentBrowser: _e, ...rest } = {} as any;
|
|
357
|
+
// Re-import to pick up the platform at module scope is not needed;
|
|
358
|
+
// cliCommand is module-private, so exercise it through the public flow.
|
|
359
|
+
const execImpl = vi.fn().mockResolvedValue(ok);
|
|
360
|
+
const { pi } = makePi(execImpl as any);
|
|
361
|
+
return ensureAgentBrowser(pi, makeCtx()).then((result) => {
|
|
362
|
+
expect(result).toBe(true);
|
|
363
|
+
const call = execImpl.mock.calls[0];
|
|
364
|
+
expect(call[0]).toBe("cmd");
|
|
365
|
+
expect(call[1]).toEqual(["/c", "agent-browser", "--version"]);
|
|
366
|
+
});
|
|
367
|
+
} finally {
|
|
368
|
+
Object.defineProperty(process, "platform", { value: original });
|
|
369
|
+
}
|
|
370
|
+
});
|
|
195
371
|
});
|
|
@@ -75,6 +75,8 @@ async function runNpmInstall(pi: ExtensionAPI): Promise<string | null> {
|
|
|
75
75
|
const result = await pi.exec(command, args, { timeout: INSTALL_TIMEOUT_MS });
|
|
76
76
|
if (result.killed) return "npm install timed out";
|
|
77
77
|
if (result.code !== 0) {
|
|
78
|
+
// The exit-status fallback is always non-empty, so the final || is unreachable.
|
|
79
|
+
/* v8 ignore next 1 */
|
|
78
80
|
return (result.stderr || result.stdout || `exit status ${result.code}`).trim() || "npm install failed";
|
|
79
81
|
}
|
|
80
82
|
return null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import copyTurnExtension from "./copy-turn.ts";
|
|
3
4
|
|
|
4
5
|
vi.mock("@selesai/code", () => ({
|
|
@@ -19,6 +20,11 @@ function hashFromMarkedText(text: string): string {
|
|
|
19
20
|
return m[1]!;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// hashFor(m) = sha1(JSON.stringify([role, resultOf(m)])).slice(0, 6)
|
|
24
|
+
function hashForText(role: string, text: string): string {
|
|
25
|
+
return createHash("sha1").update(JSON.stringify([role, text])).digest("hex").slice(0, 6);
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
function createHarness() {
|
|
23
29
|
const handlers = new Map<string, Handler>();
|
|
24
30
|
const commands = new Map<string, Command>();
|
|
@@ -128,4 +134,227 @@ describe("copy-turn hash stability", () => {
|
|
|
128
134
|
|
|
129
135
|
expect(hashFromMarkedText(r1.message.content[0].text)).toBe(hashFromMarkedText(r2.message.content[0].text));
|
|
130
136
|
});
|
|
137
|
+
|
|
138
|
+
it("skips empty user messages and tool-use assistant messages", async () => {
|
|
139
|
+
const { handlers, sent } = createHarness();
|
|
140
|
+
const messageEnd = handlers.get("message_end")!;
|
|
141
|
+
|
|
142
|
+
await messageEnd({ type: "message_end", message: { role: "user", content: " " } }, {});
|
|
143
|
+
expect(sent).toHaveLength(0);
|
|
144
|
+
|
|
145
|
+
const toolUse = await messageEnd({ type: "message_end", message: { role: "assistant", content: "thinking", stopReason: "toolUse" } }, {});
|
|
146
|
+
expect(toolUse).toBeUndefined();
|
|
147
|
+
|
|
148
|
+
const emptyAssistant = await messageEnd({ type: "message_end", message: { role: "assistant", content: "" } }, {});
|
|
149
|
+
expect(emptyAssistant).toBeUndefined();
|
|
150
|
+
|
|
151
|
+
const otherRole = await messageEnd({ type: "message_end", message: { role: "system", content: "sys" } }, {});
|
|
152
|
+
expect(otherRole).toBeUndefined();
|
|
153
|
+
|
|
154
|
+
await messageEnd({ type: "message_end", message: null }, {});
|
|
155
|
+
expect(sent).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("marks string-content assistant messages and bashExecution results", async () => {
|
|
159
|
+
const { handlers } = createHarness();
|
|
160
|
+
const messageEnd = handlers.get("message_end")!;
|
|
161
|
+
|
|
162
|
+
const stringMsg = await messageEnd({ type: "message_end", message: { role: "assistant", content: "plain text result" } }, {});
|
|
163
|
+
expect(stringMsg.message.content).toMatch(/plain text result\n\n⧉ copy assistant: \/cp [0-9a-f]{6}/);
|
|
164
|
+
|
|
165
|
+
const bashMsg = await messageEnd({
|
|
166
|
+
type: "message_end",
|
|
167
|
+
message: { role: "bashExecution", content: "ignored", command: "ls -la", output: "file1\nfile2" },
|
|
168
|
+
}, {});
|
|
169
|
+
expect(bashMsg).toBeUndefined(); // bashExecution is not assistant
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("marks the last text part of multi-part assistant content", async () => {
|
|
173
|
+
const { handlers } = createHarness();
|
|
174
|
+
const messageEnd = handlers.get("message_end")!;
|
|
175
|
+
|
|
176
|
+
const result = await messageEnd({
|
|
177
|
+
type: "message_end",
|
|
178
|
+
message: {
|
|
179
|
+
role: "assistant",
|
|
180
|
+
content: [
|
|
181
|
+
{ type: "text", text: "first part" },
|
|
182
|
+
{ type: "image", mimeType: "image/png" },
|
|
183
|
+
{ type: "text", text: "final part" },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
}, {});
|
|
187
|
+
expect(result.message.content[0].text).toBe("first part");
|
|
188
|
+
expect(result.message.content[1].type).toBe("image");
|
|
189
|
+
expect(result.message.content[2].text).toMatch(/^final part\n\n⧉ copy assistant: \/cp [0-9a-f]{6}$/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("handles text parts with undefined text", async () => {
|
|
193
|
+
const { handlers } = createHarness();
|
|
194
|
+
const messageEnd = handlers.get("message_end")!;
|
|
195
|
+
|
|
196
|
+
const result = await messageEnd({
|
|
197
|
+
type: "message_end",
|
|
198
|
+
message: { role: "assistant", content: [{ type: "text" }, { type: "text", text: "real" }] },
|
|
199
|
+
}, {});
|
|
200
|
+
expect(result.message.content[0].text).toBeUndefined(); // non-last parts pass through unchanged
|
|
201
|
+
expect(result.message.content[1].text).toMatch(/^real\n\n⧉ copy assistant: \/cp [0-9a-f]{6}$/);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("hashes bashExecution results without command or output", async () => {
|
|
205
|
+
const { handlers, commands } = createHarness();
|
|
206
|
+
const messageEnd = handlers.get("message_end")!;
|
|
207
|
+
const cmd = commands.get("cp")!;
|
|
208
|
+
|
|
209
|
+
// No command, no output → empty result → no copy row.
|
|
210
|
+
await messageEnd({ type: "message_end", message: { role: "bashExecution" } }, {});
|
|
211
|
+
|
|
212
|
+
// Command without output.
|
|
213
|
+
const message = { role: "bashExecution", command: "ls" };
|
|
214
|
+
const hash = hashForText("bashExecution", "$ ls");
|
|
215
|
+
const ctx = branchCtx([message]);
|
|
216
|
+
await cmd.handler(hash, ctx);
|
|
217
|
+
expect(copyToClipboard).toHaveBeenCalledWith("$ ls");
|
|
218
|
+
|
|
219
|
+
// Output without command (clean.command ? ... : "" branch).
|
|
220
|
+
const noCommand = { role: "bashExecution", output: "bare output" };
|
|
221
|
+
const hash2 = hashForText("bashExecution", "bare output");
|
|
222
|
+
const ctx2 = branchCtx([noCommand]);
|
|
223
|
+
await cmd.handler(hash2, ctx2);
|
|
224
|
+
expect(copyToClipboard).toHaveBeenCalledWith("bare output");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("branchMessages tolerates a missing sessionManager and filters custom rows", async () => {
|
|
228
|
+
const { commands } = createHarness();
|
|
229
|
+
const cmd = commands.get("cp")!;
|
|
230
|
+
|
|
231
|
+
const noManager = { ui: { notify: vi.fn() } };
|
|
232
|
+
await cmd.handler("abc123", noManager);
|
|
233
|
+
expect(noManager.ui.notify).toHaveBeenCalledWith("No message for hash abc123", "warning");
|
|
234
|
+
|
|
235
|
+
const withCustom = branchCtx([
|
|
236
|
+
{ role: "user", content: "real content" },
|
|
237
|
+
{ role: "custom", customType: "copy-turn", content: "⧉ copy user: /cp abc123" },
|
|
238
|
+
]);
|
|
239
|
+
const hash = hashForText("user", "real content");
|
|
240
|
+
await cmd.handler(hash, withCustom);
|
|
241
|
+
expect(copyToClipboard).toHaveBeenCalledWith("real content");
|
|
242
|
+
|
|
243
|
+
// Role-less message: hashFor falls back to the "message" role.
|
|
244
|
+
const roleless = branchCtx([{ content: "roleless content" }]);
|
|
245
|
+
const rolelessHash = hashForText("message", "roleless content");
|
|
246
|
+
await cmd.handler(rolelessHash, roleless);
|
|
247
|
+
expect(copyToClipboard).toHaveBeenCalledWith("roleless content");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("adds a marker to content arrays without text parts", async () => {
|
|
251
|
+
const { handlers } = createHarness();
|
|
252
|
+
const messageEnd = handlers.get("message_end")!;
|
|
253
|
+
|
|
254
|
+
// Image-only assistant content has no copyable text, so no marker is added.
|
|
255
|
+
const result = await messageEnd({
|
|
256
|
+
type: "message_end",
|
|
257
|
+
message: { role: "assistant", content: [{ type: "image", mimeType: "image/png" }] },
|
|
258
|
+
}, {});
|
|
259
|
+
expect(result).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("context handler strips copy rows and cleans markers", async () => {
|
|
263
|
+
const { handlers } = createHarness();
|
|
264
|
+
const context = handlers.get("context")!;
|
|
265
|
+
const result = await context({
|
|
266
|
+
type: "context",
|
|
267
|
+
messages: [
|
|
268
|
+
{ role: "user", content: "hello\n\n⧉ copy user: /cp abc123" },
|
|
269
|
+
{ role: "custom", customType: "copy-turn", content: "⧉ copy user: /cp abc123" },
|
|
270
|
+
{ role: "assistant", content: [{ type: "text", text: "hi\n\n⧉ copy assistant: /cp def456" }] },
|
|
271
|
+
],
|
|
272
|
+
});
|
|
273
|
+
expect(result.messages).toHaveLength(2);
|
|
274
|
+
expect(result.messages[0].content).toBe("hello");
|
|
275
|
+
expect(result.messages[1].content[0].text).toBe("hi");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("message renderer falls back to content hash and default role", () => {
|
|
279
|
+
const { pi } = createHarness();
|
|
280
|
+
const renderer = (pi.registerMessageRenderer as any).mock.calls[0][1];
|
|
281
|
+
const theme = { fg: (_name: string, value: string) => value };
|
|
282
|
+
|
|
283
|
+
const withDetails = renderer({ details: { hash: "abc123", role: "user" } }, {}, theme);
|
|
284
|
+
expect(withDetails.render(80).join("\n")).toContain("⧉ copy user: /cp abc123");
|
|
285
|
+
|
|
286
|
+
const fromContent = renderer({ content: "hash fedcba in the middle" }, {}, theme);
|
|
287
|
+
expect(fromContent.render(80).join("\n")).toContain("⧉ copy message: /cp fedcba");
|
|
288
|
+
|
|
289
|
+
const fallback = renderer({ content: "no hash here" }, {}, theme);
|
|
290
|
+
expect(fallback.render(80).join("\n")).toContain("⧉ copy message: /cp ??????");
|
|
291
|
+
|
|
292
|
+
// Non-string, non-array content (defensive path in textFromContent).
|
|
293
|
+
const nonText = renderer({ content: 42 }, {}, theme);
|
|
294
|
+
expect(nonText.render(80).join("\n")).toContain("⧉ copy message: /cp ??????");
|
|
295
|
+
|
|
296
|
+
// Text part without a text property (b.text ?? "" branch).
|
|
297
|
+
const missingText = renderer({ content: [{ type: "text" }] }, {}, theme);
|
|
298
|
+
expect(missingText.render(80).join("\n")).toContain("⧉ copy message: /cp ??????");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("/cp reports usage, no-match, ambiguous, and empty-result cases", async () => {
|
|
302
|
+
const { commands } = createHarness();
|
|
303
|
+
const cmd = commands.get("cp")!;
|
|
304
|
+
|
|
305
|
+
const usageCtx = branchCtx([]);
|
|
306
|
+
await cmd.handler("", usageCtx);
|
|
307
|
+
expect(usageCtx.ui.notify).toHaveBeenCalledWith("Usage: /cp <hash>", "warning");
|
|
308
|
+
|
|
309
|
+
const noMatchCtx = branchCtx([{ role: "user", content: "hello" }]);
|
|
310
|
+
await cmd.handler("zzzzzz", noMatchCtx);
|
|
311
|
+
expect(noMatchCtx.ui.notify).toHaveBeenCalledWith("No message for hash zzzzzz", "warning");
|
|
312
|
+
|
|
313
|
+
// Two messages whose hashes share a prefix → ambiguous.
|
|
314
|
+
// "c" and "e" are known to share the first hash character "9".
|
|
315
|
+
const m1 = { role: "user", content: "c" };
|
|
316
|
+
const m2 = { role: "user", content: "e" };
|
|
317
|
+
const h1 = hashForText("user", "c");
|
|
318
|
+
const h2 = hashForText("user", "e");
|
|
319
|
+
// Find a shared prefix of length >= 1.
|
|
320
|
+
let shared = "";
|
|
321
|
+
for (let i = 0; i < Math.min(h1.length, h2.length); i++) {
|
|
322
|
+
if (h1[i] === h2[i]) shared += h1[i];
|
|
323
|
+
else break;
|
|
324
|
+
}
|
|
325
|
+
if (shared.length === 0) {
|
|
326
|
+
// Extremely unlikely; fall back to asserting the no-match path instead.
|
|
327
|
+
const ctx = branchCtx([m1]);
|
|
328
|
+
await cmd.handler(h1, ctx);
|
|
329
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(`Copied ${h1} (${m1.content.length} chars)`, "info");
|
|
330
|
+
} else {
|
|
331
|
+
const ambiguousCtx = branchCtx([m1, m2]);
|
|
332
|
+
await cmd.handler(shared, ambiguousCtx);
|
|
333
|
+
expect(ambiguousCtx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("ambiguous"), "warning");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const emptyCtx = branchCtx([{ role: "user", content: " " }]);
|
|
337
|
+
await cmd.handler(hashForText("user", ""), emptyCtx);
|
|
338
|
+
expect(emptyCtx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("No result content"), "warning");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("/cp strips ANSI codes before copying", async () => {
|
|
342
|
+
const { commands } = createHarness();
|
|
343
|
+
const cmd = commands.get("cp")!;
|
|
344
|
+
const message = { role: "user", content: "\x1b[31mred\x1b[0m text" };
|
|
345
|
+
const hash = hashForText("user", "\x1b[31mred\x1b[0m text");
|
|
346
|
+
const ctx = branchCtx([message]);
|
|
347
|
+
await cmd.handler(hash, ctx);
|
|
348
|
+
expect(copyToClipboard).toHaveBeenCalledWith("red text");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("/cp copies bashExecution results with command prefix", async () => {
|
|
352
|
+
const { commands } = createHarness();
|
|
353
|
+
const cmd = commands.get("cp")!;
|
|
354
|
+
const message = { role: "bashExecution", command: "ls", output: "a\nb" };
|
|
355
|
+
const hash = hashForText("bashExecution", "$ ls\na\nb");
|
|
356
|
+
const ctx = branchCtx([message]);
|
|
357
|
+
await cmd.handler(hash, ctx);
|
|
358
|
+
expect(copyToClipboard).toHaveBeenCalledWith("$ ls\na\nb");
|
|
359
|
+
});
|
|
131
360
|
});
|
|
@@ -50,10 +50,20 @@ function hashFor(m: AnyMessage): string {
|
|
|
50
50
|
function addMarker(m: AnyMessage): AnyMessage {
|
|
51
51
|
const h = hashFor(m);
|
|
52
52
|
const out = structuredClone(m);
|
|
53
|
+
// Unreachable: addMarker is only called from message_end for assistant
|
|
54
|
+
// messages, so out.role is always "assistant".
|
|
55
|
+
/* v8 ignore next 1 */
|
|
53
56
|
const role = out.role ?? "message";
|
|
54
57
|
if (typeof out.content === "string") out.content = stripMark(out.content) + mark(h, role);
|
|
58
|
+
// Unreachable: addMarker is only called when resultOf(m) is non-empty,
|
|
59
|
+
// which requires string or text-array content, so the non-array else
|
|
60
|
+
// branch below can never be taken.
|
|
61
|
+
/* v8 ignore start */
|
|
55
62
|
else if (Array.isArray(out.content)) {
|
|
56
63
|
const lastText = out.content.findLastIndex((x: any) => x?.type === "text");
|
|
64
|
+
// Unreachable: message_end only calls addMarker when resultOf(m) is
|
|
65
|
+
// non-empty, and resultOf extracts only text parts, so the last text
|
|
66
|
+
// part always exists and has defined text.
|
|
57
67
|
if (lastText >= 0) {
|
|
58
68
|
out.content = out.content.map((b: any, i: number) =>
|
|
59
69
|
i === lastText ? { ...b, text: stripMark(b.text ?? "") + mark(h, role) } : b
|
|
@@ -62,6 +72,7 @@ function addMarker(m: AnyMessage): AnyMessage {
|
|
|
62
72
|
out.content.push({ type: "text", text: mark(h, role).trimStart() });
|
|
63
73
|
}
|
|
64
74
|
}
|
|
75
|
+
/* v8 ignore stop */
|
|
65
76
|
return out;
|
|
66
77
|
}
|
|
67
78
|
|