feat(sessions): inherit thinking level in spawned sessions

spawn_session and spawn_subsession now forward the dispatching session's
current thinking level to the new session, clamped by pi to the spawned
model's capabilities, instead of falling back to the configured default.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-30 12:54:24 +02:00
parent 7103bfcb4c
commit c2b7cce2d3
10 changed files with 104 additions and 20 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Sessions started via `spawn_session` and `spawn_subsession` now inherit the spawning session's thinking level instead of falling back to the pi default, clamped to the child model's capabilities.
+2 -1
View File
@@ -813,7 +813,8 @@
inheriting the dispatching session's model. The match is strict: an unknown or malformed value is 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 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 <a href="#prompt-completions">Prompt completions</a>) is how users ask for a specific model; agents
forward that reference as this parameter. forward that reference as this parameter. The new session also inherits the dispatching session's
thinking level, clamped to its model's capabilities.
</p> </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
+1 -1
View File
@@ -273,7 +273,7 @@ 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. 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. The new session also inherits the dispatching session's thinking level, clamped to its model's capabilities.
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.
@@ -61,6 +61,52 @@ describe("PiSessionService", () => {
await service.dispose(); await service.dispose();
}); });
it("passes the dispatching session's thinking level to the spawned session's runtime", async () => {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
let initialThinkingLevel: unknown;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialThinkingLevel = options.initialThinkingLevel;
return fake.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,
});
await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature", thinkingLevel: "high" });
expect(initialThinkingLevel).toBe("high");
await service.dispose();
});
it("leaves the spawned session's thinking level to pi defaults when the dispatcher has none", async () => {
const fake = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl" });
let initialThinkingLevel: unknown = "unset";
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialThinkingLevel = options.initialThinkingLevel;
return fake.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,
});
await service.spawnSession({ spawningCwd: "/workspace", spawningSessionId: "spawner-1", prompt: "continue", cwd: "/workspace-feature" });
expect(initialThinkingLevel).toBeUndefined();
await service.dispose();
});
it("names the spawned session's model in the result", async () => { it("names the spawned session's model in the result", async () => {
const spawned = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl", model: testModel() }); const spawned = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl", model: testModel() });
const service = new PiSessionService(new CapturingSessionEventHub(), { const service = new PiSessionService(new CapturingSessionEventHub(), {
@@ -64,17 +64,19 @@ describe("PiSessionService", () => {
await service.dispose(); await service.dispose();
}); });
it("uses the parent model and disables delegation before creating the tracked child runtime", async () => { it("uses the parent model and thinking level and disables delegation before creating the tracked child runtime", async () => {
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" }); const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") }); const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const model = testModel(); const model = testModel();
const initialModels: PiAgentSession["model"][] = []; const initialModels: PiAgentSession["model"][] = [];
const initialThinkingLevels: unknown[] = [];
const delegationCapabilities: boolean[] = []; const delegationCapabilities: boolean[] = [];
const runtimes = [parent.runtime, child.runtime]; const runtimes = [parent.runtime, child.runtime];
let index = 0; let index = 0;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => { const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve(); await Promise.resolve();
initialModels.push(options.initialModel); initialModels.push(options.initialModel);
initialThinkingLevels.push(options.initialThinkingLevel);
delegationCapabilities.push(options.delegationToolsEnabled); delegationCapabilities.push(options.delegationToolsEnabled);
const runtime = runtimes[index] ?? child.runtime; const runtime = runtimes[index] ?? child.runtime;
index += 1; index += 1;
@@ -91,9 +93,10 @@ describe("PiSessionService", () => {
}); });
await service.start("/workspace"); await service.start("/workspace");
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model }); await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature", model, thinkingLevel: "max" });
expect(initialModels).toEqual([undefined, model]); expect(initialModels).toEqual([undefined, model]);
expect(initialThinkingLevels).toEqual([undefined, "max"]);
expect(delegationCapabilities).toEqual([true, false]); expect(delegationCapabilities).toEqual([true, false]);
await service.dispose(); await service.dispose();
}); });
+25 -5
View File
@@ -243,6 +243,11 @@ type SessionCreationProvenance = "tracked-subsession";
interface StartSessionOptions { interface StartSessionOptions {
parentSession?: string; parentSession?: string;
initialModel?: AgentModel; initialModel?: AgentModel;
/**
* Thinking level for the brand new session; omit to resolve from settings
* and pi defaults. Pi clamps it to the initial model's capabilities.
*/
initialThinkingLevel?: ClientThinkingLevel;
/** /**
* Opaque label, echoed on this construction's startup progress so a browser * Opaque label, echoed on this construction's startup progress so a browser
* row with no session id yet can recognise its own. * row with no session id yet can recognise its own.
@@ -439,7 +444,7 @@ interface PendingSessionOpen {
promise: Promise<ActiveSession<PiSessionRuntime>>; promise: Promise<ActiveSession<PiSessionRuntime>>;
} }
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance" | "startupToken"> { interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "initialThinkingLevel" | "creationProvenance" | "startupToken"> {
notificationGeneration?: SessionNotificationGeneration; notificationGeneration?: SessionNotificationGeneration;
notifications?: "enabled" | "disabled"; notifications?: "enabled" | "disabled";
/** /**
@@ -607,11 +612,13 @@ interface CreateAgentRuntimeOptions {
sessionManager: PiSessionManager; sessionManager: PiSessionManager;
delegationToolsEnabled: boolean; delegationToolsEnabled: boolean;
initialModel?: AgentModel; initialModel?: AgentModel;
initialThinkingLevel?: ClientThinkingLevel;
} }
type PiWebRuntimeFactoryOptions = Parameters<CreateAgentSessionRuntimeFactory>[0] & { type PiWebRuntimeFactoryOptions = Parameters<CreateAgentSessionRuntimeFactory>[0] & {
delegationToolsEnabled?: boolean; delegationToolsEnabled?: boolean;
initialModel?: AgentModel; initialModel?: AgentModel;
initialThinkingLevel?: ClientThinkingLevel;
}; };
type PiWebCreateAgentSessionRuntimeFactory = ( type PiWebCreateAgentSessionRuntimeFactory = (
@@ -622,7 +629,7 @@ type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory,
function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> { function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager"); if (!(options.sessionManager instanceof SessionManager)) throw new Error("Default runtime creation requires an SDK SessionManager");
const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.delegationToolsEnabled); const runtimeFactory = createRuntimeWithOneShotSessionOptions(createRuntime, options.initialModel, options.initialThinkingLevel, options.delegationToolsEnabled);
return createAgentSessionRuntime(runtimeFactory, { return createAgentSessionRuntime(runtimeFactory, {
cwd: options.cwd, cwd: options.cwd,
agentDir: options.agentDir, agentDir: options.agentDir,
@@ -633,20 +640,26 @@ function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntime
function createRuntimeWithOneShotSessionOptions( function createRuntimeWithOneShotSessionOptions(
createRuntime: PiWebCreateAgentSessionRuntimeFactory, createRuntime: PiWebCreateAgentSessionRuntimeFactory,
initialModel: AgentModel | undefined, initialModel: AgentModel | undefined,
initialThinkingLevel: ClientThinkingLevel | undefined,
delegationToolsEnabled: boolean, delegationToolsEnabled: boolean,
): CreateAgentSessionRuntimeFactory { ): CreateAgentSessionRuntimeFactory {
// These inputs belong only to the session being opened. A later runtime // These inputs belong only to the session being opened. A later runtime
// replacement resolves its own model and delegation capability. // replacement resolves its own model and delegation capability, and restores
// the thinking level from the existing session file.
let pendingInitialModel = initialModel; let pendingInitialModel = initialModel;
let pendingInitialThinkingLevel = initialThinkingLevel;
let pendingDelegationToolsEnabled: boolean | undefined = delegationToolsEnabled; let pendingDelegationToolsEnabled: boolean | undefined = delegationToolsEnabled;
return async (options) => { return async (options) => {
const model = pendingInitialModel; const model = pendingInitialModel;
const thinkingLevel = pendingInitialThinkingLevel;
const toolsEnabled = pendingDelegationToolsEnabled; const toolsEnabled = pendingDelegationToolsEnabled;
pendingInitialModel = undefined; pendingInitialModel = undefined;
pendingInitialThinkingLevel = undefined;
pendingDelegationToolsEnabled = undefined; pendingDelegationToolsEnabled = undefined;
return createRuntime({ return createRuntime({
...options, ...options,
...(model === undefined ? {} : { initialModel: model }), ...(model === undefined ? {} : { initialModel: model }),
...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }),
...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }), ...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
}); });
}; };
@@ -678,7 +691,7 @@ function createDefaultRuntimeFactory(
subsessions?: SubsessionToolDeps, subsessions?: SubsessionToolDeps,
askUser?: AskUserToolDeps, askUser?: AskUserToolDeps,
): PiWebCreateAgentSessionRuntimeFactory { ): PiWebCreateAgentSessionRuntimeFactory {
return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, delegationToolsEnabled }) => { return async ({ cwd, agentDir, sessionManager, sessionStartEvent, initialModel, initialThinkingLevel, delegationToolsEnabled }) => {
const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime }); const services: AgentSessionServices = await createAgentSessionServices({ cwd, agentDir, modelRuntime });
const resolvedDelegationToolsEnabled = delegationToolsEnabled const resolvedDelegationToolsEnabled = delegationToolsEnabled
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers); ?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
@@ -689,6 +702,7 @@ function createDefaultRuntimeFactory(
customTools, customTools,
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }), ...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
...(initialModel === undefined ? {} : { model: initialModel }), ...(initialModel === undefined ? {} : { model: initialModel }),
...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
}); });
return { ...result, services, diagnostics: services.diagnostics }; return { ...result, services, diagnostics: services.diagnostics };
}; };
@@ -1170,6 +1184,7 @@ export class PiSessionService implements SessionRouteService {
startupIntent: "create", startupIntent: "create",
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }), ...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }), ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }), ...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
}, },
); );
@@ -1207,7 +1222,10 @@ export class PiSessionService implements SessionRouteService {
const model = input.modelSpec === undefined const model = input.modelSpec === undefined
? input.model ? input.model
: await this.resolveSpawnModel(input.spawningSessionId, input.modelSpec); : await this.resolveSpawnModel(input.spawningSessionId, input.modelSpec);
const created = await this.start(decision.cwd, model === undefined ? {} : { initialModel: model }); const created = await this.start(decision.cwd, {
...(model === undefined ? {} : { initialModel: model }),
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
});
const modelUsed = this.active.get(created.id)?.runtime.session.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(
@@ -1239,6 +1257,7 @@ export class PiSessionService implements SessionRouteService {
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 }),
...(model === undefined ? {} : { initialModel: model }), ...(model === undefined ? {} : { initialModel: model }),
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
creationProvenance: "tracked-subsession", creationProvenance: "tracked-subsession",
}); });
const modelUsed = this.active.get(created.id)?.runtime.session.model; const modelUsed = this.active.get(created.id)?.runtime.session.model;
@@ -2864,6 +2883,7 @@ export class PiSessionService implements SessionRouteService {
sessionManager, sessionManager,
delegationToolsEnabled, delegationToolsEnabled,
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }), ...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
}); });
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop }; const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
let boundSession = runtime.session; let boundSession = runtime.session;
+7 -7
View File
@@ -4,21 +4,21 @@ import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" }; const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
function ctxFor(sessionId: string, model?: unknown): ExtensionContext { function ctxFor(sessionId: string, model?: unknown, thinkingLevel?: string): ExtensionContext {
const sessionManager = { getSessionId: () => sessionId }; const sessionManager = { getSessionId: () => sessionId };
// The spawn tool only reads sessionManager.getSessionId and model. // The spawn tool only reads sessionManager.getSessionId, model, and thinkingLevel.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tool reads. // 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; return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext;
} }
describe("createSpawnSessionToolDefinition", () => { describe("createSpawnSessionToolDefinition", () => {
it("passes the spawning identity, explicit cwd, dispatching model, and prompt to spawn callback", async () => { it("passes the spawning identity, explicit cwd, dispatching model, thinking level, 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, ctxFor("spawner-1", dispatchModel)); const result = await tool.execute("call-1", { prompt: "do the thing", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("spawner-1", dispatchModel, "high"));
expect(spawn).toHaveBeenCalledWith({ spawningCwd: "/repos/a", spawningSessionId: "spawner-1", 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, thinkingLevel: "high" });
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." });
}); });
@@ -31,7 +31,7 @@ describe("createSpawnSessionToolDefinition", () => {
expect(tool.description).not.toMatch(/subsession|child|parent/i); expect(tool.description).not.toMatch(/subsession|child|parent/i);
}); });
it("forwards omitted cwd as undefined and omits a missing dispatching model", async () => { it("forwards omitted cwd as undefined and omits a missing dispatching model and thinking level", async () => {
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 });
+4
View File
@@ -9,6 +9,7 @@ export interface SpawnSessionResult {
} }
export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>; export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>;
export type SpawnSessionThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
export interface SpawnSessionInvocation { export interface SpawnSessionInvocation {
spawningCwd: string; spawningCwd: string;
@@ -20,6 +21,8 @@ export interface SpawnSessionInvocation {
model?: SpawnSessionModel; model?: SpawnSessionModel;
/** Strict `provider/model-id` requested by the dispatcher; overrides {@link model} when set. */ /** Strict `provider/model-id` requested by the dispatcher; overrides {@link model} when set. */
modelSpec?: string; modelSpec?: string;
/** Dispatching session's current thinking level, inherited by the spawned session (pi clamps it to the spawned model's capabilities). */
thinkingLevel?: SpawnSessionThinkingLevel;
} }
export interface SpawnSessionToolDeps { export interface SpawnSessionToolDeps {
@@ -64,6 +67,7 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw
cwd: params.cwd, cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }), ...(ctx.model === undefined ? {} : { model: ctx.model }),
...(params.model === undefined ? {} : { modelSpec: params.model }), ...(params.model === undefined ? {} : { modelSpec: params.model }),
...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
}); });
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`; const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
return { return {
@@ -5,11 +5,11 @@ import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spaw
const dispatchModel = { provider: "anthropic", id: "claude-sonnet" }; const dispatchModel = { provider: "anthropic", id: "claude-sonnet" };
function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown): ExtensionContext { function ctxFor(sessionId: string, sessionFile: string | undefined, model?: unknown, thinkingLevel?: string): ExtensionContext {
const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile }; const sessionManager = { getSessionId: () => sessionId, getSessionFile: () => sessionFile };
// The subsession tools only read sessionManager.getSessionId/getSessionFile and model. // The subsession tools only read sessionManager.getSessionId/getSessionFile, model, and thinkingLevel.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- test stub with the minimal surface the tools use.
return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext; return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext;
} }
function tools(deps: Partial<SubsessionToolDeps>) { function tools(deps: Partial<SubsessionToolDeps>) {
@@ -48,7 +48,7 @@ describe("createSubsessionToolDefinitions", () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })); const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
const { spawn: spawnTool } = tools({ spawn }); const { spawn: spawnTool } = tools({ spawn });
const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel)); const result = await spawnTool.execute("call-1", { prompt: "do it", cwd: "/repos/a-feature" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl", dispatchModel, "max"));
expect(spawn).toHaveBeenCalledWith({ expect(spawn).toHaveBeenCalledWith({
spawningCwd: "/repos/a", spawningCwd: "/repos/a",
@@ -57,6 +57,7 @@ describe("createSubsessionToolDefinitions", () => {
prompt: "do it", prompt: "do it",
cwd: "/repos/a-feature", cwd: "/repos/a-feature",
model: dispatchModel, model: dispatchModel,
thinkingLevel: "max",
}); });
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" }); expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
expect(firstText(result.content)).toContain("Started tracked subsession child-1"); expect(firstText(result.content)).toContain("Started tracked subsession child-1");
@@ -13,6 +13,7 @@ export interface SpawnSubsessionResult {
} }
export type SpawnSubsessionModel = NonNullable<ExtensionContext["model"]>; export type SpawnSubsessionModel = NonNullable<ExtensionContext["model"]>;
export type SpawnSubsessionThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
export interface SpawnSubsessionInvocation { export interface SpawnSubsessionInvocation {
/** cwd of the session that invoked the tool (used for project-scope checks). */ /** cwd of the session that invoked the tool (used for project-scope checks). */
@@ -27,6 +28,8 @@ export interface SpawnSubsessionInvocation {
model?: SpawnSubsessionModel; model?: SpawnSubsessionModel;
/** Strict `provider/model-id` requested by the parent; overrides {@link model} when set. */ /** Strict `provider/model-id` requested by the parent; overrides {@link model} when set. */
modelSpec?: string; modelSpec?: string;
/** Parent's current thinking level, inherited by the child session (pi clamps it to the child model's capabilities). */
thinkingLevel?: SpawnSubsessionThinkingLevel;
} }
export interface SubsessionSummary { export interface SubsessionSummary {
@@ -205,6 +208,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
cwd: params.cwd, cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }), ...(ctx.model === undefined ? {} : { model: ctx.model }),
...(params.model === undefined ? {} : { modelSpec: params.model }), ...(params.model === undefined ? {} : { modelSpec: params.model }),
...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
}); });
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`; const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
return { return {