diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 9a3bec51..1bf6f311 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -1075,7 +1075,7 @@ "packages/app/src/pages/new-session/new-session-draft-controller.ts": "3607771a22b6855afd0b0d666de662c9070a2dcd56ae96ba51d89c9e11018e81", "packages/app/src/pages/new-session/new-session-view.test.ts": "b27a1cc858897b6d146eecf3c50db4a17b2efc15421685efdca837dce91b4db8", "packages/app/src/pages/new-session/new-session-view.tsx": "9b2688cb95aed672cbe5ddb0f7377a389b66abde7f5e84c5b9aa2f74b408eff7", - "packages/app/src/pages/session.tsx": "e85df7c6f3672de127421617c40e74be32b8bfd967291fcdd4583154e34d1c1c", + "packages/app/src/pages/session.tsx": "57fa072f802b662e3c039b0632caac39a229ccfc43480603974592949dfe23e1", "packages/app/src/pages/session/composer/bug-dock-controller.test.ts": "fe3d68f2f2bb41b04ee5567cd95a9c01671a98fd21585788be9d4cbb01a4bc22", "packages/app/src/pages/session/composer/bug-dock-controller.ts": "ab2c72a761e1992f300b6a0cecc9d0351b00a56e4be08567bcb22d3fcece78f7", "packages/app/src/pages/session/composer/bug-dock.ts": "11eb1a1c911df8764162d47f07e5f135d94fcb622f8a2ad9b41a23233131ba58", @@ -1098,6 +1098,8 @@ "packages/app/src/pages/session/helpers.test.ts": "a473d86117e3fddd25ba28a189d90fa35bcb80da7f2a152d3188e139f375e45d", "packages/app/src/pages/session/helpers.ts": "8d0106a5ec3f01a666bd840e20b6bfb28d0e88b8c8c51fc1fdd7eaa33e9daafc", "packages/app/src/pages/session/session-panel-width.test.ts": "2b9daf379be0b54142dd791bed6bae6d215b2574176b3b3e4b1273db3baf433c", + "packages/app/src/pages/session/spawn-tabs.test.ts": "b0eaeb976f3ae520fe38cd6b4cc1493b06c626923efaa2f57c22c70a638fb511", + "packages/app/src/pages/session/spawn-tabs.ts": "e5ae126808831944199244f67e4475aeb8f31a21c9eeff70eb3cd1ec05f6c5da", "packages/app/src/pages/session/session-panel-width.ts": "8723cb2f980972ea9bf182240fdf154d131bf40e22fe563fd538a7005cff9192", "packages/app/src/pages/session/session-side-panel-structure.test.ts": "c138fe905498c8326f459dccba61b146af6dfb12b78480303723b5a85046931a", "packages/app/src/pages/session/session-side-panel.tsx": "e2346a1f9d81c4f8436051ebf92dcd747be28f1e6b610d98792569067f28a4c0", diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx index f46a3407..960cebed 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/session.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/session.tsx @@ -108,6 +108,7 @@ import { serializeSession } from "@/utils/serialize-session" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { createSessionOwnership } from "./session/session-ownership" import { createSessionLineage } from "./session/session-lineage" +import { collectSpawnedChildren } from "./session/spawn-tabs" type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick @@ -266,6 +267,23 @@ function ResolvedTargetSessionRoute() { }) }) + // amicode#639: children spawned by THIS session (the amicode_session tool + // stamps metadata.spawned_by) land here as background tabs. The server + // session store (sync().session) remembers every session.created with full + // metadata, so the spawn stamp arrives on the same stream the tab list + // already rides — no new subscription. addSessionTab never navigates, so + // spawning never moves focus; openedSpawns makes the effect idempotent. + const openedSpawns = new Set() + createEffect(() => { + const parent = params.id + if (!parent) return + const fresh = collectSpawnedChildren(sync().session.data.info, parent, openedSpawns) + for (const id of fresh) { + openedSpawns.add(id) + tabs.addSessionTab({ server: serverKey(), sessionId: id }) + } + }) + return ( // Non-keyed: closes only while the target's directory is unknown (uncached // lineage mid-resolution), which tears down the workspace subtree including diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/spawn-tabs.test.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/spawn-tabs.test.ts new file mode 100644 index 00000000..631c363c --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/spawn-tabs.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test" +import { collectSpawnedChildren, isSpawnedBy, type SpawnedLike } from "./spawn-tabs" + +const child = (id: string, parentId: string): SpawnedLike => ({ + id, + metadata: { spawned_by: parentId, spawned_depth: 1 }, +}) + +describe("isSpawnedBy", () => { + test("matches the exact spawned_by stamp", () => { + expect(isSpawnedBy(child("ses_a", "ses_parent"), "ses_parent")).toBe(true) + }) + + test("does not match other parents or absent stamps", () => { + expect(isSpawnedBy(child("ses_a", "ses_other"), "ses_parent")).toBe(false) + expect(isSpawnedBy({ id: "ses_a" }, "ses_parent")).toBe(false) + expect(isSpawnedBy({ id: "ses_a", metadata: null }, "ses_parent")).toBe(false) + expect(isSpawnedBy(undefined, "ses_parent")).toBe(false) + expect(isSpawnedBy(null, "ses_parent")).toBe(false) + }) + + test("never matches a junk stamp", () => { + expect(isSpawnedBy({ id: "ses_a", metadata: { spawned_by: 42 } }, "ses_parent")).toBe(false) + expect(isSpawnedBy({ id: "ses_a", metadata: { spawned_by: ["ses_parent"] } }, "ses_parent")).toBe(false) + }) +}) + +describe("collectSpawnedChildren", () => { + const parent = "ses_parent" + + test("selects only children spawned by the parent, sorted", () => { + const info = { + ses_z: child("ses_z", parent), + ses_a: child("ses_a", parent), + ses_other: child("ses_other", "ses_other_parent"), + ses_plain: { id: "ses_plain" }, + } + expect(collectSpawnedChildren(info, parent, [])).toEqual(["ses_a", "ses_z"]) + }) + + test("excludes already-opened ids", () => { + const info = { + ses_a: child("ses_a", parent), + ses_b: child("ses_b", parent), + } + expect(collectSpawnedChildren(info, parent, ["ses_a"])).toEqual(["ses_b"]) + expect(collectSpawnedChildren(info, parent, ["ses_a", "ses_b"])).toEqual([]) + }) + + test("empty parent id yields nothing (draft routes have no session)", () => { + expect(collectSpawnedChildren({ ses_a: child("ses_a", "") }, "", [])).toEqual([]) + }) + + test("tolerates a missing info map", () => { + expect(collectSpawnedChildren(undefined as unknown as Record, parent, [])).toEqual([]) + }) + + test("re-running with the opened-set is idempotent", () => { + const info = { ses_a: child("ses_a", parent), ses_b: child("ses_b", parent) } + const opened = new Set() + const first = collectSpawnedChildren(info, parent, opened) + for (const id of first) opened.add(id) + expect(collectSpawnedChildren(info, parent, opened)).toEqual([]) + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/pages/session/spawn-tabs.ts b/packages/app-bundle/overlay/packages/app/src/pages/session/spawn-tabs.ts new file mode 100644 index 00000000..65fb29ff --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/session/spawn-tabs.ts @@ -0,0 +1,29 @@ +// amicode#639: sessions spawned by the `amicode_session` tool stamp metadata +// {spawned_by, spawned_depth} at create time. The session route showing the +// PARENT auto-opens each spawned child as a background tab (addSessionTab — +// never navigates, never steals focus). The pure selection logic lives here +// so it is unit-testable; the effect in pages/session.tsx applies it. + +export type SpawnedLike = { id: string; metadata?: { [key: string]: unknown } | null } + +export function isSpawnedBy(info: SpawnedLike | undefined | null, parentSessionID: string): boolean { + return !!info && info.metadata?.spawned_by === parentSessionID +} + +// Returns the ids in `infoById` spawned by `parentSessionID` that are not in +// `alreadyOpened`, sorted for deterministic tab order. The caller owns the +// opened-set so a re-running effect never double-opens. +export function collectSpawnedChildren( + infoById: Record, + parentSessionID: string, + alreadyOpened: Iterable, +): string[] { + if (!parentSessionID) return [] + const opened = new Set(alreadyOpened) + const out: string[] = [] + for (const [id, info] of Object.entries(infoById ?? {})) { + if (opened.has(id)) continue + if (isSpawnedBy(info, parentSessionID)) out.push(id) + } + return out.sort() +} diff --git a/packages/extension/AGENTS.md b/packages/extension/AGENTS.md index aa945237..88544aa5 100644 --- a/packages/extension/AGENTS.md +++ b/packages/extension/AGENTS.md @@ -78,6 +78,15 @@ infrastructure. rename them with `amicode_problem`. All design state, events, and entities live there. +**`amicode_session`** spawns new chat sessions that appear as background tabs +beside the current one (the only server-mutating tool in the `amicode_*` pack — +everything else is local bookkeeping). Use it for parallel or branching work +the USER should see and steer; use the Task tool for subagent-style work they +need not watch. Children start their first turn immediately and run on the +user's model budget — fan out deliberately (max 4 per call). `mode: "fork"` +seeds a child from this session's history; the spawn-depth cap (2) is soft and +overridable with `force: true`. + **`amico-run`** is the gate + launch CLI. It validates specs, scans imports, checks tiers, and launches scripts. `amico-run --help` prints usage. diff --git a/packages/extension/opencode-plugin/amicode_tools.ts b/packages/extension/opencode-plugin/amicode_tools.ts index 300bd3c8..4be0aa09 100644 --- a/packages/extension/opencode-plugin/amicode_tools.ts +++ b/packages/extension/opencode-plugin/amicode_tools.ts @@ -88,7 +88,20 @@ import { lastEventSeq, migrateLegacyEntities, } from "./problems"; -import { guardAndRecordStage, completeStage } from "./score_guard"; +import { + guardAndRecordStage, completeStage } from "./score_guard"; +import { + SPAWN_MAX_COUNT, + SPAWN_MAX_DEPTH, + parseSpawnArgs, + computeDepth, + depthRefusal, + defaultTitle, + childTitle, + unwrap, + summarizeSpawned, + type SpawnedChild, +} from "./session_spawn"; import { onboardingStreamDir, isOnboardingEntity, @@ -242,8 +255,26 @@ const RYDBERG_SCOPE_NOTE = "honest about the tier — and do NOT tell the user Rydberg is unsupported, because it isn't."; // The plugin: exactly one export (see header). opencode calls it on session -// creation with PluginInput; we need nothing from it today. -export const AmicodeTools = async (_input: unknown) => ({ +// creation with PluginInput. We need exactly one thing from it today: the +// server-bound SDK `client` the engine builds per plugin load (fork +// packages/opencode/src/plugin/index.ts — createOpencodeClient({baseUrl, +// directory, headers})). amicode_session is the first tool in this pack that +// talks to the server; every other tool below stays local bookkeeping. When +// a loader passes no input (legacy/odd paths), the tool degrades to an +// honest refusal rather than throwing at import time. +export const AmicodeTools = async (input: unknown) => { + const engineClient = (input as { client?: unknown } | undefined)?.client as + | { + session: { + get: (o: unknown) => Promise; + create: (o: unknown) => Promise; + update: (o: unknown) => Promise; + fork: (o: unknown) => Promise; + promptAsync: (o: unknown) => Promise; + }; + } + | undefined; + return { tool: { // Capability warrant request (spec-20260727-164748 §9.5 / G-9). The CARD is the // point: this tool exists so a refusal from amico-run's --spec gate becomes a @@ -1369,6 +1400,152 @@ export const AmicodeTools = async (_input: unknown) => ({ // The policy itself (auto-accept HIGH-confidence downstream params; resource // gates always confirm; interrupt-off) is prompt-level in SCORE.md; this tool // makes the mode durable + inspectable (⚡ badge) and returns current state. + // Session spawn (amicode#639) — the FIRST tool in this pack that mutates + // server state. Everything above is local bookkeeping; this one creates + // live sessions that immediately spend model budget, so the policy (caps, + // depth, force) lives in ./session_spawn.ts and is unit-tested there. + // Children stamp metadata {spawned_by, spawned_depth}: the app's session + // route watches for that stamp and opens each child as a background tab + // in the pane showing THIS session (addSessionTab — no focus steal). + amicode_session: { + description: + "Spawn one or a few NEW chat sessions that appear as background tabs beside this one. " + + "Each child starts working on `prompt` immediately (its first turn is posted at spawn); " + + "tabs open in this session's pane without stealing focus. This is the FIRST " + + "server-mutating tool in this pack — everything else here is local bookkeeping — and " + + "each spawned session runs on the user's model budget, so fan out deliberately (hard " + + "cap " + SPAWN_MAX_COUNT + " per call). mode='fork' seeds the child from THIS session's " + + "history instead of a blank start. A session that was itself spawned cannot spawn again " + + "past depth " + SPAWN_MAX_DEPTH + " unless force=true. Do NOT use this for subagent-style " + + "work the user need not steer (use the Task tool) — sessions are for parallel or " + + "branching work the USER should see and interact with.", + args: { + prompt: { + type: "string", + description: "The first message for each spawned session — what it should work on.", + }, + count: { + type: ["integer", "null"], + description: "How many sessions to spawn (1-" + SPAWN_MAX_COUNT + "). Null = 1.", + }, + title: { + type: ["string", "null"], + description: "Tab/session title. Null = derived from the prompt.", + }, + agent: { + type: ["string", "null"], + description: "Agent for the child session (e.g. 'plan', 'build'). Null = server default.", + }, + model: { + type: ["string", "null"], + description: "'providerID/modelID' for the child. Null = this session's model.", + }, + mode: { + type: ["string", "null"], + enum: ["fresh", "fork"], + description: "fresh (blank session; default) | fork (seeded from this session's history).", + }, + force: { + type: ["boolean", "null"], + description: "Overrule the spawn-depth cap. Null = false.", + }, + }, + async execute( + a: { + prompt: string; + count?: number | null; + title?: string | null; + agent?: string | null; + model?: string | null; + mode?: string | null; + force?: boolean | null; + }, + ctx: { sessionID: string; directory: string }, + ) { + if (!engineClient) { + return "Cannot spawn: the engine did not hand this plugin a server client (legacy load path)."; + } + const parsed = parseSpawnArgs(a); + if (!parsed.ok) return `Cannot spawn: ${parsed.error}.`; + const args = parsed.args; + // Depth comes from THIS session's own stamp — never from the caller's + // claim — so the cap is enforced by construction, not by politeness. + let own: { metadata?: unknown; model?: { providerID?: string; modelID?: string } } | undefined; + try { + own = unwrap( + await engineClient.session.get({ path: { id: ctx.sessionID }, query: { directory: ctx.directory } }), + ); + } catch { + own = undefined; + } + const depth = computeDepth(own?.metadata); + if (depth >= SPAWN_MAX_DEPTH && !args.force) return depthRefusal(depth); + // Model precedence: explicit arg > this session's model > server default. + const model = + args.model ?? + (own?.model?.providerID && own?.model?.modelID + ? { providerID: own.model.providerID, modelID: own.model.modelID } + : null); + const base = args.title ?? defaultTitle(args.prompt); + const spawnMeta = { spawned_by: ctx.sessionID, spawned_depth: depth + 1 }; + const children: SpawnedChild[] = []; + try { + for (let i = 0; i < args.count; i++) { + const title = childTitle(base, i, args.count); + let id: string | undefined; + if (args.mode === "fork") { + const forked = unwrap<{ id?: string }>( + await engineClient.session.fork({ + path: { id: ctx.sessionID }, + query: { directory: ctx.directory }, + body: {}, + }), + ); + id = forked?.id; + if (id) { + // The fork endpoint carries history but not our stamp; PATCH + // metadata so the parent's route can auto-open the tab. + // Tolerated failure: the child still runs, it just won't + // auto-open — the summary below lists it either way. + await engineClient.session + .update({ path: { id }, query: { directory: ctx.directory }, body: { metadata: spawnMeta } }) + .catch(() => undefined); + } + } else { + const created = unwrap<{ id?: string }>( + await engineClient.session.create({ + query: { directory: ctx.directory }, + body: { + title, + metadata: spawnMeta, + ...(args.agent ? { agent: args.agent } : {}), + ...(model ? { model } : {}), + }, + }), + ); + id = created?.id; + } + if (!id) throw new Error(`session ${args.mode === "fork" ? "fork" : "create"} returned no id`); + await engineClient.session.promptAsync({ + path: { id }, + query: { directory: ctx.directory }, + body: { + parts: [{ type: "text", text: args.prompt }], + ...(model ? { model } : {}), + ...(args.agent ? { agent: args.agent } : {}), + }, + }); + children.push({ id, title }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (children.length > 0) return `${summarizeSpawned(children, args.mode)}\nStopped early: ${msg}`; + return `Cannot spawn: ${msg}`; + } + return summarizeSpawned(children, args.mode); + }, + }, + amicode_veloce: { description: "Turn Amico Veloce on/off, or read its state. Veloce auto-accepts HIGH-confidence " + @@ -1415,4 +1592,5 @@ export const AmicodeTools = async (_input: unknown) => ({ }, }, }, -}); + }; +}; diff --git a/packages/extension/opencode-plugin/session_spawn.ts b/packages/extension/opencode-plugin/session_spawn.ts new file mode 100644 index 00000000..078def3a --- /dev/null +++ b/packages/extension/opencode-plugin/session_spawn.ts @@ -0,0 +1,107 @@ +// ============================================================================ +// Pure logic for the `amicode_session` tool — the pack's FIRST server-mutating +// tool (amicode#639). Everything else in this plugin is local bookkeeping; +// this module holds the spawn POLICY so it is readable and unit-testable in +// one place, the same way entities.ts / problems.ts / hashes.ts are. No +// imports: it is loaded inside opencode's embedded Bun runtime by +// amicode_tools.ts, and directly by test/session_spawn.test.ts. +// +// Policy summary: +// - fan-out per call is capped (SPAWN_MAX_COUNT) — a runaway loop of live +// sessions burns real model budget; +// - spawned children stamp metadata {spawned_by, spawned_depth} so the app +// can auto-open them as background tabs in the parent's pane; +// - the depth cap (SPAWN_MAX_DEPTH) is SOFT: force=true overrules it. A +// spawned session spawning its own sessions is allowed but must be a +// deliberate choice, never an accident. +// ============================================================================ + +export const SPAWN_MAX_DEPTH = 2; +export const SPAWN_MAX_COUNT = 4; + +export type SpawnMode = "fresh" | "fork"; + +export type SpawnArgs = { + prompt: string; + count: number; + title: string | null; + agent: string | null; + model: { providerID: string; modelID: string } | null; + mode: SpawnMode; + force: boolean; +}; + +export function parseSpawnArgs(a: { + prompt: string; + count?: number | null; + title?: string | null; + agent?: string | null; + model?: string | null; + mode?: string | null; + force?: boolean | null; +}): { ok: true; args: SpawnArgs } | { ok: false; error: string } { + const prompt = typeof a.prompt === "string" ? a.prompt.trim() : ""; + if (!prompt) return { ok: false, error: "empty prompt" }; + const rawCount = typeof a.count === "number" && Number.isFinite(a.count) ? Math.floor(a.count) : 1; + const count = Math.min(Math.max(rawCount, 1), SPAWN_MAX_COUNT); + const mode: SpawnMode = a.mode === "fork" ? "fork" : "fresh"; + let model: SpawnArgs["model"] = null; + if (typeof a.model === "string" && a.model.trim() !== "") { + const slash = a.model.indexOf("/"); + if (slash <= 0 || slash === a.model.length - 1) { + return { ok: false, error: 'model must be "providerID/modelID"' }; + } + model = { providerID: a.model.slice(0, slash), modelID: a.model.slice(slash + 1) }; + } + const agent = typeof a.agent === "string" && a.agent.trim() !== "" ? a.agent.trim() : null; + const title = typeof a.title === "string" && a.title.trim() !== "" ? a.title.trim() : null; + return { ok: true, args: { prompt, count, title, agent, model, mode, force: a.force === true } }; +} + +// The calling session's own spawned_depth (absent for never-spawned sessions +// = 0). Children get depth + 1; depth >= SPAWN_MAX_DEPTH refuses without +// force. +export function computeDepth(ownMetadata: unknown): number { + const d = (ownMetadata as { spawned_depth?: unknown } | null | undefined)?.spawned_depth; + return typeof d === "number" && Number.isFinite(d) && d >= 0 ? Math.floor(d) : 0; +} + +export function depthRefusal(depth: number): string { + return ( + `Refused: this session is itself a spawned session (spawned_depth=${depth}) and the ` + + `default spawn-depth cap is ${SPAWN_MAX_DEPTH}. Pass force=true to overrule it — ` + + `sessions spawning sessions is allowed but should be a deliberate choice, not an accident.` + ); +} + +export function defaultTitle(prompt: string): string { + const flat = prompt.replace(/\s+/g, " ").trim(); + return flat.length > 42 ? `${flat.slice(0, 42)}…` : flat; +} + +export function childTitle(base: string, index: number, total: number): string { + const suffix = total > 1 ? ` (${index + 1}/${total})` : ""; + return `${base}${suffix}`; +} + +// hey-api clients return {data?, error?} when not throwing; older call shapes +// may return the payload directly. One defensive unwrap at the boundary. +export function unwrap(res: unknown): T | undefined { + if (res && typeof res === "object" && "data" in (res as Record)) { + return ((res as { data?: unknown }).data ?? undefined) as T | undefined; + } + return (res ?? undefined) as T | undefined; +} + +export type SpawnedChild = { id: string; title: string }; + +export function summarizeSpawned(children: SpawnedChild[], mode: SpawnMode): string { + if (children.length === 0) return "No sessions were spawned."; + const lines = children.map((c) => `- ${c.id}${c.title ? ` — ${c.title}` : ""}`); + const kind = mode === "fork" ? "sessions forked from this session's history" : "fresh sessions"; + return ( + `Spawned ${children.length} ${kind}. Each is already running its first turn and will ` + + `appear as a background tab beside this session (no focus change). Ids for follow-up:\n` + + lines.join("\n") + ); +} diff --git a/packages/extension/test/session_spawn.test.ts b/packages/extension/test/session_spawn.test.ts new file mode 100644 index 00000000..b7ead211 --- /dev/null +++ b/packages/extension/test/session_spawn.test.ts @@ -0,0 +1,172 @@ +// Tests for the amicode_session spawn policy (opencode-plugin/session_spawn.ts). +// The plugin module itself (amicode_tools.ts) is NOT imported here — same +// convention as amicode_tools.test.ts: the plugin file is outside the vitest +// graph on purpose; its pure logic is what carries the tests. + +import { describe, it, expect } from "vitest"; +import { + SPAWN_MAX_COUNT, + SPAWN_MAX_DEPTH, + parseSpawnArgs, + computeDepth, + depthRefusal, + defaultTitle, + childTitle, + unwrap, + summarizeSpawned, +} from "../opencode-plugin/session_spawn"; + +describe("parseSpawnArgs", () => { + it("defaults count=1, mode=fresh, force=false and trims the prompt", () => { + const r = parseSpawnArgs({ prompt: " sweep the lattice " }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.args).toEqual({ + prompt: "sweep the lattice", + count: 1, + title: null, + agent: null, + model: null, + mode: "fresh", + force: false, + }); + }); + + it("rejects an empty prompt", () => { + expect(parseSpawnArgs({ prompt: "" }).ok).toBe(false); + expect(parseSpawnArgs({ prompt: " " }).ok).toBe(false); + expect(parseSpawnArgs({ prompt: undefined as unknown as string }).ok).toBe(false); + }); + + it("clamps count into [1, SPAWN_MAX_COUNT]", () => { + const low = parseSpawnArgs({ prompt: "x", count: 0 }); + const high = parseSpawnArgs({ prompt: "x", count: 99 }); + const neg = parseSpawnArgs({ prompt: "x", count: -3 }); + expect(low.ok && high.ok && neg.ok).toBe(true); + if (low.ok && high.ok && neg.ok) { + expect(low.args.count).toBe(1); + expect(high.args.count).toBe(SPAWN_MAX_COUNT); + expect(neg.args.count).toBe(1); + } + }); + + it("floors fractional counts", () => { + const r = parseSpawnArgs({ prompt: "x", count: 2.9 }); + expect(r.ok && r.args.count === 2).toBe(true); + }); + + it("parses providerID/modelID", () => { + const r = parseSpawnArgs({ prompt: "x", model: "opencode-go/kimi-k3" }); + expect(r.ok && r.args.model?.providerID === "opencode-go" && r.args.model?.modelID === "kimi-k3").toBe(true); + }); + + it("rejects malformed model strings", () => { + expect(parseSpawnArgs({ prompt: "x", model: "noslash" }).ok).toBe(false); + expect(parseSpawnArgs({ prompt: "x", model: "/leading" }).ok).toBe(false); + expect(parseSpawnArgs({ prompt: "x", model: "trailing/" }).ok).toBe(false); + }); + + it("only accepts mode=fork as fork; everything else is fresh", () => { + const fork = parseSpawnArgs({ prompt: "x", mode: "fork" }); + const typo = parseSpawnArgs({ prompt: "x", mode: "Fork" }); + const junk = parseSpawnArgs({ prompt: "x", mode: "branch" }); + expect(fork.ok && fork.args.mode).toBe("fork"); + expect(typo.ok && typo.args.mode).toBe("fresh"); + expect(junk.ok && junk.args.mode).toBe("fresh"); + }); + + it("force only fires on the exact boolean true", () => { + const yes = parseSpawnArgs({ prompt: "x", force: true }); + const no = parseSpawnArgs({ prompt: "x", force: null }); + const weird = parseSpawnArgs({ prompt: "x", force: "yes" as unknown as boolean }); + expect(yes.ok && yes.args.force).toBe(true); + expect(no.ok && no.args.force).toBe(false); + expect(weird.ok && weird.args.force).toBe(false); + }); + + it("trims title and agent, nulling empties", () => { + const r = parseSpawnArgs({ prompt: "x", title: " CZ sweep ", agent: " " }); + expect(r.ok && r.args.title === "CZ sweep" && r.args.agent === null).toBe(true); + }); +}); + +describe("computeDepth", () => { + it("treats absent/never-spawned metadata as depth 0", () => { + expect(computeDepth(undefined)).toBe(0); + expect(computeDepth(null)).toBe(0); + expect(computeDepth({})).toBe(0); + }); + + it("reads spawned_depth from the stamp", () => { + expect(computeDepth({ spawned_depth: 1 })).toBe(1); + expect(computeDepth({ spawned_depth: 2 })).toBe(2); + }); + + it("defends against junk stamps", () => { + expect(computeDepth({ spawned_depth: "2" })).toBe(0); + expect(computeDepth({ spawned_depth: -1 })).toBe(0); + expect(computeDepth({ spawned_depth: Number.NaN })).toBe(0); + expect(computeDepth({ spawned_depth: 1.9 })).toBe(1); + }); +}); + +describe("the soft depth cap", () => { + it("refuses at SPAWN_MAX_DEPTH and the refusal names the overrule", () => { + const text = depthRefusal(SPAWN_MAX_DEPTH); + expect(text).toContain(`spawned_depth=${SPAWN_MAX_DEPTH}`); + expect(text).toContain("force=true"); + }); +}); + +describe("titles", () => { + it("derives a flattened, truncated default title", () => { + expect(defaultTitle("run\n the sweep")).toBe("run the sweep"); + const long = defaultTitle("x".repeat(80)); + expect(long.length).toBe(43); // 42 chars + ellipsis + expect(long.endsWith("…")).toBe(true); + }); + + it("suffixes only when fanning out", () => { + expect(childTitle("CZ sweep", 0, 1)).toBe("CZ sweep"); + expect(childTitle("CZ sweep", 0, 3)).toBe("CZ sweep (1/3)"); + expect(childTitle("CZ sweep", 2, 3)).toBe("CZ sweep (3/3)"); + }); +}); + +describe("unwrap", () => { + it("unwraps hey-api {data} envelopes", () => { + expect(unwrap<{ id: string }>({ data: { id: "ses_1" } })?.id).toBe("ses_1"); + expect(unwrap({ data: undefined })).toBeUndefined(); + }); + + it("passes bare payloads through", () => { + expect(unwrap<{ id: string }>({ id: "ses_2" })?.id).toBe("ses_2"); + expect(unwrap(null)).toBeUndefined(); + }); +}); + +describe("summarizeSpawned", () => { + it("returns the empty line when nothing spawned", () => { + expect(summarizeSpawned([], "fresh")).toBe("No sessions were spawned."); + }); + + it("lists ids and says the tabs are background", () => { + const text = summarizeSpawned( + [ + { id: "ses_a", title: "CZ sweep (1/2)" }, + { id: "ses_b", title: "CZ sweep (2/2)" }, + ], + "fresh", + ); + expect(text).toContain("Spawned 2"); + expect(text).toContain("ses_a"); + expect(text).toContain("ses_b"); + expect(text).toContain("background tab"); + expect(text).toContain("no focus change"); + }); + + it("says 'forked' for fork mode", () => { + const text = summarizeSpawned([{ id: "ses_c", title: "" }], "fork"); + expect(text).toContain("forked from this session's history"); + }); +});