fix(tools,client): chain-mode resolution and a live preset label

modeFor read only the exact scope's layer, so a `code`-preset session
advertised the native catalog: the mode is declared on the preset's STANDING
scope, and the agent only parents to it. Nearest scope wins along the chain —
the mode decides what the model SEES, which is the class of fact the chain
inherits. Caught live (the model politely computed with bash and said
run_code was not in its list); the chain test pins it.

The client half of the label fix: the create echo and the session-added
frame's agentPreset now reach the session list (newest wins in the upsert —
every producer of the field reports the CURRENT composition), and a confirmed
blank-session switch publishes through the new ISessions.noteAgentPreset, so
the header label moves with the composition instead of waiting for a reload.
This commit is contained in:
Yichen Jiang
2026-08-08 20:19:00 +08:00
parent 3926e95c61
commit 66e9b01fbd
8 changed files with 81 additions and 2 deletions
@@ -62,6 +62,15 @@ export interface ISessions {
* @returns completion of the current or newly started refresh.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void>
/**
* 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
/**
@@ -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':
@@ -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).
@@ -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: [] })
@@ -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 => ({
@@ -60,6 +60,12 @@ export class AgentPresetSeatController {
private readonly api: Pick<IApiClient, 'agentPresets'>,
/** 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<AgentPresetSeatState>): 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 })
+10 -1
View File
@@ -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
}
/**
@@ -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)