From 061964e3e8e6f0157abbdf19bef897719b3e68ef Mon Sep 17 00:00:00 2001 From: figmar Date: Wed, 14 Oct 2026 10:18:58 +0800 Subject: [PATCH] fix(subagent): stop 401-ing the aside-open with a guessed child mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/client/SubagentView.tsx | 13 +++++++++++-- src/client/native/subagent-chat.ts | 18 ++++++++++++++---- tests/subagent-chat-aside.spec.ts | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index cece3d4..2b07c41 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -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 diff --git a/src/client/native/subagent-chat.ts b/src/client/native/subagent-chat.ts index 624228b..c740b97 100644 --- a/src/client/native/subagent-chat.ts +++ b/src/client/native/subagent-chat.ts @@ -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()}` } diff --git a/tests/subagent-chat-aside.spec.ts b/tests/subagent-chat-aside.spec.ts index c6e8826..3b4ed7d 100644 --- a/tests/subagent-chat-aside.spec.ts +++ b/tests/subagent-chat-aside.spec.ts @@ -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') + }) })