From ef2e060c957a0028990a9fc498fa159ed69deec0 Mon Sep 17 00:00:00 2001 From: "Hermes@mac13" Date: Fri, 6 Nov 2026 14:07:33 +0800 Subject: [PATCH] fix(subagent): emit an explicit mode=unknown so the aside open survives both gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `subagentchat` aside open failed silently: the host's `parseSubagentChatAddress` (the tab type's `canOpen`) rejects a MISSING mode (`get("mode")` → `null`), so a mode we could not stand behind — omitted to dodge the controller's 401 — made `claim` refuse the address (`sidebarRight: tab type "subagentchat" refuses ...`), and the right column never revealed. Fix: the codec now normalizes an unknown/undefined mode to an EXPLICIT `mode=unknown` instead of dropping it. That value passes both gates: - client: `parseSubagentChatAddress` accepts `unknown` → `canOpen` true → claim accepts; - server: `validateAddress` skips the compare when `mode === "unknown"` → no 401. A real (one-shot/continuable) mode is still emitted when the host catalog is `ready` and has the child; only an honest `unknown` is sent when it is not. Drops the TEMP `diag.append` probe route + the client before/after diag fetches (their mission is done) and rewrites the two spec cases to assert the explicit `mode=unknown`. Verified with the host's real `parseSubagentChatAddress` pulled verbatim from the app bundle: the old no-mode address → canOpen false (reproduces the refuse), the new `mode=unknown` address → canOpen true. typecheck / lint / 29 subagent+sidechat specs green. --- src/client/SubagentView.tsx | 44 ++++++++++++++++++++++-------- src/client/native/subagent-chat.ts | 38 +++++++++++++++++--------- src/context-types.ts | 10 +++++++ tests/subagent-chat-aside.spec.ts | 24 +++++++++++----- 4 files changed, 84 insertions(+), 32 deletions(-) diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 2b07c41..ea530f1 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -855,19 +855,39 @@ export function SubagentView(props: { * `openChild`; this one never switches the main pane. */ const openChildAside = useCallback((address: SidebarSubagentAddress): void => { - // 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, + // The host's session-controller validates `address.mode` against the + // child's DURABLE descriptor (`identity.mode`): a mismatch is a hard + // `subagent/unauthorized`. The row's `address.mode` is often an inference + // (the degraded synthesized catalog guesses from `running`), which is + // exactly the value that 401s when the child's real mode differs. + // + // Only send a mode we can stand behind: the host's REAL descriptor, read + // from `subagentsByParent` once that catalog is actually `ready` AND has + // this child. If the catalog never hydrated (absent / still loading / + // errored) or lacks the child, pass `mode: undefined`. The codec then + // emits an EXPLICIT `mode=unknown` — never omitted, because the host's + // `parseSubagentChatAddress` (the tab type's `canOpen`) rejects a MISSING + // mode (`null`) and `claim` refuses the address; and the controller + // treats `unknown` as "skip the check", so an honest `unknown` can never + // 401. A *guessed* mode is strictly worse than an honest `unknown`. + const catalog = catalogs[address.parentSessionId] + const entry = catalog?.state === 'ready' + ? catalog.entries.find( + (e): e is SidebarSubagentChildEntry => e.kind === 'child' && e.id === address.childSessionId, + ) + : undefined + const mode = entry?.mode + column?.openResource?.( + subagentChatAddress({ + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + mode, + }), + { + kind: SUBAGENT_CHAT_KIND, + preferNewPane: true, + }, ) - const mode = entry?.mode ?? address.mode - column?.openResource?.(subagentChatAddress({ ...address, mode }), { - kind: SUBAGENT_CHAT_KIND, - preferNewPane: true, - }) }, [column, catalogs]) const totals = useMemo( diff --git a/src/client/native/subagent-chat.ts b/src/client/native/subagent-chat.ts index c740b97..045017c 100644 --- a/src/client/native/subagent-chat.ts +++ b/src/client/native/subagent-chat.ts @@ -9,7 +9,7 @@ * * @module */ -import type { SidebarSubagentAddress } from '../../context-types.ts' +import type { SidebarSubagentAddressMaybeMode } 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']) @@ -23,20 +23,32 @@ export const SUBAGENT_CHAT_KIND = 'subagentchat' /** * Address one subagent Session together with the routing facts needed to * restore it. - * @param address - durable direct-parent subagent address. + * @param address - durable direct-parent subagent address (mode optional). * @returns canonical Sidebar resource address. */ -export function subagentChatAddress(address: SidebarSubagentAddress): string { - // `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. +export function subagentChatAddress(address: SidebarSubagentAddressMaybeMode): string { + // `mode` is a GATE, not a label — at BOTH ends of the open, and it must be + // present in the address either way: + // + // 1. CLIENT: the host's `parseSubagentChatAddress` (the native `subagentchat` + // tab type's `canOpen`) requires `mode` to be one of `one-shot` / + // `continuable` / `unknown`. An OMITTED mode is `null` and fails that + // check, so `claim` refuses the address + // (`sidebarRight: tab type "subagentchat" refuses ...`) and the page never + // opens — this is the failure an omitted mode used to cause. + // + // 2. SERVER: the session-controller's `validateAddress` skips its comparison + // when `mode === "unknown"` + // (`if (address.mode !== "unknown" && identity.mode !== address.mode) + // throw ...`), so an explicit `unknown` is the only form guaranteed to + // open the page AND never 401. + // + // A valid known mode is passed through verbatim (the row-level catalog is + // authoritative when present); anything else — `undefined`, `unknown`, or a + // stale inference — is normalized to the explicit `mode=unknown` rather than + // omitted. const query = new URLSearchParams({ parent: address.parentSessionId }) - if (KNOWN_MODES.has(address.mode)) query.set('mode', address.mode) + const mode = address.mode + query.set('mode', mode !== undefined && KNOWN_MODES.has(mode) ? mode : 'unknown') return `${SUBAGENT_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query.toString()}` } diff --git a/src/context-types.ts b/src/context-types.ts index b8caed4..8505faf 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -182,6 +182,16 @@ export interface SidebarSubagentAddress { mode: 'one-shot' | 'continuable' } +/** + * An address whose `mode` may be unknown. `mode` is OPTIONAL here (unlike + * {@link SidebarSubagentAddress}): the host's session-controller treats an + * address with no `mode` as `mode: "unknown"` and skips its mode validation, + * so omitting it is the safe form when the caller only has an inferred value. + */ +export interface SidebarSubagentAddressMaybeMode extends Omit { + mode?: 'one-shot' | 'continuable' +} + /** Minimal structural mirror of one session event (the subagent history tail). */ export interface SidebarSessionEvent { type: string diff --git a/tests/subagent-chat-aside.spec.ts b/tests/subagent-chat-aside.spec.ts index 3b4ed7d..5c2f7e7 100644 --- a/tests/subagent-chat-aside.spec.ts +++ b/tests/subagent-chat-aside.spec.ts @@ -50,18 +50,18 @@ describe('subagentChatAddress', () => { 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. + it('normalizes an unknown mode to an EXPLICIT mode=unknown (never omitted)', () => { + // `parseSubagentChatAddress` (the native tab type's `canOpen`) rejects a + // missing mode (`null`), so an unrecognized/unknown mode must be EMITTED as + // the literal `mode=unknown` — which both the client parser accepts and the + // controller skips (`mode !== "unknown"` is false). Omitting it used to make + // `claim` refuse the address. 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=') + expect(address).toBe('dsh-resource://subagentchat/session/c?parent=p&mode=unknown') }) it('still emits the mode when it is one of the two durable values', () => { @@ -70,4 +70,14 @@ describe('subagentChatAddress', () => { expect(subagentChatAddress({ parentSessionId: 'p', childSessionId: 'c', mode: 'continuable' })) .toContain('mode=continuable') }) + + it('normalizes an unknown (undefined) mode to an explicit mode=unknown', () => { + // The degraded path: the host catalog never hydrated, so the caller has no + // durable mode — it passes `mode: undefined`. The codec must NOT omit it + // (a missing mode is `null` and the host's `canOpen` refuses the address); + // it emits an explicit `mode=unknown`, which the controller skips + // (`mode !== "unknown"` is false) so the open never 401s. + const address = subagentChatAddress({ parentSessionId: 'p', childSessionId: 'c', mode: undefined }) + expect(address).toBe('dsh-resource://subagentchat/session/c?parent=p&mode=unknown') + }) })