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
@@ -61,6 +61,52 @@ describe("PiSessionService", () => {
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 () => {
const spawned = fakeRuntime("spawned-1", { sessionFile: "/tmp/spawned-1.jsonl", model: testModel() });
const service = new PiSessionService(new CapturingSessionEventHub(), {
@@ -64,17 +64,19 @@ describe("PiSessionService", () => {
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 child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
const model = testModel();
const initialModels: PiAgentSession["model"][] = [];
const initialThinkingLevels: unknown[] = [];
const delegationCapabilities: boolean[] = [];
const runtimes = [parent.runtime, child.runtime];
let index = 0;
const createAgentRuntime: RuntimeCreator = async (_createRuntime, options) => {
await Promise.resolve();
initialModels.push(options.initialModel);
initialThinkingLevels.push(options.initialThinkingLevel);
delegationCapabilities.push(options.delegationToolsEnabled);
const runtime = runtimes[index] ?? child.runtime;
index += 1;
@@ -91,9 +93,10 @@ describe("PiSessionService", () => {
});
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(initialThinkingLevels).toEqual([undefined, "max"]);
expect(delegationCapabilities).toEqual([true, false]);
await service.dispose();
});
+25 -5
View File
@@ -243,6 +243,11 @@ type SessionCreationProvenance = "tracked-subsession";
interface StartSessionOptions {
parentSession?: string;
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
* row with no session id yet can recognise its own.
@@ -439,7 +444,7 @@ interface PendingSessionOpen {
promise: Promise<ActiveSession<PiSessionRuntime>>;
}
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance" | "startupToken"> {
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "initialThinkingLevel" | "creationProvenance" | "startupToken"> {
notificationGeneration?: SessionNotificationGeneration;
notifications?: "enabled" | "disabled";
/**
@@ -607,11 +612,13 @@ interface CreateAgentRuntimeOptions {
sessionManager: PiSessionManager;
delegationToolsEnabled: boolean;
initialModel?: AgentModel;
initialThinkingLevel?: ClientThinkingLevel;
}
type PiWebRuntimeFactoryOptions = Parameters<CreateAgentSessionRuntimeFactory>[0] & {
delegationToolsEnabled?: boolean;
initialModel?: AgentModel;
initialThinkingLevel?: ClientThinkingLevel;
};
type PiWebCreateAgentSessionRuntimeFactory = (
@@ -622,7 +629,7 @@ type CreateAgentRuntime = (createRuntime: PiWebCreateAgentSessionRuntimeFactory,
function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntimeFactory, options: CreateAgentRuntimeOptions): Promise<PiSessionRuntime> {
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, {
cwd: options.cwd,
agentDir: options.agentDir,
@@ -633,20 +640,26 @@ function defaultCreateAgentRuntime(createRuntime: PiWebCreateAgentSessionRuntime
function createRuntimeWithOneShotSessionOptions(
createRuntime: PiWebCreateAgentSessionRuntimeFactory,
initialModel: AgentModel | undefined,
initialThinkingLevel: ClientThinkingLevel | undefined,
delegationToolsEnabled: boolean,
): CreateAgentSessionRuntimeFactory {
// 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 pendingInitialThinkingLevel = initialThinkingLevel;
let pendingDelegationToolsEnabled: boolean | undefined = delegationToolsEnabled;
return async (options) => {
const model = pendingInitialModel;
const thinkingLevel = pendingInitialThinkingLevel;
const toolsEnabled = pendingDelegationToolsEnabled;
pendingInitialModel = undefined;
pendingInitialThinkingLevel = undefined;
pendingDelegationToolsEnabled = undefined;
return createRuntime({
...options,
...(model === undefined ? {} : { initialModel: model }),
...(thinkingLevel === undefined ? {} : { initialThinkingLevel: thinkingLevel }),
...(toolsEnabled === undefined ? {} : { delegationToolsEnabled: toolsEnabled }),
});
};
@@ -678,7 +691,7 @@ function createDefaultRuntimeFactory(
subsessions?: SubsessionToolDeps,
askUser?: AskUserToolDeps,
): 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 resolvedDelegationToolsEnabled = delegationToolsEnabled
?? await sessionAllowsDelegationTools(sessionManager, sessionManagers);
@@ -689,6 +702,7 @@ function createDefaultRuntimeFactory(
customTools,
...(sessionStartEvent === undefined ? {} : { sessionStartEvent }),
...(initialModel === undefined ? {} : { model: initialModel }),
...(initialThinkingLevel === undefined ? {} : { thinkingLevel: initialThinkingLevel }),
});
return { ...result, services, diagnostics: services.diagnostics };
};
@@ -1170,6 +1184,7 @@ export class PiSessionService implements SessionRouteService {
startupIntent: "create",
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
},
);
@@ -1207,7 +1222,10 @@ export class PiSessionService implements SessionRouteService {
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 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;
await this.prompt(created.id, input.prompt);
this.logger.info(
@@ -1239,6 +1257,7 @@ export class PiSessionService implements SessionRouteService {
const created = await this.startSession(decision.cwd, {
...(input.parentSessionFile === undefined ? {} : { parentSession: input.parentSessionFile }),
...(model === undefined ? {} : { initialModel: model }),
...(input.thinkingLevel === undefined ? {} : { initialThinkingLevel: input.thinkingLevel }),
creationProvenance: "tracked-subsession",
});
const modelUsed = this.active.get(created.id)?.runtime.session.model;
@@ -2864,6 +2883,7 @@ export class PiSessionService implements SessionRouteService {
sessionManager,
delegationToolsEnabled,
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
...(options.initialThinkingLevel === undefined ? {} : { initialThinkingLevel: options.initialThinkingLevel }),
});
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
let boundSession = runtime.session;
+7 -7
View File
@@ -4,21 +4,21 @@ import { createSpawnSessionToolDefinition } from "./spawnSessionTool.js";
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 };
// 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.
return { sessionManager, ...(model === undefined ? {} : { model }) } as unknown as ExtensionContext;
return { sessionManager, ...(model === undefined ? {} : { model }), ...(thinkingLevel === undefined ? {} : { thinkingLevel }) } as unknown as ExtensionContext;
}
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 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.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);
});
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 tool = createSpawnSessionToolDefinition("/repos/a", { spawn });
+4
View File
@@ -9,6 +9,7 @@ export interface SpawnSessionResult {
}
export type SpawnSessionModel = NonNullable<ExtensionContext["model"]>;
export type SpawnSessionThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
export interface SpawnSessionInvocation {
spawningCwd: string;
@@ -20,6 +21,8 @@ export interface SpawnSessionInvocation {
model?: SpawnSessionModel;
/** Strict `provider/model-id` requested by the dispatcher; overrides {@link model} when set. */
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 {
@@ -64,6 +67,7 @@ export function createSpawnSessionToolDefinition(spawningCwd: string, deps: Spaw
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
...(params.model === undefined ? {} : { modelSpec: params.model }),
...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
});
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
return {
@@ -5,11 +5,11 @@ import { createSubsessionToolDefinitions, type SubsessionToolDeps } from "./spaw
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 };
// 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.
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>) {
@@ -48,7 +48,7 @@ describe("createSubsessionToolDefinitions", () => {
const spawn = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" }));
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({
spawningCwd: "/repos/a",
@@ -57,6 +57,7 @@ describe("createSubsessionToolDefinitions", () => {
prompt: "do it",
cwd: "/repos/a-feature",
model: dispatchModel,
thinkingLevel: "max",
});
expect(result.details).toEqual({ sessionId: "child-1", cwd: "/repos/a-feature" });
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 SpawnSubsessionThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
export interface SpawnSubsessionInvocation {
/** cwd of the session that invoked the tool (used for project-scope checks). */
@@ -27,6 +28,8 @@ export interface SpawnSubsessionInvocation {
model?: SpawnSubsessionModel;
/** Strict `provider/model-id` requested by the parent; overrides {@link model} when set. */
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 {
@@ -205,6 +208,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
cwd: params.cwd,
...(ctx.model === undefined ? {} : { model: ctx.model }),
...(params.model === undefined ? {} : { modelSpec: params.model }),
...(ctx.thinkingLevel === undefined ? {} : { thinkingLevel: ctx.thinkingLevel }),
});
const modelNote = result.model === undefined ? "" : ` using model ${result.model}`;
return {