Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,64 @@ 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');
});

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', () => {
Expand Down
12 changes: 8 additions & 4 deletions apps/mobile/src/components/agents/child-session-sheet-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ function buildProps({
title: 'Subagent',
getChildMessages,
hydrationState,
sessionError: null,
isStreaming: false,
hasOlderMessages: false,
isLoadingOlderMessages: false,
Expand Down
33 changes: 25 additions & 8 deletions apps/mobile/src/components/agents/child-session-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -32,6 +33,7 @@ type ChildSessionSheetProps = {
title: string;
getChildMessages: (sessionId: string) => StoredMessage[];
hydrationState: ChildSessionHydrationState;
sessionError: string | null;
isStreaming: boolean;
hasOlderMessages: boolean;
isLoadingOlderMessages: boolean;
Expand All @@ -53,6 +55,7 @@ export function ChildSessionSheet({
title,
getChildMessages,
hydrationState,
sessionError,
isStreaming,
hasOlderMessages,
isLoadingOlderMessages,
Expand All @@ -67,7 +70,7 @@ export function ChildSessionSheet({
modelOptions,
}: Readonly<ChildSessionSheetProps>) {
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
Expand Down Expand Up @@ -107,13 +110,16 @@ export function ChildSessionSheet({
/>
);
} else if (state === 'error') {
content = (
<QueryError
title="Could not load subagent session"
message={hydrationState.status === 'error' ? hydrationState.message : undefined}
onRetry={onRetry}
/>
);
content =
hydrationState.status === 'error' ? (
<QueryError
title="Could not load subagent session"
message={hydrationState.message}
onRetry={onRetry}
/>
) : (
<QueryError title="Subagent session failed" message={sessionError ?? undefined} />
);
} else if (state === 'empty') {
content = (
<EmptyState
Expand All @@ -134,6 +140,17 @@ export function ChildSessionSheet({
);
}

if (state === 'content' && sessionError) {
content = (
<View className="flex-1">
<SessionStatusIndicator
indicator={{ type: 'error', message: sessionError, timestamp: 0 }}
/>
{content}
</View>
);
}

return (
<Modal
visible={visible}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export function SessionDetailContent({
);
const getChildMessages = useAtomValue(manager.atoms.childMessages);
const getChildSessionHydrationState = useAtomValue(manager.atoms.childSessionHydrationState);
const getChildSessionError = useAtomValue(manager.atoms.childSessionError);
const pendingMessages = useAtomValue(manager.atoms.pendingMessages);
const activeSessionType = useAtomValue(manager.atoms.activeSessionType);
const remoteModelState = useAtomValue(manager.atoms.remoteModelState);
Expand Down Expand Up @@ -1159,6 +1160,7 @@ export function SessionDetailContent({
title={childSessionSheet.sheet.title}
getChildMessages={getChildMessages}
hydrationState={getChildSessionHydrationState(childSessionSheet.sheet.sessionId)}
sessionError={getChildSessionError(childSessionSheet.sheet.sessionId)}
isStreaming={getChildSessionStreaming(messages, childSessionSheet.sheet.sessionId)}
hasOlderMessages={childHasOlderMessages}
isLoadingOlderMessages={childIsLoadingOlderMessages}
Expand Down
67 changes: 67 additions & 0 deletions packages/cloud-agent-sdk/src/service-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,73 @@ 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('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();
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 }));
Expand Down
15 changes: 14 additions & 1 deletion packages/cloud-agent-sdk/src/service-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?:
Expand Down Expand Up @@ -108,6 +109,7 @@ function upsertByRequestId<T extends { requestId: string }>(
}

function createServiceState(config: ServiceStateConfig): ServiceState {
let rootSessionId = config.rootSessionId;
let activity: SessionActivity = INITIAL_ACTIVITY;
let status: AgentStatus = IDLE_STATUS;
let cloudStatus: CloudStatus | null = null;
Expand All @@ -134,7 +136,7 @@ function createServiceState(config: ServiceStateConfig): ServiceState {
}

function isRootSession(sessionId: string): boolean {
return sessionId === config.rootSessionId;
return sessionId === rootSessionId;
}

function processSessionStatus(event: Extract<ServiceEvent, { type: 'session.status' }>): void {
Expand Down Expand Up @@ -230,13 +232,24 @@ function createServiceState(config: ServiceStateConfig): ServiceState {
function processSessionError(event: Extract<ServiceEvent, { type: 'session.error' }>): 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)) {
Comment thread
iscekic marked this conversation as resolved.
config.onChildSessionError?.(event.sessionId, event.error);
return;
}

config.onError?.(event.error);
status = { type: 'error', message: event.error };

notify();
}

function processSessionCreated(event: Extract<ServiceEvent, { type: 'session.created' }>): void {
if (event.info.parentID == null) {
rootSessionId = event.info.id;
}
// Only track root session info
if (isRootSession(event.info.id)) {
sessionInfo = event.info;
Expand Down
Loading