Ver código fonte

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.
Yichen Jiang 1 mês atrás
pai
commit
66e9b01fbd

+ 9 - 0
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<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
   /**

+ 20 - 1
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':

+ 4 - 0
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).

+ 8 - 0
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: [] })

+ 2 - 0
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 => ({

+ 7 - 0
packages/client/ui-agent-preset/src/client/seat-store.ts

@@ -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
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
   }
 
   /**

+ 21 - 0
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)