diff --git a/.changeset/spawn-model-picker.md b/.changeset/spawn-model-picker.md new file mode 100644 index 0000000..010d0cb --- /dev/null +++ b/.changeset/spawn-model-picker.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Let agents pick a model when delegating work: `spawn_session` and `spawn_subsession` accept an optional `model` parameter as an exact `provider/model-id` (an unknown value is rejected; omitting it keeps the inherited model). In the chat composer, typing `#` opens a model completion menu that inserts a `#provider/model-id` reference into the draft, which agents forward as that parameter. diff --git a/docs/config.html b/docs/config.html index 47b2dfd..17314f4 100644 --- a/docs/config.html +++ b/docs/config.html @@ -103,6 +103,7 @@ Model catalog refresh Session tools Extension dialogs + Prompt completions Completion tools @@ -805,6 +806,15 @@ available when the child stops. Included output and transcripts follow a labeled marker and come last, after PI WEB guidance.

+

+ Both spawn_session and spawn_subsession accept an optional + model parameter, given as an exact provider/model-id such as + anthropic/claude-sonnet-4-5. When set, the new session starts on that model instead of + inheriting the dispatching session's model. The match is strict: an unknown or malformed value is + rejected with an error. A #provider/model-id reference in the prompt (see + Prompt completions) is how users ask for a specific model; agents + forward that reference as this parameter. +

In Settings → Session daemon, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them. @@ -879,6 +889,25 @@ +

+

Prompt completions

+

The chat composer opens completion menus on three trigger characters:

+ +
+

Optional completion tools

diff --git a/docs/config.md b/docs/config.md index 515f912..e9d7fe8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -273,6 +273,8 @@ A completion notice wakes an idle parent or queues behind in-flight work. Each n `list_subsessions`, `check_subsession`, and `read_subsession` never yield or change control flow. They are for deliberate inspection or recovery, not completion polling. While a child works, agent-facing `check_subsession` and `read_subsession` withhold partial output and direct the parent to continue independent work or yield at the join point. Output becomes available when the child stops. Included output and transcripts follow a labeled marker and come last, after PI WEB guidance. +Both `spawn_session` and `spawn_subsession` accept an optional `model` parameter, given as an exact `provider/model-id` such as `anthropic/claude-sonnet-4-5`. When set, the new session starts on that model instead of inheriting the dispatching session's model. The match is strict: an unknown or malformed value is rejected with an error. A `#provider/model-id` reference in the prompt (see [Prompt completions](#prompt-completions)) is how users ask for a specific model; agents forward that reference as this parameter. + In **Settings → Session daemon**, these keys are saved on the selected machine. Restart the session daemon on that machine after changing them. #### `askUser` and `ask_user` @@ -331,6 +333,14 @@ Shortcut values are keyed by action id. Values are shortcut strings such as `mod Prefer Settings → Keyboard for editing shortcuts interactively. +## Prompt completions + +The chat composer opens completion menus on three trigger characters: + +- `/` at the very start of the draft completes session commands. +- `@` completes file paths: `@` for tracked files, `@ ` (at, then space) or `!@` for all files. Picking one inserts an `@path` reference into the draft, quoted automatically when the path contains spaces. +- `#` completes the models available to the session, filtered case-insensitively as you type (at most 12 entries). Picking one inserts a `#provider/model-id` reference into the draft, which tells agents the request should run on that model — for example as the `model` parameter of `spawn_session`. + ## Optional completion tools File and path `@` completions work without extra tools. If `fzf` is available on the PI WEB server's `PATH`, PI WEB uses it to improve completion filtering/ranking; otherwise it falls back to built-in ranking. diff --git a/src/client/src/components/PromptEditor.modelCompletion.test.ts b/src/client/src/components/PromptEditor.modelCompletion.test.ts new file mode 100644 index 0000000..9a15f91 --- /dev/null +++ b/src/client/src/components/PromptEditor.modelCompletion.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { api, type SessionModel } from "../api"; +import { PromptEditor } from "./PromptEditor"; + +const sonnet: SessionModel = { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }; +const gpt: SessionModel = { provider: "openai", id: "gpt-5.2" }; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PromptEditor model completions", () => { + it("requests models for the session and maps a # token to model completions", async () => { + const models = vi.spyOn(api, "models").mockResolvedValue({ models: [sonnet, gpt] }); + const editor = new PromptEditor(); + editor.sessionId = "session-1"; + editor.cwd = "/repo"; + editor.machineId = "remote-a"; + + await refreshCompletions(editor, "please use #cla"); + + expect(models).toHaveBeenCalledWith({ id: "session-1", cwd: "/repo" }, "remote-a"); + expect(currentCompletions(editor)).toEqual([ + { kind: "model", replaceFrom: 11, replaceTo: 15, insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" }, + ]); + expect(Reflect.get(editor, "selectedIndex")).toBe(0); + }); + + it("lists every model for a bare # token", async () => { + vi.spyOn(api, "models").mockResolvedValue({ models: [sonnet, gpt] }); + const editor = new PromptEditor(); + editor.sessionId = "session-1"; + editor.cwd = "/repo"; + + await refreshCompletions(editor, "#"); + + expect(currentCompletions(editor)).toEqual([ + { kind: "model", replaceFrom: 0, replaceTo: 1, insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" }, + { kind: "model", replaceFrom: 0, replaceTo: 1, insertText: "#openai/gpt-5.2", detail: "openai" }, + ]); + }); + + it("clears completions when the models request fails", async () => { + vi.spyOn(api, "models").mockRejectedValue(new Error("models unavailable")); + const editor = new PromptEditor(); + editor.sessionId = "session-1"; + editor.cwd = "/repo"; + + await refreshCompletions(editor, "#cla"); + + expect(currentCompletions(editor)).toEqual([]); + }); + + it("does not request models without session context", async () => { + const models = vi.spyOn(api, "models").mockResolvedValue({ models: [sonnet] }); + const editor = new PromptEditor(); + + await refreshCompletions(editor, "#cla"); + + expect(models).not.toHaveBeenCalled(); + expect(currentCompletions(editor)).toEqual([]); + }); +}); + +// refreshCompletions is private and driven by CodeMirror updates in production; +// invoking it through Reflect mirrors the PromptEditor.draft.test.ts seam and +// keeps the wiring test at the component boundary without a DOM harness. +async function refreshCompletions(editor: PromptEditor, draft: string): Promise { + Reflect.set(editor, "draft", draft); + const refresh: unknown = Reflect.get(editor, "refreshCompletions"); + if (!isRefreshCompletions(refresh)) throw new Error("PromptEditor.refreshCompletions is not callable"); + await refresh.call(editor); +} + +function isRefreshCompletions(value: unknown): value is (this: PromptEditor) => Promise { + return typeof value === "function"; +} + +function currentCompletions(editor: PromptEditor): unknown { + return Reflect.get(editor, "completions"); +} diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index d504c40..68f7493 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -5,12 +5,12 @@ import { EditorView, keymap, placeholder } from "@codemirror/view"; import { defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language"; import { LitElement, html, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; -import { api, type FileSuggestion, type PromptAttachment, type SessionStatus, type SlashCommand } from "../api"; +import { api, type FileSuggestion, type PromptAttachment, type SessionModel, type SessionStatus, type SlashCommand } from "../api"; import type { PromptAttachmentDelivery } from "../../../shared/apiTypes"; import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture"; import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes"; import { machineSessionKey } from "../machineKeys"; -import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; +import { detectPromptCompletionTrigger, fileCompletionInsertText, modelCompletionChoices, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior"; @@ -283,7 +283,7 @@ export class PromptEditor extends LitElement { keyup: (event) => this.handleEditorKeyUp(event), blur: () => this.resetEditorModifierState(), }), - placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"), + placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files, # for models"), this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), EditorView.updateListener.of((update) => { @@ -372,6 +372,15 @@ export class PromptEditor extends LitElement { ...(file.path.endsWith("/") && insertText.endsWith("\"") ? { cursorOffset: insertText.length - 1 } : {}), }; }); + } else if (trigger.kind === "model" && this.sessionId !== undefined && this.sessionId !== "" && this.cwd !== undefined && this.cwd !== "") { + const models = await api.models({ id: this.sessionId, cwd: this.cwd }, this.machineId).then((response) => response.models).catch(emptySessionModels); + if (version !== this.requestVersion) return; + this.completions = modelCompletionChoices(models, trigger.query).map((choice) => ({ + kind: "model", + replaceFrom: trigger.from, + replaceTo: trigger.to, + ...choice, + })); } } @@ -516,6 +525,10 @@ function emptyFileSuggestions(): FileSuggestion[] { return []; } +function emptySessionModels(): SessionModel[] { + return []; +} + function filesFromDataTransfer(data: DataTransfer | null): File[] { if (data === null) return []; return Array.from(data.files); diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 41b93c4..67d48f3 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -72,7 +72,7 @@ export interface ChatLine { } export interface CompletionItem { - kind: "command" | "file"; + kind: "command" | "file" | "model"; replaceFrom: number; replaceTo: number; insertText: string; diff --git a/src/client/src/inputModes.test.ts b/src/client/src/inputModes.test.ts index 20e84df..ac0380f 100644 --- a/src/client/src/inputModes.test.ts +++ b/src/client/src/inputModes.test.ts @@ -31,4 +31,11 @@ describe("input mode helpers", () => { expect(inputModeForDraft("open !@\"vendor/file.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open \"src/main.ts")).toEqual({ kind: "normal" }); }); + + it("collapses # model completion tokens to model mode", () => { + expect(inputModeForDraft("#anthropic/claude-opus")).toEqual({ kind: "model" }); + expect(inputModeForDraft("use #gpt-5.2")).toEqual({ kind: "model" }); + expect(inputModeForDraft("use #gpt-5.2 please")).toEqual({ kind: "normal" }); + expect(inputModeForDraft("# ")).toEqual({ kind: "normal" }); + }); }); diff --git a/src/client/src/inputModes.ts b/src/client/src/inputModes.ts index bfeb755..afe06d2 100644 --- a/src/client/src/inputModes.ts +++ b/src/client/src/inputModes.ts @@ -4,6 +4,7 @@ export type InputMode = | { kind: "normal" } | { kind: "command" } | { kind: "file" } + | { kind: "model" } | { kind: "shell"; excludeFromContext: boolean }; export function inputModeForDraft(draft: string): InputMode { @@ -11,7 +12,9 @@ export function inputModeForDraft(draft: string): InputMode { if (trimmed.startsWith("!@")) return { kind: "file" }; if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") }; if (currentToken(draft).startsWith("/")) return { kind: "command" }; - if (detectPromptCompletionTrigger(draft)?.kind === "file") return { kind: "file" }; + const trigger = detectPromptCompletionTrigger(draft); + if (trigger?.kind === "file") return { kind: "file" }; + if (trigger?.kind === "model") return { kind: "model" }; return { kind: "normal" }; } diff --git a/src/client/src/promptCompletions.test.ts b/src/client/src/promptCompletions.test.ts index 59bb8ed..1cb1989 100644 --- a/src/client/src/promptCompletions.test.ts +++ b/src/client/src/promptCompletions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { detectPromptCompletionTrigger, fileCompletionInsertText } from "./promptCompletions"; +import type { SessionModel } from "../../shared/apiTypes"; +import { detectPromptCompletionTrigger, fileCompletionInsertText, modelCompletionChoices } from "./promptCompletions"; describe("detectPromptCompletionTrigger", () => { it("keeps all-file suggestions active when an @ space query contains spaces", () => { @@ -54,6 +55,73 @@ describe("detectPromptCompletionTrigger", () => { }); expect(detectPromptCompletionTrigger("/model")).toEqual({ kind: "command", query: "model", from: 0, to: 6 }); }); + + it("detects model queries for tokens starting with #", () => { + expect(detectPromptCompletionTrigger("#")).toEqual({ kind: "model", query: "", from: 0, to: 1 }); + expect(detectPromptCompletionTrigger("use #anthropic/claude-opus")).toEqual({ kind: "model", query: "anthropic/claude-opus", from: 4, to: 26 }); + }); + + it("detects model queries at any cursor position but not inside other tokens or quotes", () => { + expect(detectPromptCompletionTrigger("use #claude now", 10)).toEqual({ kind: "model", query: "claud", from: 4, to: 10 }); + expect(detectPromptCompletionTrigger("say hello#world")).toBeUndefined(); + expect(detectPromptCompletionTrigger('say "#claude')).toBeUndefined(); + }); + + it("shows model completion for a markdown header only on the bare # keystroke", () => { + expect(detectPromptCompletionTrigger("#")).toEqual({ kind: "model", query: "", from: 0, to: 1 }); + expect(detectPromptCompletionTrigger("# ")).toBeUndefined(); + expect(detectPromptCompletionTrigger("# Title")).toBeUndefined(); + }); +}); + +describe("modelCompletionChoices", () => { + const models: SessionModel[] = [ + { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" }, + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, + { provider: "google", id: "gemini-3-pro" }, + ]; + + it("lists all models for an empty query, mapped to #provider/id insert texts", () => { + expect(modelCompletionChoices(models, "")).toEqual([ + { insertText: "#anthropic/claude-opus-4-5", detail: "anthropic", description: "Claude Opus 4.5" }, + { insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" }, + { insertText: "#openai/gpt-5.2", detail: "openai", description: "GPT-5.2" }, + { insertText: "#google/gemini-3-pro", detail: "google" }, + ]); + }); + + it("filters case-insensitively across provider/id, id, and display name", () => { + expect(modelCompletionChoices(models, "OPUS-4")).toEqual([ + { insertText: "#anthropic/claude-opus-4-5", detail: "anthropic", description: "Claude Opus 4.5" }, + ]); + expect(modelCompletionChoices(models, "openai")).toEqual([ + { insertText: "#openai/gpt-5.2", detail: "openai", description: "GPT-5.2" }, + ]); + expect(modelCompletionChoices(models, "sonnet 4.5")).toEqual([ + { insertText: "#anthropic/claude-sonnet-4-5", detail: "anthropic", description: "Claude Sonnet 4.5" }, + ]); + }); + + it("omits the description when the display name matches the id", () => { + expect(modelCompletionChoices([{ provider: "ollama", id: "qwen3", name: "qwen3" }], "")).toEqual([ + { insertText: "#ollama/qwen3", detail: "ollama" }, + ]); + }); + + it("skips models without a provider or id since they cannot form a #provider/id reference", () => { + expect(modelCompletionChoices([{ id: "orphan" }, { provider: "ghost" }, { provider: "openai", id: "gpt-5.2" }], "")).toEqual([ + { insertText: "#openai/gpt-5.2", detail: "openai" }, + ]); + }); + + it("caps the list at 12 models, preserving server order", () => { + const many = Array.from({ length: 20 }, (_, index) => ({ provider: "p", id: `m${String(index)}` })); + const choices = modelCompletionChoices(many, ""); + expect(choices).toHaveLength(12); + expect(choices[0]?.insertText).toBe("#p/m0"); + expect(choices[11]?.insertText).toBe("#p/m11"); + }); }); describe("fileCompletionInsertText", () => { diff --git a/src/client/src/promptCompletions.ts b/src/client/src/promptCompletions.ts index 1a0fc7b..c18742d 100644 --- a/src/client/src/promptCompletions.ts +++ b/src/client/src/promptCompletions.ts @@ -1,6 +1,9 @@ +import type { SessionModel } from "../../shared/apiTypes"; + export type PromptCompletionTrigger = | { kind: "command"; query: string; from: number; to: number } - | { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean }; + | { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean } + | { kind: "model"; query: string; from: number; to: number }; export function detectPromptCompletionTrigger(draft: string, cursor = draft.length): PromptCompletionTrigger | undefined { const beforeCursor = draft.slice(0, cursor); @@ -17,9 +20,46 @@ export function detectPromptCompletionTrigger(draft: string, cursor = draft.leng if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor }; if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" }; if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" }; + if (token.startsWith("#")) return { kind: "model", query: token.slice(1), from: tokenStart, to: cursor }; return undefined; } +export interface ModelCompletionChoice { + insertText: string; + detail: string; + description?: string; +} + +const MODEL_COMPLETION_LIMIT = 12; + +export function modelCompletionChoices(models: readonly SessionModel[], query: string): ModelCompletionChoice[] { + const needle = query.toLowerCase(); + const choices: ModelCompletionChoice[] = []; + for (const model of models) { + // A completion must produce a strict provider/model-id reference, so models + // missing either half of the identity can never be inserted. + if (!hasQualifiedModelId(model)) continue; + if (!modelMatchesQuery(model, needle)) continue; + choices.push({ + insertText: `#${model.provider}/${model.id}`, + detail: model.provider, + ...(model.name !== undefined && model.name !== "" && model.name !== model.id ? { description: model.name } : {}), + }); + if (choices.length >= MODEL_COMPLETION_LIMIT) break; + } + return choices; +} + +function hasQualifiedModelId(model: SessionModel): model is SessionModel & { provider: string; id: string } { + return typeof model.provider === "string" && model.provider !== "" && typeof model.id === "string" && model.id !== ""; +} + +function modelMatchesQuery(model: SessionModel & { provider: string; id: string }, needle: string): boolean { + return `${model.provider}/${model.id}`.toLowerCase().includes(needle) + || model.id.toLowerCase().includes(needle) + || (model.name?.toLowerCase().includes(needle) ?? false); +} + export function fileCompletionInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string { const prefix = allPrefix ?? "@"; if (!quoted && !path.includes(" ")) return `${prefix}${path}`; diff --git a/src/server/sessions/piSessionService.delegationTools.test.ts b/src/server/sessions/piSessionService.delegationTools.test.ts index 247b3d3..4cb4854 100644 --- a/src/server/sessions/piSessionService.delegationTools.test.ts +++ b/src/server/sessions/piSessionService.delegationTools.test.ts @@ -1,6 +1,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createPiWebCustomToolDefinitions, sessionAllowsDelegationTools, type PiSessionManager } from "./piSessionService.js"; import type { SubsessionToolDeps } from "./spawnSubsessionTool.js"; @@ -14,13 +15,14 @@ afterEach(async () => { function delegationDeps() { const spawn = vi.fn(() => Promise.resolve({ sessionId: "independent-1", cwd: "/workspace" })); + const subsessionSpawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })); const subsessions: SubsessionToolDeps = { - spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })), + spawn: subsessionSpawn, list: vi.fn(() => Promise.resolve([])), check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })), read: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })), }; - return { spawn, subsessions }; + return { spawn, subsessions, subsessionSpawn }; } function toolNames(definitions: ReturnType): string[] { @@ -35,6 +37,20 @@ function manager(id: string, file: string | undefined, entries: readonly unknown }); } +const dispatchModel = { provider: "anthropic", id: "claude-sonnet" }; + +function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext { + // The delegation tools only read sessionManager and model from the context. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools read. + return { sessionManager: manager(sessionId, sessionFile), ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext; +} + +function findTool(definitions: ReturnType, name: string) { + const tool = definitions.find((definition) => definition.name === name); + if (tool === undefined) throw new Error(`missing tool ${name}`); + return tool; +} + describe("delegation tool capability boundary", () => { it("provides every globally enabled delegation tool to unrestricted sessions", () => { const { spawn, subsessions } = delegationDeps(); @@ -63,6 +79,39 @@ describe("delegation tool capability boundary", () => { expect(toolNames(createPiWebCustomToolDefinitions("/workspace", false, spawn, subsessions))).toEqual(["edit"]); }); + it("wires the dispatching session identity, inherited model, and model spec into spawn_session", async () => { + const { spawn, subsessions } = delegationDeps(); + const spawnTool = findTool(createPiWebCustomToolDefinitions("/workspace", true, spawn, subsessions), "spawn_session"); + + await spawnTool.execute("call-1", { prompt: "go", model: "openai/gpt-5" }, undefined, undefined, ctxFor("spawner-7", "/sessions/spawner-7.jsonl", dispatchModel)); + + expect(spawn).toHaveBeenCalledWith({ + spawningCwd: "/workspace", + spawningSessionId: "spawner-7", + prompt: "go", + cwd: undefined, + model: dispatchModel, + modelSpec: "openai/gpt-5", + }); + }); + + it("wires the parent identity, inherited model, and model spec into spawn_subsession", async () => { + const { spawn, subsessions, subsessionSpawn } = delegationDeps(); + const spawnTool = findTool(createPiWebCustomToolDefinitions("/workspace", true, spawn, subsessions), "spawn_subsession"); + + await spawnTool.execute("call-2", { prompt: "go", model: "openai/gpt-5" }, undefined, undefined, ctxFor("parent-9", "/sessions/parent-9.jsonl", dispatchModel)); + + expect(subsessionSpawn).toHaveBeenCalledWith({ + spawningCwd: "/workspace", + parentSessionId: "parent-9", + parentSessionFile: "/sessions/parent-9.jsonl", + prompt: "go", + cwd: undefined, + model: dispatchModel, + modelSpec: "openai/gpt-5", + }); + }); + it.each(["human-created", "spawn_session-created"])("allows delegation for a %s session without tracked-child provenance", async () => { const sessionManager = manager("session-1", undefined); const open = vi.fn(() => { throw new Error("no parent session should be opened"); }); diff --git a/src/server/sessions/piSessionService.spawnSession.test.ts b/src/server/sessions/piSessionService.spawnSession.test.ts index 0d10106..439f724 100644 --- a/src/server/sessions/piSessionService.spawnSession.test.ts +++ b/src/server/sessions/piSessionService.spawnSession.test.ts @@ -4,6 +4,7 @@ import type { SpawnTargetDecision } from "./spawnTargetResolver.js"; import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.js"; const TEST_AGENT_DIR = "/tmp/pi-web-test-agent"; +const TEST_MODEL_SPEC = "anthropic/claude-sonnet-4-5-20250929"; describe("PiSessionService", () => { describe("spawnSession", () => { @@ -25,7 +26,7 @@ describe("PiSessionService", () => { it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => { const { fake, service, log } = spawnService({ allowed: true, cwd: "/workspace-feature" }); - const result = await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue the plan", cwd: "/workspace-feature" }); + const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue the plan", cwd: "/workspace-feature" }); expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" }); expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]); @@ -53,17 +54,34 @@ describe("PiSessionService", () => { heartbeatIntervalMs: 60_000, }); - await service.spawnSession({ spawningCwd: "/workspace", prompt: "continue", cwd: "/workspace-feature", model }); + await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature", model }); expect(initialModel).toBe(model); expect(delegationToolsEnabled).toBe(true); await service.dispose(); }); + it("names the spawned session's model in the result", async () => { + const spawned = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl", model: testModel() }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime: runtimeCreator(spawned.runtime), + sessionManager: sessionGateway([]), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature" }); + + expect(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature", model: TEST_MODEL_SPEC }); + await service.dispose(); + }); + it("rejects an out-of-project target without starting a session", async () => { const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] }); - await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: "/elsewhere" })) + await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/elsewhere" })) .rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace"); expect(fake.calls.prompt).toEqual([]); expect(service.activeCount()).toBe(0); @@ -73,7 +91,7 @@ describe("PiSessionService", () => { it("rejects when the spawning session is not in a registered project", async () => { const { service } = spawnService({ allowed: false, reason: "not-registered" }); - await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) + await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: undefined })) .rejects.toThrow("Spawning session is not in a registered project"); await service.dispose(); }); @@ -88,9 +106,120 @@ describe("PiSessionService", () => { heartbeatIntervalMs: 60_000, }); - await expect(service.spawnSession({ spawningCwd: "/workspace", prompt: "go", cwd: undefined })) + await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: undefined })) .rejects.toThrow("Spawning sessions is disabled"); await service.dispose(); }); + + describe("model spec resolution", () => { + /** + * Harness: the spawner comes online via `service.start`, then the spawn + * creates the next queued runtime. `initialModels` records every + * creation-time model so tests can see exactly what the spawned session + * was started with. + */ + function specService(spawnerPatch: Parameters[1] = {}) { + const spawner = fakeRuntime("spawner-1", { sessionFile: "/tmp/spawner-1.jsonl", ...spawnerPatch }); + const spawned = fakeRuntime("spawned-2", { sessionFile: "/tmp/spawned-2.jsonl", model: testModel() }); + const initialModels: PiAgentSession["model"][] = []; + const runtimes = [spawner.runtime, spawned.runtime]; + let index = 0; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { + await Promise.resolve(); + initialModels.push(options.initialModel); + const runtime = runtimes[index] ?? spawned.runtime; + index += 1; + return runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime, + sessionManager: sessionGateway([]), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + return { service, spawner, spawned, initialModels }; + } + + it("resolves the spec against the spawning session's scoped models and names it in the result", async () => { + const scoped = testModel(); + const { service, initialModels } = specService({ scopedModels: [{ model: scoped }] }); + await service.start("/workspace"); + + const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: TEST_MODEL_SPEC }); + + expect(initialModels).toHaveLength(2); + expect(initialModels[0]).toBeUndefined(); + expect(initialModels[1]).toBe(scoped); + expect(result).toEqual({ sessionId: "spawned-2", cwd: "/workspace-feature", model: TEST_MODEL_SPEC }); + await service.dispose(); + }); + + it("falls back to a direct runtime lookup when the spec is not among the available candidates", async () => { + // The shared test runtime has no configured auth, so its available + // snapshot is empty; only the getModel fallback can resolve the spec. + const { service, initialModels } = specService(); + await service.start("/workspace"); + + const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: TEST_MODEL_SPEC }); + + expect(initialModels[1]).toMatchObject({ provider: "anthropic", id: "claude-sonnet-4-5-20250929" }); + expect(result).toEqual({ sessionId: "spawned-2", cwd: "/workspace-feature", model: TEST_MODEL_SPEC }); + await service.dispose(); + }); + + it.each(["no-slash", "anthropic/", "/id"])("rejects the malformed spec %s without starting a session", async (modelSpec) => { + const { service, spawned, initialModels } = specService({ scopedModels: [{ model: testModel() }] }); + await service.start("/workspace"); + + await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec })) + .rejects.toThrow(`Unknown model "${modelSpec}". Pass an exact "provider/model-id".`); + expect(initialModels).toEqual([undefined]); + expect(spawned.calls.prompt).toEqual([]); + expect(service.activeCount()).toBe(1); + await service.dispose(); + }); + + it("rejects an unknown spec without starting a session", async () => { + const { service, spawned, initialModels } = specService({ scopedModels: [{ model: testModel() }] }); + await service.start("/workspace"); + + await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: "anthropic/does-not-exist" })) + .rejects.toThrow('Unknown model "anthropic/does-not-exist". Pass an exact "provider/model-id".'); + expect(initialModels).toEqual([undefined]); + expect(spawned.calls.prompt).toEqual([]); + expect(service.activeCount()).toBe(1); + await service.dispose(); + }); + + it("rejects an unknown spec even when the spawning session has no available models", async () => { + const { service } = specService(); + await service.start("/workspace"); + + await expect(service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "go", cwd: "/workspace-feature", modelSpec: "ghost/model" })) + .rejects.toThrow('Unknown model "ghost/model". Pass an exact "provider/model-id".'); + await service.dispose(); + }); + + it("does not look up the spawning session when no model spec is given", async () => { + const spawned = fakeRuntime("spawned-2", { sessionFile: "/tmp/spawned-2.jsonl", model: testModel() }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime: runtimeCreator(spawned.runtime), + sessionManager: sessionGateway([]), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + // "ghost" is not a resolvable session, and the default path must not care. + const result = await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "ghost", prompt: "go", cwd: "/workspace-feature" }); + + expect(result).toEqual({ sessionId: "spawned-2", cwd: "/workspace-feature", model: TEST_MODEL_SPEC }); + expect(spawned.calls.prompt).toEqual([{ text: "go", options: undefined }]); + await service.dispose(); + }); + }); }); }); diff --git a/src/server/sessions/piSessionService.spawnSubsession.test.ts b/src/server/sessions/piSessionService.spawnSubsession.test.ts index 13177eb..4a43468 100644 --- a/src/server/sessions/piSessionService.spawnSubsession.test.ts +++ b/src/server/sessions/piSessionService.spawnSubsession.test.ts @@ -98,6 +98,38 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("resolves a model spec against the parent's models and names it in the result", async () => { + const scoped = testModel(); + const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl", scopedModels: [{ model: scoped }] }); + const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature"), model: scoped }); + const initialModels: PiAgentSession["model"][] = []; + const runtimes = [parent.runtime, child.runtime]; + let index = 0; + const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { + await Promise.resolve(); + initialModels.push(options.initialModel); + const runtime = runtimes[index] ?? child.runtime; + index += 1; + return runtime; + }; + const service = new PiSessionService(new CapturingSessionEventHub(), { + agentDir: TEST_AGENT_DIR, + modelRuntime: testModelRuntime, + createAgentRuntime, + sessionManager: sessionGateway([]), + archiveStore: emptyArchiveStore(), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + const result = await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", modelSpec: "anthropic/claude-sonnet-4-5-20250929" }); + + expect(initialModels[1]).toBe(scoped); + expect(result).toEqual({ sessionId: "child-1", cwd: "/workspace-feature", model: "anthropic/claude-sonnet-4-5-20250929" }); + await service.dispose(); + }); + it("persists tracked child links in the parent and child sessions", async () => { const parentPersisted: { customType: string; data?: unknown }[] = []; const childPersisted: { customType: string; data?: unknown }[] = []; diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index f8f7552..d85de27 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -120,6 +120,30 @@ function spawnTargetError(decision: Extract { + await session.modelRuntime.refresh(); + return session.scopedModels.length > 0 + ? session.scopedModels.map((scoped) => scoped.model) + : session.modelRuntime.getAvailableSnapshot(); + } + + /** + * Resolve a strict `provider/model-id` spec from a spawn tool against the + * *spawning* session's model runtime, using the same candidates + * {@link setModel} offers plus a direct runtime lookup as fallback. Unknown + * or malformed specs throw; the agent loop turns that into an error tool + * result the spawning agent can retry from. + */ + private async resolveSpawnModel(spawningSessionId: string, modelSpec: string): Promise { + const session = await this.getOrOpen(spawningSessionId); + const parsed = parseModelSpec(modelSpec); + const candidates = await this.sessionModelCandidates(session); + const model = parsed === undefined + ? undefined + : candidates.find((candidate) => candidate.provider === parsed.provider && candidate.id === parsed.modelId) + ?? session.modelRuntime.getModel(parsed.provider, parsed.modelId); + if (model === undefined) throw unknownSpawnModelError(modelSpec); + return model; } /** @@ -1822,10 +1897,7 @@ export class PiSessionService implements SessionRouteService { async availableModels(ref: PiSessionLookup): Promise { const session = await this.getOrOpen(ref); - await session.modelRuntime.refresh(); - const models = session.scopedModels.length > 0 - ? session.scopedModels.map((scoped) => scoped.model) - : session.modelRuntime.getAvailableSnapshot(); + const models = await this.sessionModelCandidates(session); return models.map(modelToClientModel); } @@ -1833,11 +1905,8 @@ export class PiSessionService implements SessionRouteService { await this.assertWritable(ref); const session = await this.getOrOpen(ref); this.assertTreeNavigationInactive(session, "change models"); - await session.modelRuntime.refresh(); + const candidates = await this.sessionModelCandidates(session); this.assertTreeNavigationInactive(session, "change models"); - const candidates = session.scopedModels.length > 0 - ? session.scopedModels.map((scoped) => scoped.model) - : session.modelRuntime.getAvailableSnapshot(); const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId) ?? session.modelRuntime.getModel(provider, modelId); if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`); diff --git a/src/server/sessions/spawnSessionTool.test.ts b/src/server/sessions/spawnSessionTool.test.ts index f2fa1c6..58dc660 100644 --- a/src/server/sessions/spawnSessionTool.test.ts +++ b/src/server/sessions/spawnSessionTool.test.ts @@ -2,20 +2,23 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js"; -// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads. -const ctx = {} as ExtensionContext; const dispatchModel = { provider: "anthropic", id: "claude-sonnet" }; -// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads. -const ctxWithModel = { model: dispatchModel } as ExtensionContext; + +function ctxFor(sessionId: string, model?: unknown): ExtensionContext { + const sessionManager = { getSessionId: () => sessionId }; + // The spawn tool only reads sessionManager.getSessionId and model. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads. + return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext; +} describe("createSpawnSessionToolDefinition", () => { - it("passes the spawning cwd, explicit cwd, dispatching model, and prompt to spawn callback", async () => { + it("passes the spawning identity, explicit cwd, dispatching model, and prompt to spawn callback", async () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" })); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); - const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxWithModel); + const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("spawner-1", dispatchModel)); - expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel }); + expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "do the thing", cwd: "/repos/a-feature", model: dispatchModel }); expect(result.details).toEqual({ sessionId: "new-1", cwd: "/repos/a-feature" }); expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-1 in /repos/a-feature." }); }); @@ -32,16 +35,45 @@ describe("createSpawnSessionToolDefinition", () => { const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" })); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); - await tool.execute("call-2", { prompt: "continue" }, undefined, undefined, ctx); + await tool.execute("call-2", { prompt: "continue" }, undefined, undefined, ctxFor("spawner-1")); - expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", prompt: "continue", cwd: undefined }); + expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", prompt: "continue", cwd: undefined }); + }); + + it("forwards an explicit model as a model spec alongside the inherited model", async () => { + const spawn = vi.fn(() => Promise.resolve({ sessionId: "new-3", cwd: "/repos/a", model: "openai/gpt-5" })); + const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); + + const result = await tool.execute("call-3", { prompt: "continue", model: "openai/gpt-5" }, undefined, undefined, ctxFor("spawner-1", dispatchModel)); + + expect(spawn).toHaveBeenCalledWith({ + spawningCwd: "/repos/a", + spawningSessionId: "spawner-1", + prompt: "continue", + cwd: undefined, + model: dispatchModel, + modelSpec: "openai/gpt-5", + }); + expect(result.details).toEqual({ sessionId: "new-3", cwd: "/repos/a", model: "openai/gpt-5" }); + expect(result.content[0]).toMatchObject({ type: "text", text: "Started independent session new-3 in /repos/a using model openai/gpt-5." }); + }); + + it("teaches the model parameter format and the #provider/model-id reference convention", () => { + const tool = createSpawnSessionToolDefinition("/repos/a", { spawn: vi.fn() }); + + expect(tool.parameters).toMatchObject({ + properties: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- stringMatching yields `any` against the loosely typed tool schema. + model: { description: expect.stringMatching(/provider\/model-id.*#provider\/model-id.*Omit to inherit/s) }, + }, + }); }); it("propagates the spawn callback error so the agent loop reports it", async () => { const spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a"))); const tool = createSpawnSessionToolDefinition("/repos/a", { spawn }); - await expect(tool.execute("call-4", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctx)) + await expect(tool.execute("call-4", { prompt: "x", cwd: "/elsewhere" }, undefined, undefined, ctxFor("spawner-1"))) .rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a"); }); }); diff --git a/src/server/sessions/spawnSessionTool.ts b/src/server/sessions/spawnSessionTool.ts index aad33ee..45c81e5 100644 --- a/src/server/sessions/spawnSessionTool.ts +++ b/src/server/sessions/spawnSessionTool.ts @@ -4,16 +4,22 @@ import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-age export interface SpawnSessionResult { sessionId: string; cwd: string; + /** Model the spawned session runs with, as `provider/id`; absent when unknown. */ + model?: string; } export type SpawnSessionModel = NonNullable; export interface SpawnSessionInvocation { spawningCwd: string; + /** Id of the dispatching session; used to resolve {@link modelSpec} against its model runtime. */ + spawningSessionId: string; prompt: string; cwd: string | undefined; /** Current model from the dispatching session, used as the spawned session's default. */ model?: SpawnSessionModel; + /** Strict `provider/model-id` requested by the dispatcher; overrides {@link model} when set. */ + modelSpec?: string; } export interface SpawnSessionToolDeps { @@ -29,6 +35,9 @@ const SpawnSessionParams = Type.Object({ cwd: Type.Optional(Type.String({ description: "Working directory for the new session. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.", })), + model: Type.Optional(Type.String({ + description: 'Model for the new session, as an exact "provider/model-id" such as "anthropic/claude-sonnet-4-5". When the user references a model as #provider/model-id in their request, forward it here. An unknown value is rejected. Omit to inherit this session\'s model.', + })), }); /** @@ -50,12 +59,15 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw // valid workspace) rather than crash. const result = await deps.spawn({ spawningCwd, + spawningSessionId: ctx.sessionManager.getSessionId(), prompt: params.prompt, cwd: params.cwd, ...(ctx.model === undefined ? {} : { model: ctx.model }), + ...(params.model === undefined ? {} : { modelSpec: params.model }), }); + const modelNote = result.model === undefined ? "" : ` using model ${result.model}`; return { - content: [{ type: "text", text: `Started independent session ${result.sessionId} in ${result.cwd}.` }], + content: [{ type: "text", text: `Started independent session ${result.sessionId} in ${result.cwd}${modelNote}.` }], details: result, }; }, diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts index bf25ccb..da1f221 100644 --- a/src/server/sessions/spawnSubsessionTool.test.ts +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -112,6 +112,36 @@ describe("createSubsessionToolDefinitions", () => { }); }); + it("spawn_subsession forwards an explicit model as a model spec and names the model used", async () => { + const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-3", cwd: "/repos/a", model: "openai/gpt-5" })); + const { spawn: spawnTool } = tools({ spawn }); + + const result = await spawnTool.execute("call-model", { prompt: "do it", model: "openai/gpt-5" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel)); + + expect(spawn).toHaveBeenCalledWith({ + spawningCwd: "/repos/a", + parentSessionId: "parent-1", + parentSessionFile: "/sessions/parent-1.jsonl", + prompt: "do it", + cwd: undefined, + model: dispatchModel, + modelSpec: "openai/gpt-5", + }); + expect(result.details).toEqual({ sessionId: "child-3", cwd: "/repos/a", model: "openai/gpt-5" }); + expect(firstText(result.content)).toBe("Started tracked subsession child-3 in /repos/a using model openai/gpt-5. Continue other work, then join with yield_to_subsessions; do not poll."); + }); + + it("spawn_subsession teaches the model parameter format and the #provider/model-id reference convention", () => { + const { spawn: spawnTool } = tools({}); + + expect(spawnTool.parameters).toMatchObject({ + properties: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- stringMatching yields `any` against the loosely typed tool schema. + model: { description: expect.stringMatching(/provider\/model-id.*#provider\/model-id.*Omit to inherit/s) }, + }, + }); + }); + it("list_subsessions reports the caller's subsessions and their status", async () => { const list = vi.fn(() => Promise.resolve([ { sessionId: "child-1", cwd: "/repos/a", status: "working" as const }, diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index 0f9aec8..173679b 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -8,6 +8,8 @@ export type SubsessionStatus = "working" | "idle" | "error" | "unknown"; export interface SpawnSubsessionResult { sessionId: string; cwd: string; + /** Model the child session runs with, as `provider/id`; absent when unknown. */ + model?: string; } export type SpawnSubsessionModel = NonNullable; @@ -23,6 +25,8 @@ export interface SpawnSubsessionInvocation { cwd: string | undefined; /** Current model from the dispatching session, used as the spawned session's default. */ model?: SpawnSubsessionModel; + /** Strict `provider/model-id` requested by the parent; overrides {@link model} when set. */ + modelSpec?: string; } export interface SubsessionSummary { @@ -72,6 +76,9 @@ const SpawnSubsessionParams = Type.Object({ cwd: Type.Optional(Type.String({ description: "Child workspace in the same project (worktree or root); defaults to the parent's directory.", })), + model: Type.Optional(Type.String({ + description: 'Model for the child session, as an exact "provider/model-id" such as "anthropic/claude-sonnet-4-5". When the user references a model as #provider/model-id in their request, forward it here. An unknown value is rejected. Omit to inherit this session\'s model.', + })), }); const ListSubsessionsParams = Type.Object({}); @@ -197,9 +204,11 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse prompt: params.prompt, cwd: params.cwd, ...(ctx.model === undefined ? {} : { model: ctx.model }), + ...(params.model === undefined ? {} : { modelSpec: params.model }), }); + const modelNote = result.model === undefined ? "" : ` using model ${result.model}`; return { - content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Continue other work, then join with yield_to_subsessions; do not poll.` }], + content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}${modelNote}. Continue other work, then join with yield_to_subsessions; do not poll.` }], details: result, }; },