diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 2af2ef51c8..144287f44c 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -62,6 +62,15 @@ export interface ISessions { * @returns completion of the current or newly started refresh. */ refreshSubagents(parentSessionId: SessionId): Promise + + /** + * Record the composition one session now runs. The agent-preset seat calls + * this after a successful blank-session switch, so the header label moves + * with the composition instead of waiting for the next full list refresh. + * @param sessionId - the switched session. + * @param agentPreset - the preset id the host confirmed. + */ + noteAgentPreset(sessionId: SessionId, agentPreset: string): void /** Clear the current selection into the no-session view state. */ clear(): void /** diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 64199c4812..314b1d72ff 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -523,6 +523,7 @@ export class SessionManager { this.recordMutation({ kind: 'upsert', summary: { sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + ...(result.value.agentPreset !== undefined ? { agentPreset: result.value.agentPreset } : {}), } }) } else { const publishedSessionId = workspaceAttachSessionId(result.error) @@ -588,6 +589,17 @@ export class SessionManager { this.recordMutation({ kind: 'upsert', summary }) } + /** + * Record a host-confirmed composition switch (see ISessions.noteAgentPreset). + * @param sessionId - the switched session. + * @param agentPreset - the preset id the host confirmed. + */ + noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + this.recordMutation({ kind: 'upsert', summary: { + sessionId, updatedAt: Date.now(), running: false, blank: true, agentPreset, + } }) + } + /** Apply immediately and retain for replay when a list response is in flight. */ private recordMutation(mutation: SessionListMutation): void { this.listMutations?.push(mutation) @@ -743,6 +755,7 @@ export class SessionManager { ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), ...(frame.origin !== undefined ? { origin: frame.origin } : {}), ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), + ...(frame.agentPreset !== undefined ? { agentPreset: frame.agentPreset } : {}), }) this.sessions.get(frame.sessionId)?.handleBlank(frame.blank) if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) { @@ -1027,9 +1040,15 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi ? { parentSessionId: mutation.summary.parentSessionId } : {}), ...(existing.origin === undefined && mutation.summary.origin !== undefined ? { origin: mutation.summary.origin } : {}), + // Newest wins, not fill-only: a blank-session preset switch replaces + // the creation-time value, and every producer of this field (the + // create echo, the select echo, a list row) reports the CURRENT one. + ...(mutation.summary.agentPreset !== undefined + ? { agentPreset: mutation.summary.agentPreset } : {}), } if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId - && filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries] + && filled.origin === existing.origin && filled.blank === existing.blank + && filled.agentPreset === existing.agentPreset) return [...summaries] return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) } case 'remove': diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index e354f5ee15..4f80ea2223 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -365,6 +365,10 @@ export class SessionsService implements ISessions { return this.manager.refreshSubagents(parentSessionId) } + noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + this.manager.noteAgentPreset(sessionId, agentPreset) + } + /** * Clear the current selection so the layout shows the no-session empty * state (new-session affordance and the workspace preselection flow). diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 4747929572..a019f1a724 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -430,6 +430,14 @@ export class TestSessions implements ISessions { return Promise.resolve() } + /** Apply a confirmed preset switch into the fixture list, as production does. */ + noteAgentPreset(sessionId: SessionId, agentPreset: string): void { + this.list.update((draft) => { + const summary = draft.byId[sessionId] + if (summary !== undefined) draft.byId[sessionId] = { ...summary, agentPreset } + }) + } + /** Clear the current selection (recorded; the production no-session flow). */ clear(): void { this.calls.push({ method: 'clear', args: [] }) diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index a3141dcc68..4737198195 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -98,6 +98,8 @@ export function apply(ctx: ClientContext): void { blank: summary.blank, ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }, } + }, (sessionId, agentPreset) => { + scope.sessions.noteAgentPreset(sessionId as never, agentPreset) }) const seatInjected = (): AgentPresetSeatInjected => ({ diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 335cd48e68..8c200dd32f 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -60,6 +60,12 @@ export class AgentPresetSeatController { private readonly api: Pick, /** The session the hero is about to hand over to, when there is one. */ private readonly currentSession: () => SeatSessionSummary | undefined, + /** + * Publish an applied switch into the session list, so the header label + * moves with the composition instead of waiting for the next full list + * refresh. Optional: a harness that renders no list omits it. + */ + private readonly onApplied?: (sessionId: string, agentPreset: string) => void, ) {} private set(patch: Partial): void { @@ -129,6 +135,7 @@ export class AgentPresetSeatController { } // Consumed: the next new session opens on the deployment default again. this.set({ busy: false, current: response.result.value.agentPreset }) + this.onApplied?.(session.id, response.result.value.agentPreset) } catch (error) { this.staged = undefined this.set({ busy: false, error: messageOf(error), current: this.fallback }) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 56cc07ce64..32448e2481 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -835,7 +835,16 @@ export class ToolRegistry extends Service { * @returns the resolved presentation mode. */ private modeFor(scope?: ScopeKey): ToolPresentationMode { - return this.layers.peek(scope)?.mode ?? this.defaultMode + // Nearest scope wins along the chain: a preset's standing declaration + // covers every agent parented under it, and an agent's own (were one ever + // declared) would override its preset's. The mode decides what the model + // SEES, which is exactly the class of fact the chain inherits. + const layers = this.layers.chainLayers(scope) + for (let index = layers.length - 1; index >= 0; index -= 1) { + const mode = layers[index]?.mode + if (mode !== undefined) return mode + } + return this.defaultMode } /** diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 328a41428b..8b56579e76 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1587,6 +1587,27 @@ describe('per-agent presentation', () => { expect(native.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) + it('inherits a STANDING preset scope\'s mode down the chain, agents beside it unaffected', async () => { + const { setScopeParent } = await import('@deepseek-ai/dsh-scope') + const { ctx, systemPrompt } = await setup({ mode: 'native' }) + registerEcho(ctx) + // The preset's standing scope declares once; the agent only PARENTS to it + // (the per-preset standing-mount shape — no per-agent declaration at all). + const standing = await mintAgentScope(ctx, 'preset:code-like') + standing.scope.ctx.tools.presentAs('code') + const joined = await mintAgentScope(ctx, 'joined-agent') + setScopeParent(joined.agent, standing.agent) + const loner = await mintAgentScope(ctx, 'loner-agent') + + expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() + const coded = await systemPrompt.assemble({ scope: joined.agent }) + expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + // A sibling that never parented stays native, as does the global view. + expect(ctx.tools.get(RUN_CODE_NAME, loner.agent)).toBeUndefined() + const native = await systemPrompt.assemble({ scope: loner.agent }) + expect(native.tools.map(tool => tool.name)).toEqual(['echo']) + }) + it('keeps run_code out of a native agent\'s dispatch table', async () => { const { ctx } = await setup({ mode: 'native' }) registerEcho(ctx)