From b31d2c978259a4f176e70d69235ca2b747567b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 02:49:48 +0200 Subject: [PATCH 1/4] fix(cloud-agent-sdk): scope child session errors to the child Route a child session.error (non-root sessionId) to a new optional onChildSessionError callback instead of the shared root status and onError. The manager stores the message per child id in a new childSessionErrorsAtom and exposes a read-only childSessionError atom. Events without a sessionId keep the legacy root behavior. --- .../cloud-agent-sdk/src/service-state.test.ts | 50 +++++++++++++++++ packages/cloud-agent-sdk/src/service-state.ts | 9 +++ .../src/session-manager.test.ts | 56 +++++++++++++++++++ .../cloud-agent-sdk/src/session-manager.ts | 13 +++++ packages/cloud-agent-sdk/src/session.ts | 2 + 5 files changed, 130 insertions(+) diff --git a/packages/cloud-agent-sdk/src/service-state.test.ts b/packages/cloud-agent-sdk/src/service-state.test.ts index e0de986311..ffdc0cd61b 100644 --- a/packages/cloud-agent-sdk/src/service-state.test.ts +++ b/packages/cloud-agent-sdk/src/service-state.test.ts @@ -284,6 +284,56 @@ describe('createServiceState', () => { expect(state.getStatus()).toEqual({ type: 'error', message: 'Something went wrong' }); }); + it('scopes child errors to onChildSessionError without touching root state', () => { + const onError = jest.fn(); + const onChildSessionError = jest.fn(); + const state = createServiceState(makeConfig({ onError, onChildSessionError })); + + state.process({ + type: 'session.error', + error: 'Requests ending with a model turn are not supported.', + sessionId: 'child-1', + }); + + expect(onError).not.toHaveBeenCalled(); + expect(state.getStatus()).toEqual({ type: 'idle' }); + expect(onChildSessionError).toHaveBeenCalledWith( + 'child-1', + 'Requests ending with a model turn are not supported.' + ); + }); + + it('keeps root session errors on onError and root status', () => { + const onError = jest.fn(); + const onChildSessionError = jest.fn(); + const state = createServiceState(makeConfig({ onError, onChildSessionError })); + + state.process({ + type: 'session.error', + error: 'Requests ending with a model turn are not supported.', + sessionId: 'root-1', + }); + + expect(onError).toHaveBeenCalledWith('Requests ending with a model turn are not supported.'); + expect(onChildSessionError).not.toHaveBeenCalled(); + expect(state.getStatus()).toEqual({ + type: 'error', + message: 'Requests ending with a model turn are not supported.', + }); + }); + + it('keeps events without a sessionId on the legacy root path', () => { + const onError = jest.fn(); + const onChildSessionError = jest.fn(); + const state = createServiceState(makeConfig({ onError, onChildSessionError })); + + state.process({ type: 'session.error', error: 'Something went wrong' }); + + expect(onError).toHaveBeenCalledWith('Something went wrong'); + expect(onChildSessionError).not.toHaveBeenCalled(); + expect(state.getStatus()).toEqual({ type: 'error', message: 'Something went wrong' }); + }); + it('is suppressed after stopped(error) — aftershock absorption', () => { const onError = jest.fn(); const state = createServiceState(makeConfig({ onError })); diff --git a/packages/cloud-agent-sdk/src/service-state.ts b/packages/cloud-agent-sdk/src/service-state.ts index 85fd3e9ac2..fe4ee7fd66 100644 --- a/packages/cloud-agent-sdk/src/service-state.ts +++ b/packages/cloud-agent-sdk/src/service-state.ts @@ -26,6 +26,7 @@ type ServiceStateConfig = { /** The root session ID we're tracking (to detect child sessions). */ rootSessionId: string; onError?: ((message: string) => void) | undefined; + onChildSessionError?: ((sessionId: string, message: string) => void) | undefined; onQuestionAsked?: ((requestId: string, questions?: QuestionInfo[]) => void) | undefined; onQuestionResolved?: ((requestId: string) => void) | undefined; onPermissionAsked?: @@ -230,6 +231,14 @@ function createServiceState(config: ServiceStateConfig): ServiceState { function processSessionError(event: Extract): void { if (terminated) return; + // Child session errors are scoped to the child. They must not touch the + // shared root status or onError, which drive the parent status indicator. + // Events without a sessionId keep the legacy root behavior. + if (event.sessionId !== undefined && !isRootSession(event.sessionId)) { + config.onChildSessionError?.(event.sessionId, event.error); + return; + } + config.onError?.(event.error); status = { type: 'error', message: event.error }; diff --git a/packages/cloud-agent-sdk/src/session-manager.test.ts b/packages/cloud-agent-sdk/src/session-manager.test.ts index 4eab488dad..8bd811bee6 100644 --- a/packages/cloud-agent-sdk/src/session-manager.test.ts +++ b/packages/cloud-agent-sdk/src/session-manager.test.ts @@ -128,6 +128,7 @@ const mockSessionCallbacks: { state: Extract ) => void; onError?: (message: string) => void; + onChildSessionError?: (sessionId: string, message: string) => void; } = {}; let latestStorage: JotaiSessionStorage | null = null; @@ -163,6 +164,7 @@ jest.mock('./session', () => ({ state: Extract ) => void; onError?: (message: string) => void; + onChildSessionError?: (sessionId: string, message: string) => void; transport?: { userWebConnection?: unknown; fetchSnapshotPage?: ( @@ -232,6 +234,7 @@ jest.mock('./session', () => ({ mockSessionCallbacks.onMessageCompleted = sessionConfig.onMessageCompleted; mockSessionCallbacks.onMessageFailed = sessionConfig.onMessageFailed; mockSessionCallbacks.onError = sessionConfig.onError; + mockSessionCallbacks.onChildSessionError = sessionConfig.onChildSessionError; return mockSession; } ), @@ -433,6 +436,7 @@ describe('createSessionManager', () => { mockSessionCallbacks.onMessageCompleted = undefined; mockSessionCallbacks.onMessageFailed = undefined; mockSessionCallbacks.onError = undefined; + mockSessionCallbacks.onChildSessionError = undefined; }); // ------------------------------------------------------------------------- @@ -3022,6 +3026,58 @@ describe('createSessionManager', () => { }); }); + describe('child session errors', () => { + it('routes child session errors to the per-child atom without touching root atoms', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-root')); + + mockSessionCallbacks.onChildSessionError?.( + 'child-1', + 'Requests ending with a model turn are not supported.' + ); + + expect(atomValue(config.store, mgr.atoms.error)).toBeNull(); + expect(atomValue(config.store, mgr.atoms.statusIndicator)).toBeNull(); + const childSessionError = atomValue<(childSessionId: string) => string | null>( + config.store, + mgr.atoms.childSessionError + ); + expect(childSessionError('child-1')).toBe( + 'Requests ending with a model turn are not supported.' + ); + expect(childSessionError('other-child')).toBeNull(); + }); + + it('clears child session errors when switching sessions', async () => { + const config = createMockConfig(); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-root')); + + mockSessionCallbacks.onChildSessionError?.( + 'child-1', + 'Requests ending with a model turn are not supported.' + ); + + expect( + atomValue<(childSessionId: string) => string | null>( + config.store, + mgr.atoms.childSessionError + )('child-1') + ).toBe('Requests ending with a model turn are not supported.'); + + await mgr.switchSession(kiloId('ses-other')); + + const childSessionError = atomValue<(childSessionId: string) => string | null>( + config.store, + mgr.atoms.childSessionError + ); + expect(childSessionError('child-1')).toBeNull(); + }); + }); + // ------------------------------------------------------------------------- // sessionConfig variant tracking // ------------------------------------------------------------------------- diff --git a/packages/cloud-agent-sdk/src/session-manager.ts b/packages/cloud-agent-sdk/src/session-manager.ts index 51135657c1..d329cb78f8 100644 --- a/packages/cloud-agent-sdk/src/session-manager.ts +++ b/packages/cloud-agent-sdk/src/session-manager.ts @@ -367,6 +367,7 @@ type SessionManagerAtoms = { contextUsage: Atom; childMessages: Atom<(childSessionId: string) => StoredMessage[]>; childSessionHydrationState: Atom<(childSessionId: string) => ChildSessionHydrationState>; + childSessionError: Atom<(childSessionId: string) => string | null>; /** True when the latest page left a non-null cursor (more history to load). */ hasOlderMessages: W; /** True while `loadOlderMessages()` is fetching a page. */ @@ -642,6 +643,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { */ const availableCommandsAtom = atom([]); const childSessionHydrationStatesAtom = atom>(new Map()); + const childSessionErrorsAtom = atom>(new Map()); const hasOlderMessagesAtom = atom(false); const isLoadingOlderMessagesAtom = atom(false); const olderMessagesErrorAtom = atom(null); @@ -726,6 +728,10 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { return (childSessionId: string): ChildSessionHydrationState => states.get(childSessionId) ?? IDLE_CHILD_SESSION_HYDRATION_STATE; }); + const childSessionErrorAtom = atom(get => { + const errors = get(childSessionErrorsAtom); + return (childSessionId: string): string | null => errors.get(childSessionId) ?? null; + }); // Private mutable state let activeSessionId: KiloSessionId | null = null; @@ -853,6 +859,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { store.set(failedPromptAtom, null); store.set(fetchedSessionDataAtom, null); store.set(childSessionHydrationStatesAtom, new Map()); + store.set(childSessionErrorsAtom, new Map()); store.set(chatUIAtom, { shouldAutoScroll: true }); store.set(availableCommandsAtom, []); store.set(hasOlderMessagesAtom, false); @@ -1690,6 +1697,11 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { } store.set(errorAtom, message); }, + onChildSessionError: (childSessionId, message) => { + const next = new Map(store.get(childSessionErrorsAtom)); + next.set(childSessionId, message); + store.set(childSessionErrorsAtom, next); + }, onMessageFailed: (_messageId, deliveryState) => { if (deliveryState.reason !== 'exhausted') return; setIndicator({ @@ -2188,6 +2200,7 @@ function createSessionManager(config: SessionManagerConfig): SessionManager { contextUsage: contextUsageAtom, childMessages: childMessagesAtom, childSessionHydrationState: childSessionHydrationStateAtom, + childSessionError: childSessionErrorAtom, hasOlderMessages: hasOlderMessagesAtom, isLoadingOlderMessages: isLoadingOlderMessagesAtom, olderMessagesError: olderMessagesErrorAtom, diff --git a/packages/cloud-agent-sdk/src/session.ts b/packages/cloud-agent-sdk/src/session.ts index e1d4a79662..5d65347ff0 100644 --- a/packages/cloud-agent-sdk/src/session.ts +++ b/packages/cloud-agent-sdk/src/session.ts @@ -56,6 +56,7 @@ type CloudAgentSessionConfig = { websocketBaseUrl?: string; storage?: SessionStorage; onError?: (message: string) => void; + onChildSessionError?: (sessionId: string, message: string) => void; onQuestionAsked?: (requestId: string, questions?: QuestionInfo[]) => void; onQuestionResolved?: (requestId: string) => void; onPermissionAsked?: ( @@ -229,6 +230,7 @@ function createCloudAgentSession(config: CloudAgentSessionConfig): CloudAgentSes const serviceState = createServiceState({ rootSessionId: config.kiloSessionId, onError: config.onError, + onChildSessionError: config.onChildSessionError, onQuestionAsked: config.onQuestionAsked, onQuestionResolved: config.onQuestionResolved, onPermissionAsked: config.onPermissionAsked, From 801b15f55548262a05d5957da082f46d88035d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 02:59:12 +0200 Subject: [PATCH 2/4] fix(mobile): scope child session error to the child sheet Read the per-child error atom in the session detail screen and pass it into the child sheet. The sheet shows a runtime error only in the failed child: an error state when the child has no messages, an error banner above the transcript when it has messages. The parent indicator, error atom, and composer are untouched. --- .../agents/child-session-sheet-state.test.ts | 51 +++++++++++++++++++ .../agents/child-session-sheet-state.ts | 12 +++-- .../components/agents/child-session-sheet.tsx | 33 +++++++++--- .../agents/session-detail-content.tsx | 2 + 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/components/agents/child-session-sheet-state.test.ts b/apps/mobile/src/components/agents/child-session-sheet-state.test.ts index 42af4a528c..33845fe6f7 100644 --- a/apps/mobile/src/components/agents/child-session-sheet-state.test.ts +++ b/apps/mobile/src/components/agents/child-session-sheet-state.test.ts @@ -34,6 +34,23 @@ describe('getChildSessionSheetState', () => { ).toBe('empty'); }); + it('shows an empty state when the session error is null', () => { + expect( + getChildSessionSheetState( + { + status: 'ready', + cursor: null, + hasOlder: false, + isLoadingOlder: false, + olderError: null, + omittedItemCount: 0, + }, + 0, + null + ) + ).toBe('empty'); + }); + it('shows an error after failed hydration with no messages', () => { expect(getChildSessionSheetState({ status: 'error', message: 'Failed' }, 0)).toBe('error'); }); @@ -41,6 +58,40 @@ describe('getChildSessionSheetState', () => { it('keeps rendering messages if a refresh fails', () => { expect(getChildSessionSheetState({ status: 'error', message: 'Failed' }, 1)).toBe('content'); }); + + it('shows an error for a runtime error with no messages and hydration ready', () => { + expect( + getChildSessionSheetState( + { + status: 'ready', + cursor: null, + hasOlder: false, + isLoadingOlder: false, + olderError: null, + omittedItemCount: 0, + }, + 0, + 'Requests ending with a model turn are not supported.' + ) + ).toBe('error'); + }); + + it('keeps rendering messages when a runtime error exists', () => { + expect( + getChildSessionSheetState( + { + status: 'ready', + cursor: null, + hasOlder: false, + isLoadingOlder: false, + olderError: null, + omittedItemCount: 0, + }, + 1, + 'Requests ending with a model turn are not supported.' + ) + ).toBe('content'); + }); }); describe('openChildSessionSheet / closeChildSessionSheet', () => { diff --git a/apps/mobile/src/components/agents/child-session-sheet-state.ts b/apps/mobile/src/components/agents/child-session-sheet-state.ts index bf246b13ca..4dd862984f 100644 --- a/apps/mobile/src/components/agents/child-session-sheet-state.ts +++ b/apps/mobile/src/components/agents/child-session-sheet-state.ts @@ -19,17 +19,21 @@ export type ChildSessionSheetMountState = { export function getChildSessionSheetState( hydrationState: ChildSessionHydrationState, - messageCount: number + messageCount: number, + sessionError?: string | null ): ChildSessionSheetState { if (messageCount > 0) { return 'content'; } - if (hydrationState.status === 'ready') { - return 'empty'; - } if (hydrationState.status === 'error') { return 'error'; } + if (sessionError) { + return 'error'; + } + if (hydrationState.status === 'ready') { + return 'empty'; + } return 'loading'; } diff --git a/apps/mobile/src/components/agents/child-session-sheet.tsx b/apps/mobile/src/components/agents/child-session-sheet.tsx index 209fc28738..05de7c9ee8 100644 --- a/apps/mobile/src/components/agents/child-session-sheet.tsx +++ b/apps/mobile/src/components/agents/child-session-sheet.tsx @@ -24,6 +24,7 @@ import { MessageErrorBoundary } from './message-error-boundary'; import { PartDetailSheetHost } from './part-detail-sheet-host'; import { getChildSessionSheetState } from './child-session-sheet-state'; import { SessionMessageList } from './session-message-list'; +import { SessionStatusIndicator } from './session-status-indicator'; import { WorkingIndicator } from './working-indicator'; type ChildSessionSheetProps = { @@ -32,6 +33,7 @@ type ChildSessionSheetProps = { title: string; getChildMessages: (sessionId: string) => StoredMessage[]; hydrationState: ChildSessionHydrationState; + sessionError: string | null; isStreaming: boolean; hasOlderMessages: boolean; isLoadingOlderMessages: boolean; @@ -53,6 +55,7 @@ export function ChildSessionSheet({ title, getChildMessages, hydrationState, + sessionError, isStreaming, hasOlderMessages, isLoadingOlderMessages, @@ -67,7 +70,7 @@ export function ChildSessionSheet({ modelOptions, }: Readonly) { const messages = getChildMessages(sessionId); - const state = getChildSessionSheetState(hydrationState, messages.length); + const state = getChildSessionSheetState(hydrationState, messages.length, sessionError); const modelLabel = getChildSessionModelLabel(messages, modelOptions ?? []); // Safe-area context can return 0 inside a RN `Modal` (pageSheet doesn't // always propagate the home-indicator inset), so we floor the value with @@ -107,13 +110,16 @@ export function ChildSessionSheet({ /> ); } else if (state === 'error') { - content = ( - - ); + content = + hydrationState.status === 'error' ? ( + + ) : ( + + ); } else if (state === 'empty') { content = ( + + {content} + + ); + } + return ( Date: Fri, 21 Aug 2026 18:21:17 +0200 Subject: [PATCH 3/4] fix(cloud-agent-sdk): adopt server root session ID --- .../cloud-agent-sdk/src/service-state.test.ts | 17 +++++++++++++++++ packages/cloud-agent-sdk/src/service-state.ts | 6 +++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/cloud-agent-sdk/src/service-state.test.ts b/packages/cloud-agent-sdk/src/service-state.test.ts index ffdc0cd61b..9ac91419f2 100644 --- a/packages/cloud-agent-sdk/src/service-state.test.ts +++ b/packages/cloud-agent-sdk/src/service-state.test.ts @@ -322,6 +322,23 @@ describe('createServiceState', () => { }); }); + it('adopts the server-reported root session ID for errors', () => { + const onError = jest.fn(); + const onChildSessionError = jest.fn(); + const state = createServiceState(makeConfig({ onError, onChildSessionError })); + + state.process({ type: 'session.created', info: makeSession('server-root') }); + state.process({ + type: 'session.error', + error: 'Root session failed.', + sessionId: 'server-root', + }); + + expect(onError).toHaveBeenCalledWith('Root session failed.'); + expect(onChildSessionError).not.toHaveBeenCalled(); + expect(state.getStatus()).toEqual({ type: 'error', message: 'Root session failed.' }); + }); + it('keeps events without a sessionId on the legacy root path', () => { const onError = jest.fn(); const onChildSessionError = jest.fn(); diff --git a/packages/cloud-agent-sdk/src/service-state.ts b/packages/cloud-agent-sdk/src/service-state.ts index fe4ee7fd66..0d0eb46625 100644 --- a/packages/cloud-agent-sdk/src/service-state.ts +++ b/packages/cloud-agent-sdk/src/service-state.ts @@ -109,6 +109,7 @@ function upsertByRequestId( } function createServiceState(config: ServiceStateConfig): ServiceState { + let rootSessionId = config.rootSessionId; let activity: SessionActivity = INITIAL_ACTIVITY; let status: AgentStatus = IDLE_STATUS; let cloudStatus: CloudStatus | null = null; @@ -135,7 +136,7 @@ function createServiceState(config: ServiceStateConfig): ServiceState { } function isRootSession(sessionId: string): boolean { - return sessionId === config.rootSessionId; + return sessionId === rootSessionId; } function processSessionStatus(event: Extract): void { @@ -246,6 +247,9 @@ function createServiceState(config: ServiceStateConfig): ServiceState { } function processSessionCreated(event: Extract): void { + if (event.info.parentID == null) { + rootSessionId = event.info.id; + } // Only track root session info if (isRootSession(event.info.id)) { sessionInfo = event.info; From 09e78e9d37ca043ebba4684bb7c4cf4f14bd4106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 21 Aug 2026 18:51:48 +0200 Subject: [PATCH 4/4] test(mobile): set child session error fixture --- .../src/components/agents/child-session-sheet.mounted.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/src/components/agents/child-session-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/child-session-sheet.mounted.test.tsx index b932a1c4a6..517b76ea60 100644 --- a/apps/mobile/src/components/agents/child-session-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/agents/child-session-sheet.mounted.test.tsx @@ -131,6 +131,7 @@ function buildProps({ title: 'Subagent', getChildMessages, hydrationState, + sessionError: null, isStreaming: false, hasOlderMessages: false, isLoadingOlderMessages: false,