little-coder 1.8.3 → 1.9.0

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.
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeHistory } from "./index.ts";
3
+
4
+ describe("prompt history recall", () => {
5
+ it("records prompts, skipping blanks and consecutive duplicates", () => {
6
+ const h = makeHistory();
7
+ h.add("first");
8
+ h.add(" "); // blank
9
+ h.add("second");
10
+ h.add("second"); // dup
11
+ expect(h.items).toEqual(["first", "second"]);
12
+ });
13
+
14
+ it("persists on add via the provided callback", () => {
15
+ const saved: string[][] = [];
16
+ const h = makeHistory([], (items) => saved.push([...items]));
17
+ h.add("one");
18
+ h.add("two");
19
+ expect(saved).toEqual([["one"], ["one", "two"]]);
20
+ });
21
+
22
+ it("seeds from initial (cross-session) history", () => {
23
+ const h = makeHistory(["older", "newer"]);
24
+ expect(h.recall("up", "")).toBe("newer");
25
+ expect(h.recall("up", "newer")).toBe("older");
26
+ });
27
+
28
+ it("up from an empty prompt recalls most-recent-first and clamps", () => {
29
+ const h = makeHistory(["a", "b", "c"]);
30
+ expect(h.recall("up", "")).toBe("c");
31
+ expect(h.recall("up", "c")).toBe("b");
32
+ expect(h.recall("up", "b")).toBe("a");
33
+ expect(h.recall("up", "a")).toBe("a"); // clamps at oldest
34
+ });
35
+
36
+ it("down walks forward and clears past the newest", () => {
37
+ const h = makeHistory(["a", "b"]);
38
+ h.recall("up", ""); // → b
39
+ h.recall("up", "b"); // → a
40
+ expect(h.recall("down", "a")).toBe("b");
41
+ expect(h.recall("down", "b")).toBe(""); // past newest → empty
42
+ });
43
+
44
+ it("does NOT hijack up when the prompt is non-empty (editor handles it)", () => {
45
+ const h = makeHistory(["a"]);
46
+ expect(h.recall("up", "draft text")).toBeNull();
47
+ });
48
+
49
+ it("does nothing on up when there is no history", () => {
50
+ const h = makeHistory([]);
51
+ expect(h.recall("up", "")).toBeNull();
52
+ });
53
+
54
+ it("down with no active navigation is a no-op (editor handles it)", () => {
55
+ const h = makeHistory(["a"]);
56
+ expect(h.recall("down", "")).toBeNull();
57
+ });
58
+
59
+ it("reset() ends navigation so the next up starts fresh", () => {
60
+ const h = makeHistory(["a", "b"]);
61
+ h.recall("up", ""); // → b (navigating)
62
+ h.reset();
63
+ expect(h.recall("up", "b")).toBeNull(); // non-empty + not navigating → editor handles
64
+ });
65
+
66
+ it("caps stored history at the maximum", () => {
67
+ const many = Array.from({ length: 150 }, (_, i) => `p${i}`);
68
+ const h = makeHistory(many);
69
+ expect(h.items.length).toBe(100);
70
+ expect(h.items[0]).toBe("p50"); // kept the newest 100
71
+ });
72
+ });
@@ -0,0 +1,89 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { normalizeWritePath } from "../write-guard/index.ts";
3
+ import { harnessIntervention } from "../_shared/intervention.ts";
4
+
5
+ // Read-before-edit guard.
6
+ //
7
+ // Small models routinely fire `edit` with an `oldText` they never actually saw
8
+ // — guessing at the current file contents — which either fails the exact-match
9
+ // requirement (wasting a turn) or, worse, matches the wrong span. Editors the
10
+ // user is used to (Claude Code et al.) enforce a simple invariant: a file must
11
+ // be Read before it can be Edited. We reproduce that here.
12
+ //
13
+ // Mechanism mirrors write-guard: we don't own pi's built-in `read`/`edit`
14
+ // tools, so we enforce at the event layer. We remember every file that was
15
+ // successfully `read` this session (`tool_result`, !isError), and block any
16
+ // `edit` whose target hasn't been read, redirecting the model to Read first.
17
+ //
18
+ // Why a separate extension from `read-guard`: read-guard trims an oversized
19
+ // read so it can't overflow a small context window — a different concern from
20
+ // the read-before-edit invariant. Keeping them apart keeps each single-purpose.
21
+ //
22
+ // A successful `edit` or `write` also marks the path as known: an edit only
23
+ // succeeds when the file was already read (we'd have blocked it otherwise), and
24
+ // a write means the model authored the file's contents, so a follow-up edit to
25
+ // either is legitimate without a re-read.
26
+
27
+ // Files read (or authored) in the current session. Module-scoped: one pi
28
+ // process drives one session at a time, and we clear on session_start.
29
+ export const readFiles = new Set<string>();
30
+
31
+ // pi's built-in tools use `path`; some prompts/older builds use `file_path`.
32
+ // Accept both so the guard is independent of which key the model emits.
33
+ export function resolveToolPath(
34
+ input: Record<string, unknown>,
35
+ cwd: string,
36
+ ): string | undefined {
37
+ const raw =
38
+ typeof input.path === "string"
39
+ ? input.path
40
+ : typeof input.file_path === "string"
41
+ ? input.file_path
42
+ : undefined;
43
+ if (!raw) return undefined;
44
+ return normalizeWritePath(raw, cwd).path;
45
+ }
46
+
47
+ export function editBeforeReadReason(resolved: string): string {
48
+ return (
49
+ `File must be read first before edit — ${resolved} has not been read in ` +
50
+ `this session.\n` +
51
+ `\n` +
52
+ `Read ${resolved} first to get the exact current text for oldText ` +
53
+ `(whitespace and indentation must match exactly), then issue the Edit. ` +
54
+ `Reading also lets you include enough surrounding context (2-3 lines) to ` +
55
+ `make oldText unique in the file. Do NOT guess the file's contents.`
56
+ );
57
+ }
58
+
59
+ export default function (pi: ExtensionAPI) {
60
+ // New session (startup, /clear, /resume, reload) is a clean slate — what was
61
+ // read in a previous session says nothing about the current one.
62
+ pi.on("session_start", async () => {
63
+ readFiles.clear();
64
+ });
65
+
66
+ // Record successful reads (and authored files) as "known".
67
+ pi.on("tool_result", async (event, ctx) => {
68
+ const name = String((event as any).toolName ?? "").toLowerCase();
69
+ if (name !== "read" && name !== "edit" && name !== "write") return;
70
+ if ((event as any).isError) return;
71
+ const p = resolveToolPath(((event as any).input ?? {}) as Record<string, unknown>, ctx.cwd);
72
+ if (p) readFiles.add(p);
73
+ });
74
+
75
+ // Block edits to files that were never read.
76
+ pi.on("tool_call", async (event, ctx) => {
77
+ if (String((event as any).toolName ?? "").toLowerCase() !== "edit") return;
78
+ const input = ((event as any).input ?? {}) as Record<string, unknown>;
79
+ const p = resolveToolPath(input, ctx.cwd);
80
+ if (!p) return; // no resolvable path — let the edit tool surface its own error
81
+ if (readFiles.has(p)) return; // already read this session — allow
82
+
83
+ harnessIntervention(
84
+ ctx,
85
+ "the model tried to edit a file it hadn't read — redirected it to Read first.",
86
+ );
87
+ return { block: true, reason: editBeforeReadReason(p) };
88
+ });
89
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import setupReadGuardEdit, { readFiles, resolveToolPath } from "./index.ts";
3
+
4
+ describe("resolveToolPath", () => {
5
+ const cwd = "/home/me/proj";
6
+ it("resolves relative paths against cwd", () => {
7
+ expect(resolveToolPath({ path: "src/a.ts" }, cwd)).toBe("/home/me/proj/src/a.ts");
8
+ });
9
+ it("honors the file_path key", () => {
10
+ expect(resolveToolPath({ file_path: "b.ts" }, cwd)).toBe("/home/me/proj/b.ts");
11
+ });
12
+ it("returns undefined with no path", () => {
13
+ expect(resolveToolPath({ content: "x" }, cwd)).toBeUndefined();
14
+ });
15
+ });
16
+
17
+ // Wire up the extension against a fake pi, capturing each registered handler.
18
+ function setup() {
19
+ const handlers: Record<string, (...args: any[]) => any> = {};
20
+ const pi = {
21
+ on(name: string, h: (...args: any[]) => any) {
22
+ handlers[name] = h;
23
+ },
24
+ };
25
+ setupReadGuardEdit(pi as any);
26
+ return handlers;
27
+ }
28
+
29
+ function makeCtx(cwd: string) {
30
+ const notifies: string[] = [];
31
+ return { cwd, notifies, ui: { notify: (m: string) => notifies.push(m) } };
32
+ }
33
+
34
+ describe("read-before-edit guard", () => {
35
+ const cwd = "/home/me/proj";
36
+ beforeEach(() => readFiles.clear());
37
+
38
+ it("blocks an edit to a file that was never read", async () => {
39
+ const h = setup();
40
+ const ctx = makeCtx(cwd);
41
+ const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts", edits: [] } }, ctx);
42
+ expect(result?.block).toBe(true);
43
+ expect(result.reason).toContain("must be read first");
44
+ expect(result.reason).toContain("/home/me/proj/a.ts");
45
+ expect(ctx.notifies[0]).toMatch(/harness intervention:.*Read first/i);
46
+ });
47
+
48
+ it("allows an edit after the file was successfully read", async () => {
49
+ const h = setup();
50
+ const ctx = makeCtx(cwd);
51
+ await h.tool_result({ toolName: "read", isError: false, input: { path: "a.ts" } }, ctx);
52
+ const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts", edits: [] } }, ctx);
53
+ expect(result).toBeUndefined();
54
+ expect(ctx.notifies).toHaveLength(0);
55
+ });
56
+
57
+ it("does not count a failed read as having read the file", async () => {
58
+ const h = setup();
59
+ const ctx = makeCtx(cwd);
60
+ await h.tool_result({ toolName: "read", isError: true, input: { path: "a.ts" } }, ctx);
61
+ const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts" } }, ctx);
62
+ expect(result?.block).toBe(true);
63
+ });
64
+
65
+ it("treats a freshly written file as known (write then edit is allowed)", async () => {
66
+ const h = setup();
67
+ const ctx = makeCtx(cwd);
68
+ await h.tool_result({ toolName: "write", isError: false, input: { path: "new.ts" } }, ctx);
69
+ const result = await h.tool_call({ toolName: "edit", input: { path: "new.ts" } }, ctx);
70
+ expect(result).toBeUndefined();
71
+ });
72
+
73
+ it("clears known files on session_start", async () => {
74
+ const h = setup();
75
+ const ctx = makeCtx(cwd);
76
+ await h.tool_result({ toolName: "read", isError: false, input: { path: "a.ts" } }, ctx);
77
+ await h.session_start();
78
+ const result = await h.tool_call({ toolName: "edit", input: { path: "a.ts" } }, ctx);
79
+ expect(result?.block).toBe(true);
80
+ });
81
+
82
+ it("matches reads and edits that use different path spellings for the same file", async () => {
83
+ const h = setup();
84
+ const ctx = makeCtx(cwd);
85
+ // read with cwd-relative, edit with the absolute form of the same file
86
+ await h.tool_result({ toolName: "read", isError: false, input: { path: "src/a.ts" } }, ctx);
87
+ const result = await h.tool_call(
88
+ { toolName: "edit", input: { path: "/home/me/proj/src/a.ts" } },
89
+ ctx,
90
+ );
91
+ expect(result).toBeUndefined();
92
+ });
93
+
94
+ it("ignores non-edit tool calls", async () => {
95
+ const h = setup();
96
+ const ctx = makeCtx(cwd);
97
+ const result = await h.tool_call({ toolName: "grep", input: { pattern: "x" } }, ctx);
98
+ expect(result).toBeUndefined();
99
+ });
100
+ });
@@ -56,6 +56,9 @@ const INTENT_MAP: Record<string, string[]> = {
56
56
  browse: ["BrowserNavigate", "BrowserExtract"],
57
57
  page: ["BrowserExtract"],
58
58
  click: ["BrowserClick"],
59
+ // Sub-coder delegation
60
+ delegate: ["dispatch"], dispatch: ["dispatch"], subagent: ["dispatch"],
61
+ investigate: ["dispatch"], parallel: ["dispatch"],
59
62
  };
60
63
 
61
64
  function skillsDir(): string {
@@ -60,10 +60,10 @@ describe("skills directory loads from repo", () => {
60
60
  const here = dirname(fileURLToPath(import.meta.url));
61
61
  const toolsDir = join(here, "..", "..", "..", "skills", "tools");
62
62
 
63
- it("exists and has 13 markdown files", () => {
63
+ it("exists and has 14 markdown files", () => {
64
64
  expect(existsSync(toolsDir)).toBe(true);
65
65
  const files = readdirSync(toolsDir).filter((f) => f.endsWith(".md"));
66
- expect(files.length).toBe(13);
66
+ expect(files.length).toBe(14);
67
67
  });
68
68
 
69
69
  it("every tool skill has target_tool in frontmatter", () => {
@@ -0,0 +1,201 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import {
4
+ runSubCoder,
5
+ runSubCodersConcurrent,
6
+ truncateReport,
7
+ type SubCoderItem,
8
+ type SubCoderResult,
9
+ } from "./spawn.ts";
10
+ import { SubCoderTracker } from "./tracker.ts";
11
+
12
+ // The `dispatch` tool: the main little-coder spawns isolated child little-coder
13
+ // sessions ("sub-coders") to research a focused question — they read the repo
14
+ // and browse online, then return a CONCISE report. The full child transcript
15
+ // lives in the tool's `details` (UI-only); only the short report enters the
16
+ // parent model's context. A live panel above the input tracks them while they
17
+ // run. See spawn.ts for the engine and the read-only constraints.
18
+
19
+ const MAX_PARALLEL = 4;
20
+
21
+ /** "provider/id" of the current model, so children run on the same backend. */
22
+ export function currentModelId(ctx: any): string | undefined {
23
+ const m = ctx?.model;
24
+ if (!m || typeof m.id !== "string") return undefined;
25
+ return m.provider ? `${m.provider}/${m.id}` : m.id;
26
+ }
27
+
28
+ /** A short label for a single-mode task (first few words). */
29
+ function shortLabel(task: string): string {
30
+ const words = task.trim().split(/\s+/).filter(Boolean).slice(0, 4).join(" ");
31
+ return words.length > 28 ? `${words.slice(0, 27)}…` : words || "sub-coder";
32
+ }
33
+
34
+ function buildContent(results: SubCoderResult[]): string {
35
+ if (results.length === 1) {
36
+ const r = results[0];
37
+ if (r.exitCode !== 0) return `Sub-coder "${r.label}" ${r.stopReason || "failed"}: ${r.errorMessage || "(no output)"}`;
38
+ return truncateReport(r.report) || "(no report)";
39
+ }
40
+ const ok = results.filter((r) => r.exitCode === 0).length;
41
+ const blocks = results.map((r) => {
42
+ const status = r.exitCode === 0 ? "" : ` [${r.stopReason || "failed"}]`;
43
+ const body = r.exitCode === 0 ? truncateReport(r.report) : r.errorMessage || "(no output)";
44
+ return `### ${r.label}${status}\n${body || "(no report)"}`;
45
+ });
46
+ return `${ok}/${results.length} sub-coders succeeded.\n\n${blocks.join("\n\n")}`;
47
+ }
48
+
49
+ export default function (pi: ExtensionAPI) {
50
+ pi.registerTool({
51
+ name: "dispatch",
52
+ label: "Dispatch sub-coder",
53
+ description: [
54
+ "Dispatch one or more isolated sub-coders to research a focused question.",
55
+ "Each runs in its own context window, can read the repo and browse online",
56
+ "(read, grep, glob, webfetch, websearch, browser, read-only bash) but CANNOT",
57
+ "edit or write files. Each returns a concise report. Use this to gather",
58
+ "information without cluttering your own context.",
59
+ "Single: { task }. Parallel: { tasks: [{ label, task }] } (max 4).",
60
+ ].join(" "),
61
+ parameters: Type.Object({
62
+ task: Type.Optional(Type.String({ description: "A single research task (single mode)" })),
63
+ tasks: Type.Optional(
64
+ Type.Array(
65
+ Type.Object({
66
+ label: Type.String({ description: "Short name for this sub-coder (shown in the tracker)" }),
67
+ task: Type.String({ description: "The research task delegated to this sub-coder" }),
68
+ }),
69
+ { description: `Up to ${MAX_PARALLEL} tasks to run in parallel` },
70
+ ),
71
+ ),
72
+ cwd: Type.Optional(Type.String({ description: "Working directory for the sub-coders (default: current)" })),
73
+ }),
74
+
75
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
76
+ const hasSingle = typeof params.task === "string" && params.task.trim().length > 0;
77
+ const hasTasks = Array.isArray(params.tasks) && params.tasks.length > 0;
78
+ if (hasSingle === hasTasks) {
79
+ return {
80
+ content: [{ type: "text", text: "Provide exactly one of `task` (single) or `tasks` (parallel)." }],
81
+ details: { results: [] },
82
+ isError: true,
83
+ };
84
+ }
85
+ if (hasTasks && params.tasks!.length > MAX_PARALLEL) {
86
+ return {
87
+ content: [{ type: "text", text: `Too many parallel tasks (${params.tasks!.length}). Max is ${MAX_PARALLEL}.` }],
88
+ details: { results: [] },
89
+ isError: true,
90
+ };
91
+ }
92
+
93
+ const cwd = params.cwd || ctx.cwd;
94
+ const items: SubCoderItem[] = hasSingle
95
+ ? [{ id: "1", label: shortLabel(params.task!), task: params.task!, cwd }]
96
+ : params.tasks!.map((t, i) => ({ id: String(i + 1), label: t.label, task: t.task, cwd }));
97
+
98
+ const model = currentModelId(ctx);
99
+ const tracker = new SubCoderTracker(ctx as any);
100
+ tracker.begin(items.map((it) => ({ id: it.id, label: it.label })));
101
+
102
+ const streamToToolCard = (results: SubCoderResult[]) => {
103
+ if (!onUpdate) return;
104
+ const done = results.filter((r) => r.exitCode !== -1).length;
105
+ onUpdate({
106
+ content: [{ type: "text", text: `${done}/${results.length} sub-coders done…` }],
107
+ details: { results },
108
+ });
109
+ };
110
+
111
+ let results: SubCoderResult[];
112
+ try {
113
+ if (hasSingle) {
114
+ const r = await runSubCoder({
115
+ ...items[0],
116
+ model,
117
+ signal,
118
+ onUpdate: (live) => {
119
+ tracker.update([live]);
120
+ streamToToolCard([live]);
121
+ },
122
+ });
123
+ results = [r];
124
+ } else {
125
+ results = await runSubCodersConcurrent(items, {
126
+ model,
127
+ signal,
128
+ onUpdate: (all) => {
129
+ tracker.update(all);
130
+ streamToToolCard(all);
131
+ },
132
+ });
133
+ }
134
+ } finally {
135
+ tracker.end();
136
+ }
137
+
138
+ const anyError = results.some((r) => r.exitCode !== 0);
139
+ const allError = results.every((r) => r.exitCode !== 0);
140
+ return {
141
+ content: [{ type: "text", text: buildContent(results) }],
142
+ details: { results },
143
+ isError: allError && anyError,
144
+ };
145
+ },
146
+
147
+ // Renderers return a duck-typed Component ({ render→lines, invalidate }).
148
+ // pi 0.79 stopped hoisting pi-tui, so we build colored lines via `theme`
149
+ // rather than importing pi-tui primitives (same approach as branding).
150
+ renderCall(args: any, theme: any) {
151
+ const lines: string[] = [];
152
+ const title = theme.fg("toolTitle", theme.bold("dispatch "));
153
+ if (Array.isArray(args.tasks) && args.tasks.length > 0) {
154
+ lines.push(title + theme.fg("accent", `${args.tasks.length} sub-coders`));
155
+ for (const t of args.tasks.slice(0, MAX_PARALLEL)) {
156
+ const preview = t.task.length > 48 ? `${t.task.slice(0, 48)}…` : t.task;
157
+ lines.push(` ${theme.fg("accent", t.label)}${theme.fg("dim", ` — ${preview}`)}`);
158
+ }
159
+ } else {
160
+ const task = String(args.task ?? "…");
161
+ lines.push(title + theme.fg("dim", task.length > 64 ? `${task.slice(0, 64)}…` : task));
162
+ }
163
+ return makeComponent(lines);
164
+ },
165
+
166
+ renderResult(result: any, options: any, theme: any) {
167
+ const expanded = !!options?.expanded;
168
+ const results: SubCoderResult[] = result?.details?.results ?? [];
169
+ if (results.length === 0) {
170
+ const t = result?.content?.[0];
171
+ return makeComponent([t?.type === "text" ? t.text : "(no output)"]);
172
+ }
173
+ const ok = results.filter((r) => r.exitCode === 0).length;
174
+ const lines: string[] = [
175
+ theme.fg("toolTitle", theme.bold("dispatch ")) + theme.fg("accent", `${ok}/${results.length} sub-coders`),
176
+ ];
177
+ for (const r of results) {
178
+ const icon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
179
+ lines.push("");
180
+ lines.push(`${icon} ${theme.fg("accent", r.label)}`);
181
+ const body = r.exitCode === 0 ? r.report : r.errorMessage || r.stderr || "(no output)";
182
+ const bodyLines = body.trim() ? body.trim().split("\n") : ["(no output)"];
183
+ const shown = expanded ? bodyLines : bodyLines.slice(0, 4);
184
+ for (const bl of shown) lines.push(theme.fg("toolOutput", bl));
185
+ if (!expanded && bodyLines.length > shown.length) lines.push(theme.fg("muted", " …"));
186
+ }
187
+ if (!expanded) lines.push(theme.fg("muted", "(Ctrl+O to expand)"));
188
+ return makeComponent(lines);
189
+ },
190
+ });
191
+ }
192
+
193
+ /** A minimal pi-tui Component backed by precomputed lines. */
194
+ function makeComponent(lines: string[]) {
195
+ return {
196
+ render(_width: number): string[] {
197
+ return lines;
198
+ },
199
+ invalidate() {},
200
+ };
201
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolve } from "node:path";
3
+ import { runSubCoder, runSubCodersConcurrent } from "./spawn.ts";
4
+
5
+ // Live end-to-end: spawns real child little-coder sessions against the local
6
+ // model. Skipped unless LC_LIVE=1 (needs a running model server). Run with:
7
+ // LC_LIVE=1 LLAMACPP_API_KEY=noop npx vitest run .pi/extensions/subagent/live-spawn.test.ts
8
+ const LIVE = process.env.LC_LIVE === "1";
9
+ const MODEL = process.env.LC_LIVE_MODEL || "llamacpp/qwen3.6-35b-a3b";
10
+ const repoRoot = resolve(__dirname, "..", "..", "..");
11
+
12
+ describe.skipIf(!LIVE)("sub-coder live spawn", () => {
13
+ it(
14
+ "spawns a child that runs on the parent's model and returns a report",
15
+ async () => {
16
+ const r = await runSubCoder({
17
+ id: "1",
18
+ label: "ping",
19
+ task: "Respond with exactly the single word ALIVE. Do not use any tools.",
20
+ cwd: repoRoot,
21
+ model: MODEL,
22
+ });
23
+ expect(r.exitCode).toBe(0);
24
+ expect(r.report.toUpperCase()).toContain("ALIVE");
25
+ expect(r.usage.turns).toBeGreaterThan(0);
26
+ },
27
+ 240_000,
28
+ );
29
+
30
+ it(
31
+ "runs two sub-coders in parallel and reports per-item",
32
+ async () => {
33
+ const results = await runSubCodersConcurrent(
34
+ [
35
+ { id: "1", label: "two", task: "What is 2+2? Reply with just the number.", cwd: repoRoot },
36
+ { id: "2", label: "cap", task: "What is the capital of France? One word.", cwd: repoRoot },
37
+ ],
38
+ { model: MODEL },
39
+ );
40
+ expect(results).toHaveLength(2);
41
+ expect(results.every((r) => r.exitCode === 0)).toBe(true);
42
+ expect(results[0].report).toContain("4");
43
+ expect(results[1].report.toLowerCase()).toContain("paris");
44
+ },
45
+ 300_000,
46
+ );
47
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildChildEnv,
4
+ defaultConcurrency,
5
+ getFinalText,
6
+ resolveLauncher,
7
+ summarizeActivity,
8
+ truncateReport,
9
+ SUBCODER_ALLOWED_TOOLS,
10
+ type SubCoderResult,
11
+ } from "./spawn.ts";
12
+
13
+ const base: SubCoderResult = {
14
+ id: "1",
15
+ label: "x",
16
+ task: "t",
17
+ exitCode: -1,
18
+ report: "",
19
+ messages: [],
20
+ stderr: "",
21
+ usage: { input: 0, output: 0, cost: 0, turns: 0, contextTokens: 0 },
22
+ };
23
+
24
+ describe("getFinalText", () => {
25
+ it("returns the last assistant text block", () => {
26
+ const messages = [
27
+ { role: "assistant", content: [{ type: "text", text: "first" }] },
28
+ { role: "assistant", content: [{ type: "toolCall", name: "read", arguments: {} }] },
29
+ { role: "assistant", content: [{ type: "text", text: "final answer" }] },
30
+ ];
31
+ expect(getFinalText(messages)).toBe("final answer");
32
+ });
33
+ it("returns empty string when there is no assistant text", () => {
34
+ expect(getFinalText([{ role: "user", content: [{ type: "text", text: "hi" }] }])).toBe("");
35
+ expect(getFinalText([])).toBe("");
36
+ });
37
+ });
38
+
39
+ describe("truncateReport", () => {
40
+ it("leaves short reports intact", () => {
41
+ expect(truncateReport("short")).toBe("short");
42
+ });
43
+ it("truncates long reports with a notice", () => {
44
+ const out = truncateReport("a".repeat(5000), 100);
45
+ expect(out.length).toBeLessThan(300);
46
+ expect(out).toContain("truncated at 100 chars");
47
+ });
48
+ });
49
+
50
+ describe("summarizeActivity", () => {
51
+ it("shows the report's first line when done", () => {
52
+ expect(summarizeActivity({ ...base, exitCode: 0, report: "Found 3 routes\nmore" })).toBe("Found 3 routes");
53
+ });
54
+ it("surfaces the latest tool call while running", () => {
55
+ const r = {
56
+ ...base,
57
+ messages: [{ role: "assistant", content: [{ type: "toolCall", name: "grep", arguments: { pattern: "login(" } }] }],
58
+ };
59
+ expect(summarizeActivity(r)).toBe("→ grep login(");
60
+ });
61
+ it("shows the error message on failure", () => {
62
+ expect(summarizeActivity({ ...base, exitCode: 1, errorMessage: "boom" })).toBe("boom");
63
+ });
64
+ it("falls back to working when running with no tool call", () => {
65
+ expect(summarizeActivity(base)).toBe("working…");
66
+ });
67
+ });
68
+
69
+ describe("buildChildEnv", () => {
70
+ it("constrains the child to read-only tools and the headless fast-path", () => {
71
+ const env = buildChildEnv();
72
+ expect(env.LITTLE_CODER_ALLOWED_TOOLS).toBe(SUBCODER_ALLOWED_TOOLS);
73
+ expect(env.LITTLE_CODER_ALLOWED_TOOLS).not.toContain("edit");
74
+ expect(env.LITTLE_CODER_ALLOWED_TOOLS).not.toContain("write");
75
+ expect(env.LITTLE_CODER_ALLOWED_TOOLS).not.toContain("dispatch");
76
+ expect(env.LITTLE_CODER_PERMISSION_MODE).toBe("auto");
77
+ expect(env.LITTLE_CODER_SUBAGENT).toBe("1");
78
+ });
79
+ it("merges extra overrides", () => {
80
+ expect(buildChildEnv({ FOO: "bar" }).FOO).toBe("bar");
81
+ });
82
+ });
83
+
84
+ describe("resolveLauncher / defaultConcurrency", () => {
85
+ it("points at bin/little-coder.mjs", () => {
86
+ expect(resolveLauncher().replace(/\\/g, "/")).toMatch(/\/bin\/little-coder\.mjs$/);
87
+ });
88
+ it("defaults concurrency to 2", () => {
89
+ const prev = process.env.LITTLE_CODER_SUBCODER_CONCURRENCY;
90
+ delete process.env.LITTLE_CODER_SUBCODER_CONCURRENCY;
91
+ expect(defaultConcurrency()).toBe(2);
92
+ process.env.LITTLE_CODER_SUBCODER_CONCURRENCY = "3";
93
+ expect(defaultConcurrency()).toBe(3);
94
+ if (prev === undefined) delete process.env.LITTLE_CODER_SUBCODER_CONCURRENCY;
95
+ else process.env.LITTLE_CODER_SUBCODER_CONCURRENCY = prev;
96
+ });
97
+ });