fix(subagent): stop 401-ing the aside-open with a guessed child mode
CI / ci (pull_request) Canceled after 0s
CI / ci-windows (pull_request) Canceled after 0s
CI / plugin-mount (pull_request) Canceled after 0s

The aside button could hit `subagent/unauthorized: subagent mode does not
match the supplied address`. The host's session-controller (dsh-api-session-
controller validateAddress) compares the `mode` in the resource address
against the child's durable descriptor mode and rejects any mismatch. The
address `mode` came from the row, and on the degraded path the row's mode is
a best-effort guess from the summary `running` flag (running => continuable)
— so a child that is genuinely one-shot but still running was guessed as
continuable and the very open it was meant to trigger 401'd.

Two layers, so it holds on both the real-catalog and the degraded path:

- subagentChatAddress: only pass a mode through when it is one of the two
  durable values (`one-shot` / `continuable`). Anything else is omitted,
  which the controller reads as `mode: "unknown"` and therefore skips the
  comparison — the safest form when the caller does not actually know.
- openChildAside: prefer the host's real descriptor mode for the child
  (the `subagentsByParent` entry carries the durable mode) over the address
  mode, falling back to the address only when the real catalog never arrived.

Gates: typecheck / lint / build / test 132 files 1392 passed.
This commit is contained in:
figmar
2026-10-14 10:18:58 +08:00
parent 6b4a29ba24
commit 061964e3e8
3 changed files with 46 additions and 6 deletions
+11 -2
View File
@@ -855,11 +855,20 @@ export function SubagentView(props: {
* `openChild`; this one never switches the main pane.
*/
const openChildAside = useCallback((address: SidebarSubagentAddress): void => {
column?.openResource?.(subagentChatAddress(address), {
// The address's `mode` may be an inference (the degraded synthesized
// catalog). Prefer the host's REAL descriptor mode for this child — the
// `subagentsByParent` entry carries the durable `mode` the session-
// controller validates against — and only fall back to the address when
// the real catalog never arrived.
const entry = catalogs[address.parentSessionId]?.entries.find(
(e): e is SidebarSubagentChildEntry => e.kind === 'child' && e.id === address.childSessionId,
)
const mode = entry?.mode ?? address.mode
column?.openResource?.(subagentChatAddress({ ...address, mode }), {
kind: SUBAGENT_CHAT_KIND,
preferNewPane: true,
})
}, [column])
}, [column, catalogs])
const totals = useMemo(
() => rootId === undefined
+14 -4
View File
@@ -11,6 +11,9 @@
*/
import type { SidebarSubagentAddress } from '../../context-types.ts'
/** The only modes the host's session-controller will accept in an address. */
const KNOWN_MODES = new Set(['one-shot', 'continuable'])
/** Resource-address prefix for an embedded Session chat. */
export const SUBAGENT_CHAT_ADDRESS = 'dsh-resource://subagentchat/session/'
@@ -24,9 +27,16 @@ export const SUBAGENT_CHAT_KIND = 'subagentchat'
* @returns canonical Sidebar resource address.
*/
export function subagentChatAddress(address: SidebarSubagentAddress): string {
const query = new URLSearchParams({
parent: address.parentSessionId,
mode: address.mode,
})
// `mode` is a GATE, not a label: the host's session-controller rejects an
// address whose `mode` differs from the child's durable descriptor mode
// (`subagent/unauthorized: subagent mode does not match the supplied
// address`). The row-level `mode` can be a best-effort inference (the
// degraded summary-synthesized catalog guesses from `running`), so a wrong
// guess used to 401 the very open it was meant to trigger. A valid mode is
// passed through verbatim; anything else is OMITTED, which the controller
// reads as `mode: "unknown"` and therefore skips the comparison entirely —
// the safest form when the caller does not actually know the mode.
const query = new URLSearchParams({ parent: address.parentSessionId })
if (KNOWN_MODES.has(address.mode)) query.set('mode', address.mode)
return `${SUBAGENT_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query.toString()}`
}
+21
View File
@@ -49,4 +49,25 @@ describe('subagentChatAddress', () => {
it('names the page kind the native Sidebar roots these pages under', () => {
expect(SUBAGENT_CHAT_KIND).toBe('subagentchat')
})
it('omits an unknown mode so the host treats it as "unknown" and skips the check', () => {
// The degraded synthesized catalog guesses mode from `running`; a wrong
// guess used to 401 the open (the controller compares address.mode against
// the child's durable descriptor). An unrecognized mode must be dropped,
// which the controller reads as `mode: "unknown"` and does not compare.
const address = subagentChatAddress({
parentSessionId: 'p',
childSessionId: 'c',
mode: 'unknown' as never,
})
expect(address).toBe('dsh-resource://subagentchat/session/c?parent=p')
expect(address).not.toContain('mode=')
})
it('still emits the mode when it is one of the two durable values', () => {
expect(subagentChatAddress({ parentSessionId: 'p', childSessionId: 'c', mode: 'one-shot' }))
.toContain('mode=one-shot')
expect(subagentChatAddress({ parentSessionId: 'p', childSessionId: 'c', mode: 'continuable' }))
.toContain('mode=continuable')
})
})