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:
Federico Jaramillo Martinez
2026-07-29 23:58:38 +02:00
parent 9b8728a5d6
commit 5f4d81352f
18 changed files with 656 additions and 38 deletions
@@ -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<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", () => {
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"); });
@@ -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<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();
});
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 }[] = [];
+81 -12
View File
@@ -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(", ")}`);
}
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 {
return `${sessionId}:${provider}/${modelId}`;
}
@@ -1178,13 +1202,23 @@ export class PiSessionService implements SessionRouteService {
if (this.spawnTargets === undefined) throw new Error("Spawning sessions is disabled");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
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);
this.logger.info(
{ spawningCwd: input.spawningCwd, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length },
"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");
const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd);
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, {
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
...(input.model === undefined ? {} : { initialModel: input.model }),
...(model === undefined ? {} : { initialModel: model }),
creationProvenance: "tracked-subsession",
});
const modelUsed = this.active.get(created.id)?.runtime.session.model;
const parentSessionFile = nonEmptyString(input.parentSessionFile);
const link: TrackedSubsessionLink = {
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 },
"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[]> {
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}`);
+42 -10
View File
@@ -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");
});
});
+13 -1
View File
@@ -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<ExtensionContext["model"]>;
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,
};
},
@@ -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 },
+10 -1
View File
@@ -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<ExtensionContext["model"]>;
@@ -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,
};
},