Archived
feat(sessions): optional model for spawn tools and # model completion in composer
spawn_session and spawn_subsession accept an optional model parameter as an exact provider/model-id (strict matching; unknown specs fail listing available models; omitting it keeps the inherited model). The chat composer opens a model completion menu on # and inserts a #provider/model-id reference into the draft, which agents forward as the model parameter.
This commit is contained in:
@@ -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.
|
||||||
@@ -103,6 +103,7 @@
|
|||||||
<a href="#catalog-refresh">Model catalog refresh</a>
|
<a href="#catalog-refresh">Model catalog refresh</a>
|
||||||
<a href="#session-tools">Session tools</a>
|
<a href="#session-tools">Session tools</a>
|
||||||
<a href="#extension-dialogs">Extension dialogs</a>
|
<a href="#extension-dialogs">Extension dialogs</a>
|
||||||
|
<a href="#prompt-completions">Prompt completions</a>
|
||||||
<a href="#completion-tools">Completion tools</a>
|
<a href="#completion-tools">Completion tools</a>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -805,6 +806,15 @@
|
|||||||
available when the child stops. Included output and transcripts follow a labeled marker and come last,
|
available when the child stops. Included output and transcripts follow a labeled marker and come last,
|
||||||
after PI WEB guidance.
|
after PI WEB guidance.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
Both <code>spawn_session</code> and <code>spawn_subsession</code> accept an optional
|
||||||
|
<code>model</code> parameter, given as an exact <code>provider/model-id</code> such as
|
||||||
|
<code>anthropic/claude-sonnet-4-5</code>. 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 <code>#provider/model-id</code> reference in the prompt (see
|
||||||
|
<a href="#prompt-completions">Prompt completions</a>) is how users ask for a specific model; agents
|
||||||
|
forward that reference as this parameter.
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
|
In <strong>Settings → Session daemon</strong>, these keys are saved on the selected machine. Restart the
|
||||||
session daemon on that machine after changing them.
|
session daemon on that machine after changing them.
|
||||||
@@ -879,6 +889,25 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="prompt-completions">
|
||||||
|
<h2>Prompt completions</h2>
|
||||||
|
<p>The chat composer opens completion menus on three trigger characters:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>/</code> at the very start of the draft completes session commands.</li>
|
||||||
|
<li>
|
||||||
|
<code>@</code> completes file paths: <code>@</code> for tracked files, <code>@ </code> (at, then
|
||||||
|
space) or <code>!@</code> for all files. Picking one inserts an <code>@path</code> reference into the
|
||||||
|
draft, quoted automatically when the path contains spaces.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>#</code> completes the models available to the session, filtered case-insensitively as you type
|
||||||
|
(at most 12 entries). Picking one inserts a <code>#provider/model-id</code> reference into the draft,
|
||||||
|
which tells agents the request should run on that model — for example as the <code>model</code>
|
||||||
|
parameter of <code>spawn_session</code>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="completion-tools">
|
<section id="completion-tools">
|
||||||
<h2>Optional completion tools</h2>
|
<h2>Optional completion tools</h2>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -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.
|
`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.
|
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`
|
#### `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.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentCompletions(editor: PromptEditor): unknown {
|
||||||
|
return Reflect.get(editor, "completions");
|
||||||
|
}
|
||||||
@@ -5,12 +5,12 @@ import { EditorView, keymap, placeholder } from "@codemirror/view";
|
|||||||
import { defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
|
import { defaultHighlightStyle, indentOnInput, indentUnit, syntaxHighlighting } from "@codemirror/language";
|
||||||
import { LitElement, html, type PropertyValues } from "lit";
|
import { LitElement, html, type PropertyValues } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators.js";
|
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 type { PromptAttachmentDelivery } from "../../../shared/apiTypes";
|
||||||
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
|
import { capturePromptAttachments, effectivePromptAttachmentDelivery, isInlinePromptAttachment, promptAttachmentsCanUseInlineDelivery, type CapturedAttachment } from "../promptAttachmentCapture";
|
||||||
import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
|
import { inputModeForDraft, inputModesEqual, type InputMode } from "../inputModes";
|
||||||
import { machineSessionKey } from "../machineKeys";
|
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 { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||||
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
|
import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences";
|
||||||
import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior";
|
import { createMobilePromptEnterMedia, readPromptEnterPreference, shouldSendPromptOnEnterShortcut, shouldUsePromptEnterShiftShortcut } from "../promptEnterBehavior";
|
||||||
@@ -283,7 +283,7 @@ export class PromptEditor extends LitElement {
|
|||||||
keyup: (event) => this.handleEditorKeyUp(event),
|
keyup: (event) => this.handleEditorKeyUp(event),
|
||||||
blur: () => this.resetEditorModifierState(),
|
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.editableCompartment.of(EditorView.editable.of(!this.disabled)),
|
||||||
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
|
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
@@ -372,6 +372,15 @@ export class PromptEditor extends LitElement {
|
|||||||
...(file.path.endsWith("/") && insertText.endsWith("\"") ? { cursorOffset: insertText.length - 1 } : {}),
|
...(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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptySessionModels(): SessionModel[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
function filesFromDataTransfer(data: DataTransfer | null): File[] {
|
function filesFromDataTransfer(data: DataTransfer | null): File[] {
|
||||||
if (data === null) return [];
|
if (data === null) return [];
|
||||||
return Array.from(data.files);
|
return Array.from(data.files);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export interface ChatLine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompletionItem {
|
export interface CompletionItem {
|
||||||
kind: "command" | "file";
|
kind: "command" | "file" | "model";
|
||||||
replaceFrom: number;
|
replaceFrom: number;
|
||||||
replaceTo: number;
|
replaceTo: number;
|
||||||
insertText: string;
|
insertText: string;
|
||||||
|
|||||||
@@ -31,4 +31,11 @@ describe("input mode helpers", () => {
|
|||||||
expect(inputModeForDraft("open !@\"vendor/file.ts")).toEqual({ kind: "file" });
|
expect(inputModeForDraft("open !@\"vendor/file.ts")).toEqual({ kind: "file" });
|
||||||
expect(inputModeForDraft("open \"src/main.ts")).toEqual({ kind: "normal" });
|
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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type InputMode =
|
|||||||
| { kind: "normal" }
|
| { kind: "normal" }
|
||||||
| { kind: "command" }
|
| { kind: "command" }
|
||||||
| { kind: "file" }
|
| { kind: "file" }
|
||||||
|
| { kind: "model" }
|
||||||
| { kind: "shell"; excludeFromContext: boolean };
|
| { kind: "shell"; excludeFromContext: boolean };
|
||||||
|
|
||||||
export function inputModeForDraft(draft: string): InputMode {
|
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: "file" };
|
||||||
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
|
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
|
||||||
if (currentToken(draft).startsWith("/")) return { kind: "command" };
|
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" };
|
return { kind: "normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("detectPromptCompletionTrigger", () => {
|
||||||
it("keeps all-file suggestions active when an @ space query contains spaces", () => {
|
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 });
|
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", () => {
|
describe("fileCompletionInsertText", () => {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import type { SessionModel } from "../../shared/apiTypes";
|
||||||
|
|
||||||
export type PromptCompletionTrigger =
|
export type PromptCompletionTrigger =
|
||||||
| { kind: "command"; query: string; from: number; to: number }
|
| { 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 {
|
export function detectPromptCompletionTrigger(draft: string, cursor = draft.length): PromptCompletionTrigger | undefined {
|
||||||
const beforeCursor = draft.slice(0, cursor);
|
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("/") && 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(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: "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;
|
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 {
|
export function fileCompletionInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
|
||||||
const prefix = allPrefix ?? "@";
|
const prefix = allPrefix ?? "@";
|
||||||
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createPiWebCustomToolDefinitions, sessionAllowsDelegationTools, type PiSessionManager } from "./piSessionService.js";
|
import { createPiWebCustomToolDefinitions, sessionAllowsDelegationTools, type PiSessionManager } from "./piSessionService.js";
|
||||||
import type { SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
import type { SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||||
@@ -14,13 +15,14 @@ afterEach(async () => {
|
|||||||
|
|
||||||
function delegationDeps() {
|
function delegationDeps() {
|
||||||
const spawn = vi.fn(() => Promise.resolve({ sessionId: "independent-1", cwd: "/workspace" }));
|
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 = {
|
const subsessions: SubsessionToolDeps = {
|
||||||
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })),
|
spawn: subsessionSpawn,
|
||||||
list: vi.fn(() => Promise.resolve([])),
|
list: vi.fn(() => Promise.resolve([])),
|
||||||
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })),
|
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 })),
|
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<typeof createPiWebCustomToolDefinitions>): string[] {
|
function toolNames(definitions: ReturnType<typeof createPiWebCustomToolDefinitions>): 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<typeof createPiWebCustomToolDefinitions>, 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", () => {
|
describe("delegation tool capability boundary", () => {
|
||||||
it("provides every globally enabled delegation tool to unrestricted sessions", () => {
|
it("provides every globally enabled delegation tool to unrestricted sessions", () => {
|
||||||
const { spawn, subsessions } = delegationDeps();
|
const { spawn, subsessions } = delegationDeps();
|
||||||
@@ -63,6 +79,39 @@ describe("delegation tool capability boundary", () => {
|
|||||||
expect(toolNames(createPiWebCustomToolDefinitions("/workspace", false, spawn, subsessions))).toEqual(["edit"]);
|
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 () => {
|
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 sessionManager = manager("session-1", undefined);
|
||||||
const open = vi.fn(() => { throw new Error("no parent session should be opened"); });
|
const open = vi.fn(() => { throw new Error("no parent session should be opened"); });
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { SpawnTargetDecision } from "./spawnTargetResolver.js";
|
|||||||
import { CapturingSessionEventHub, fakeRuntime, runtimeCreator, sessionGateway, testModel, testModelRuntime, type RuntimeCreator } from "./piSessionService.testSupport.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_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||||
|
const TEST_MODEL_SPEC = "anthropic/claude-sonnet-4-5-20250929";
|
||||||
|
|
||||||
describe("PiSessionService", () => {
|
describe("PiSessionService", () => {
|
||||||
describe("spawnSession", () => {
|
describe("spawnSession", () => {
|
||||||
@@ -25,7 +26,7 @@ describe("PiSessionService", () => {
|
|||||||
it("starts a session at the resolved target, delivers the prompt, and logs the spawn", async () => {
|
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 { 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(result).toEqual({ sessionId: "spawned-1", cwd: "/workspace-feature" });
|
||||||
expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]);
|
expect(fake.calls.prompt).toEqual([{ text: "continue the plan", options: undefined }]);
|
||||||
@@ -53,17 +54,34 @@ describe("PiSessionService", () => {
|
|||||||
heartbeatIntervalMs: 60_000,
|
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(initialModel).toBe(model);
|
||||||
expect(delegationToolsEnabled).toBe(true);
|
expect(delegationToolsEnabled).toBe(true);
|
||||||
await service.dispose();
|
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 () => {
|
it("rejects an out-of-project target without starting a session", async () => {
|
||||||
const { fake, service } = spawnService({ allowed: false, reason: "out-of-project", allowedCwds: ["/workspace"] });
|
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");
|
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /workspace");
|
||||||
expect(fake.calls.prompt).toEqual([]);
|
expect(fake.calls.prompt).toEqual([]);
|
||||||
expect(service.activeCount()).toBe(0);
|
expect(service.activeCount()).toBe(0);
|
||||||
@@ -73,7 +91,7 @@ describe("PiSessionService", () => {
|
|||||||
it("rejects when the spawning session is not in a registered project", async () => {
|
it("rejects when the spawning session is not in a registered project", async () => {
|
||||||
const { service } = spawnService({ allowed: false, reason: "not-registered" });
|
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");
|
.rejects.toThrow("Spawning session is not in a registered project");
|
||||||
await service.dispose();
|
await service.dispose();
|
||||||
});
|
});
|
||||||
@@ -88,9 +106,120 @@ describe("PiSessionService", () => {
|
|||||||
heartbeatIntervalMs: 60_000,
|
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");
|
.rejects.toThrow("Spawning sessions is disabled");
|
||||||
await service.dispose();
|
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<typeof fakeRuntime>[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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,6 +98,38 @@ describe("PiSessionService", () => {
|
|||||||
await service.dispose();
|
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 () => {
|
it("persists tracked child links in the parent and child sessions", async () => {
|
||||||
const parentPersisted: { customType: string; data?: unknown }[] = [];
|
const parentPersisted: { customType: string; data?: unknown }[] = [];
|
||||||
const childPersisted: { customType: string; data?: unknown }[] = [];
|
const childPersisted: { customType: string; data?: unknown }[] = [];
|
||||||
|
|||||||
@@ -120,6 +120,30 @@ function spawnTargetError(decision: Extract<SpawnTargetDecision, { allowed: fals
|
|||||||
return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
|
return new Error(`cwd must be a workspace of this project. Allowed: ${decision.allowedCwds.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelSpecOf(model: { provider: string; id: string }): string {
|
||||||
|
return `${model.provider}/${model.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a strict `provider/model-id` spec: split on the first `/` (model ids
|
||||||
|
* may themselves contain `/`) and require both parts to be non-empty.
|
||||||
|
*/
|
||||||
|
function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined {
|
||||||
|
const slash = spec.indexOf("/");
|
||||||
|
if (slash <= 0 || slash === spec.length - 1) return undefined;
|
||||||
|
return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error for a spawn-tool model spec that matched nothing. States the facts —
|
||||||
|
* the bad spec and the required format — with deliberately no model list
|
||||||
|
* (a list would invite guesses). The agent loop turns the throw into an
|
||||||
|
* error tool result; how to recover is the agent's call.
|
||||||
|
*/
|
||||||
|
function unknownSpawnModelError(modelSpec: string): Error {
|
||||||
|
return new Error(`Unknown model "${modelSpec}". Pass an exact "provider/model-id".`);
|
||||||
|
}
|
||||||
|
|
||||||
function authLossWarningKey(sessionId: string, provider: string, modelId: string): string {
|
function authLossWarningKey(sessionId: string, provider: string, modelId: string): string {
|
||||||
return `${sessionId}:${provider}/${modelId}`;
|
return `${sessionId}:${provider}/${modelId}`;
|
||||||
}
|
}
|
||||||
@@ -1178,13 +1202,23 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||||
if (!decision.allowed) throw spawnTargetError(decision);
|
if (!decision.allowed) throw spawnTargetError(decision);
|
||||||
const created = await this.start(decision.cwd, input.model === undefined ? {} : { initialModel: input.model });
|
// A model spec overrides the inherited model. Only a spec triggers a
|
||||||
|
// spawning-session lookup; the default path must not depend on it.
|
||||||
|
const model = input.modelSpec === undefined
|
||||||
|
? input.model
|
||||||
|
: await this.resolveSpawnModel(input.spawningSessionId, input.modelSpec);
|
||||||
|
const created = await this.start(decision.cwd, model === undefined ? {} : { initialModel: model });
|
||||||
|
const modelUsed = this.active.get(created.id)?.runtime.session.model;
|
||||||
await this.prompt(created.id, input.prompt);
|
await this.prompt(created.id, input.prompt);
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
||||||
"spawn_session started a new session",
|
"spawn_session started a new session",
|
||||||
);
|
);
|
||||||
return { sessionId: created.id, cwd: decision.cwd };
|
return {
|
||||||
|
sessionId: created.id,
|
||||||
|
cwd: decision.cwd,
|
||||||
|
...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1197,11 +1231,17 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
|
||||||
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
|
||||||
if (!decision.allowed) throw spawnTargetError(decision);
|
if (!decision.allowed) throw spawnTargetError(decision);
|
||||||
|
// A model spec overrides the inherited model and is resolved against the
|
||||||
|
// parent's model runtime; only a spec triggers that lookup.
|
||||||
|
const model = input.modelSpec === undefined
|
||||||
|
? input.model
|
||||||
|
: await this.resolveSpawnModel(input.parentSessionId, input.modelSpec);
|
||||||
const created = await this.startSession(decision.cwd, {
|
const created = await this.startSession(decision.cwd, {
|
||||||
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
|
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
|
||||||
...(input.model === undefined ? {} : { initialModel: input.model }),
|
...(model === undefined ? {} : { initialModel: model }),
|
||||||
creationProvenance: "tracked-subsession",
|
creationProvenance: "tracked-subsession",
|
||||||
});
|
});
|
||||||
|
const modelUsed = this.active.get(created.id)?.runtime.session.model;
|
||||||
const parentSessionFile = nonEmptyString(input.parentSessionFile);
|
const parentSessionFile = nonEmptyString(input.parentSessionFile);
|
||||||
const link: TrackedSubsessionLink = {
|
const link: TrackedSubsessionLink = {
|
||||||
parentSessionId: input.parentSessionId,
|
parentSessionId: input.parentSessionId,
|
||||||
@@ -1218,7 +1258,42 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
{ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
{ parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
|
||||||
"spawn_subsession started a tracked child session",
|
"spawn_subsession started a tracked child session",
|
||||||
);
|
);
|
||||||
return { sessionId: created.id, cwd: decision.cwd };
|
return {
|
||||||
|
sessionId: created.id,
|
||||||
|
cwd: decision.cwd,
|
||||||
|
...(modelUsed === undefined ? {} : { model: modelSpecOf(modelUsed) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The models a session may pick from: its scoped set when model-scoped,
|
||||||
|
* otherwise the runtime's available snapshot. Refreshes the runtime catalog
|
||||||
|
* first so callers see newly configured providers and models.
|
||||||
|
*/
|
||||||
|
private async sessionModelCandidates(session: PiAgentSession): Promise<readonly AgentModel[]> {
|
||||||
|
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<AgentModel> {
|
||||||
|
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<ClientSessionModel[]> {
|
async availableModels(ref: PiSessionLookup): Promise<ClientSessionModel[]> {
|
||||||
const session = await this.getOrOpen(ref);
|
const session = await this.getOrOpen(ref);
|
||||||
await session.modelRuntime.refresh();
|
const models = await this.sessionModelCandidates(session);
|
||||||
const models = session.scopedModels.length > 0
|
|
||||||
? session.scopedModels.map((scoped) => scoped.model)
|
|
||||||
: session.modelRuntime.getAvailableSnapshot();
|
|
||||||
return models.map(modelToClientModel);
|
return models.map(modelToClientModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1833,11 +1905,8 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
await this.assertWritable(ref);
|
await this.assertWritable(ref);
|
||||||
const session = await this.getOrOpen(ref);
|
const session = await this.getOrOpen(ref);
|
||||||
this.assertTreeNavigationInactive(session, "change models");
|
this.assertTreeNavigationInactive(session, "change models");
|
||||||
await session.modelRuntime.refresh();
|
const candidates = await this.sessionModelCandidates(session);
|
||||||
this.assertTreeNavigationInactive(session, "change models");
|
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)
|
const model = candidates.find((candidate) => candidate.provider === provider && candidate.id === modelId)
|
||||||
?? session.modelRuntime.getModel(provider, modelId);
|
?? session.modelRuntime.getModel(provider, modelId);
|
||||||
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
|
if (model === undefined) throw new Error(`Model not found: ${provider}/${modelId}`);
|
||||||
|
|||||||
@@ -2,20 +2,23 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
|
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" };
|
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", () => {
|
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 spawn = vi.fn(() => Promise.resolve({ sessionId: "new-1", cwd: "/repos/a-feature" }));
|
||||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
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.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." });
|
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 spawn = vi.fn(() => Promise.resolve({ sessionId: "new-2", cwd: "/repos/a" }));
|
||||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
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 () => {
|
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 spawn = vi.fn(() => Promise.reject(new Error("cwd must be a workspace of this project. Allowed: /repos/a")));
|
||||||
const tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
|
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");
|
.rejects.toThrow("cwd must be a workspace of this project. Allowed: /repos/a");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,16 +4,22 @@ import { defineTool, type ExtensionContext } from "@earendil-works/pi-coding-age
|
|||||||
export interface SpawnSessionResult {
|
export interface SpawnSessionResult {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
/** Model the spawned session runs with, as `provider/id`; absent when unknown. */
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>;
|
export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>;
|
||||||
|
|
||||||
export interface SpawnSessionInvocation {
|
export interface SpawnSessionInvocation {
|
||||||
spawningCwd: string;
|
spawningCwd: string;
|
||||||
|
/** Id of the dispatching session; used to resolve {@link modelSpec} against its model runtime. */
|
||||||
|
spawningSessionId: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
cwd: string | undefined;
|
cwd: string | undefined;
|
||||||
/** Current model from the dispatching session, used as the spawned session's default. */
|
/** Current model from the dispatching session, used as the spawned session's default. */
|
||||||
model?: SpawnSessionModel;
|
model?: SpawnSessionModel;
|
||||||
|
/** Strict `provider/model-id` requested by the dispatcher; overrides {@link model} when set. */
|
||||||
|
modelSpec?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpawnSessionToolDeps {
|
export interface SpawnSessionToolDeps {
|
||||||
@@ -29,6 +35,9 @@ const SpawnSessionParams = Type.Object({
|
|||||||
cwd: Type.Optional(Type.String({
|
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.",
|
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.
|
// valid workspace) rather than crash.
|
||||||
const result = await deps.spawn({
|
const result = await deps.spawn({
|
||||||
spawningCwd,
|
spawningCwd,
|
||||||
|
spawningSessionId: ctx.sessionManager.getSessionId(),
|
||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
||||||
|
...(params.model === undefined ? {} : { modelSpec: params.model }),
|
||||||
});
|
});
|
||||||
|
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
|
||||||
return {
|
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,
|
details: result,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 () => {
|
it("list_subsessions reports the caller's subsessions and their status", async () => {
|
||||||
const list = vi.fn(() => Promise.resolve([
|
const list = vi.fn(() => Promise.resolve([
|
||||||
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
|
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type SubsessionStatus = "working" | "idle" | "error" | "unknown";
|
|||||||
export interface SpawnSubsessionResult {
|
export interface SpawnSubsessionResult {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
/** Model the child session runs with, as `provider/id`; absent when unknown. */
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SpawnSubsessionModel = NonNullable<ExtensionContext["model"]>;
|
export type SpawnSubsessionModel = NonNullable<ExtensionContext["model"]>;
|
||||||
@@ -23,6 +25,8 @@ export interface SpawnSubsessionInvocation {
|
|||||||
cwd: string | undefined;
|
cwd: string | undefined;
|
||||||
/** Current model from the dispatching session, used as the spawned session's default. */
|
/** Current model from the dispatching session, used as the spawned session's default. */
|
||||||
model?: SpawnSubsessionModel;
|
model?: SpawnSubsessionModel;
|
||||||
|
/** Strict `provider/model-id` requested by the parent; overrides {@link model} when set. */
|
||||||
|
modelSpec?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubsessionSummary {
|
export interface SubsessionSummary {
|
||||||
@@ -72,6 +76,9 @@ const SpawnSubsessionParams = Type.Object({
|
|||||||
cwd: Type.Optional(Type.String({
|
cwd: Type.Optional(Type.String({
|
||||||
description: "Child workspace in the same project (worktree or root); defaults to the parent's directory.",
|
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({});
|
const ListSubsessionsParams = Type.Object({});
|
||||||
@@ -197,9 +204,11 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
|||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
||||||
|
...(params.model === undefined ? {} : { modelSpec: params.model }),
|
||||||
});
|
});
|
||||||
|
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
|
||||||
return {
|
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,
|
details: result,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user