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:
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user