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
79 changes: 79 additions & 0 deletions apps/mobile/src/components/agents/child-session-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
import * as React from 'react';
import { describe, expect, it, vi } from 'vitest';

import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';

import { ChildSessionMessage, ChildSessionSection } from './child-session-section';
import { ChildSessionModelLabel } from './child-session-model-label';
import { MessageErrorBoundary } from './message-error-boundary';

vi.mock('react-native', () => ({
Expand Down Expand Up @@ -81,6 +84,17 @@ function makeMessage(parts: Part[]): StoredMessage {
};
}

const modelOption: SessionModelOption = {
id: 'kilo/model',
name: 'Test Model',
displayId: 'model',
variants: [],
isPreferred: false,
showGatewayMetadata: false,
provider: { id: 'kilo', name: 'Kilo' },
modelRef: { providerID: 'kilo', modelID: 'model' },
};

function findAll(
node: unknown,
predicate: (el: React.ReactElement) => boolean
Expand Down Expand Up @@ -210,3 +224,68 @@ describe('ChildSessionMessage routing seam', () => {
expect(renderPart).not.toHaveBeenCalled();
});
});

describe('ChildSessionSection model label', () => {
it('renders the model label when child messages carry model data', () => {
const childMessages = [makeMessage([makeTextPart('child text')])];
const onOpenChildSession = vi.fn<(sessionId: string, title: string) => void>();
const part = makeToolPart('task', taskCompletedState);

// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const root = ChildSessionSection({
part,
childMessages,
modelOptions: [modelOption],
onOpenChildSession,
});

const labels = findByType(root, ChildSessionModelLabel);
expect(labels).toHaveLength(1);
expect(labels[0]?.props).toMatchObject({ modelLabel: 'Test Model' });
});

it('hides the model label when child messages are empty and still renders the card', () => {
const onOpenChildSession = vi.fn<(sessionId: string, title: string) => void>();
const part = makeToolPart('task', taskCompletedState);

// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const root = ChildSessionSection({
part,
childMessages: [],
modelOptions: [modelOption],
onOpenChildSession,
});

expect(findByType(root, ChildSessionModelLabel)).toHaveLength(0);
const taskNameTexts = findAll(
root,
el => el.type === 'Text' && textChildren(el) === 'child task'
);
expect(taskNameTexts).toHaveLength(1);
});

it('passes modelOptions through to the nested ChildSessionSection', () => {
const childMessages = [makeMessage([makeTextPart('child text')])];
const getChildMessages = vi.fn((id: string) => (id === 'child-1' ? childMessages : []));
const renderPart = vi.fn();
const onOpenChildSession = vi.fn<(sessionId: string, title: string) => void>();

// eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call
const root = ChildSessionMessage({
message: makeMessage([makeToolPart('task', taskCompletedState)]),
depth: 0,
getChildMessages,
renderPart,
onOpenChildSession,
modelOptions: [modelOption],
});

const sections = findByType(root, ChildSessionSection);
expect(sections).toHaveLength(1);
const section = sections[0];
if (!section) {
throw new Error('expected ChildSessionSection');
}
expect(section.props).toMatchObject({ modelOptions: [modelOption] });
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/components/ui/selectable-text.mounted.test.tsx) */
import { createElement } from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';

import { ChildSessionModelLabel } from './child-session-model-label';

// The real `@/components/ui/text` loads `@rn-primitives/slot`, whose node_modules
// `.mjs` contains JSX that this pipeline cannot transform. Provide a real context
// so any `useContext(TextClassContext)` consumer still resolves.
vi.mock('@/components/ui/text', async () => {
const React = await import('react');
return {
Text: 'Text',
TextClassContext: React.createContext<string | undefined>(undefined),
};
});

describe('ChildSessionModelLabel mounted', () => {
it('renders the literal Model: accessibility label with the model name as text', async () => {
const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = {
current: undefined,
};
await act(async () => {
await Promise.resolve();
rendererRef.current = TestRenderer.create(
createElement(ChildSessionModelLabel, { modelLabel: 'Claude Sonnet 4' })
);
});
const renderer = rendererRef.current;
if (!renderer) {
throw new Error('renderer was not created');
}

const nodes = renderer.root.findAll(
node => node.props.accessibilityLabel === 'Model: Claude Sonnet 4'
);
expect(nodes).toHaveLength(1);
expect(nodes[0]?.children).toEqual(['Claude Sonnet 4']);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/components/agents/child-session-model-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Text } from '@/components/ui/text';

export function ChildSessionModelLabel({ modelLabel }: Readonly<{ modelLabel: string }>) {
return (
<Text
className="text-xs leading-4 text-muted-foreground"
numberOfLines={1}
accessibilityLabel={`Model: ${modelLabel}`}
>
{modelLabel}
</Text>
);
}
146 changes: 146 additions & 0 deletions apps/mobile/src/components/agents/child-session-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';

import {
type AssistantMessage,
type Part,
type StepFinishPart,
type StoredMessage,
type UserMessage,
} from '@kilocode/cloud-agent-sdk';

import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';

import { getChildSessionModelLabel } from './child-session-model';

/**
* Child-session model label resolution.
*
* Contract:
* - scans from the last message to the first
* - the last assistant message with resolvable model data wins
* - returns null for empty transcripts, user-only transcripts, and legacy
* blank provider/model fields
* - prefers the routed model on the last step-finish part over info-level
* provider/model
* - falls back to a date-suffix-stripped raw modelID for unknown models
*/

const catalogOption: SessionModelOption = {
id: 'anthropic/claude-sonnet-4',
name: 'Claude Sonnet 4',
displayId: 'claude-sonnet-4',
variants: [],
isPreferred: false,
showGatewayMetadata: false,
provider: { id: 'anthropic', name: 'Anthropic' },
modelRef: { providerID: 'anthropic', modelID: 'claude-sonnet-4' },
};

function assistantInfo(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
return {
id: 'msg-1',
sessionID: 'ses-1',
role: 'assistant',
time: { created: 1 },
parentID: 'msg-0',
modelID: 'claude-sonnet-4',
providerID: 'anthropic',
mode: 'code',
agent: 'test',
path: { cwd: '/', root: '/' },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...overrides,
};
}

function userInfo(overrides: Partial<UserMessage> = {}): UserMessage {
return {
id: 'u-1',
sessionID: 'ses-1',
role: 'user',
time: { created: 1 },
agent: 'test',
model: { providerID: 'anthropic', modelID: 'claude-sonnet-4' },
...overrides,
};
}

function stepFinish(overrides: Partial<StepFinishPart> = {}): StepFinishPart {
return {
id: 'p-finish',
sessionID: 'ses-1',
messageID: 'msg-1',
type: 'step-finish',
reason: 'stop',
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...overrides,
};
}

function storedMessage(info: AssistantMessage | UserMessage, parts: Part[] = []): StoredMessage {
return { info, parts };
}

// The `model` field is present on the wire and in the Zod contract but
// absent from the generated `StepFinishPart` type, so we cast — same
// pattern as `message-model-label.test.ts` and `part-utils.test.ts`.
function stepFinishWithRouted(
routed: { providerID: string; modelID: string },
overrides: Partial<StepFinishPart> = {}
): StepFinishPart {
return Object.assign(stepFinish(overrides), { model: routed }) as StepFinishPart;
}

describe('getChildSessionModelLabel', () => {
it('returns null for an empty transcript', () => {
expect(getChildSessionModelLabel([], [catalogOption])).toBeNull();
});

it('returns null for a user-only transcript', () => {
const messages = [storedMessage(userInfo())];
expect(getChildSessionModelLabel(messages, [catalogOption])).toBeNull();
});

it('returns the catalog name when info matches a catalog option', () => {
const messages = [storedMessage(assistantInfo())];
expect(getChildSessionModelLabel(messages, [catalogOption])).toBe('Claude Sonnet 4');
});

it('strips a trailing date suffix for an unknown model', () => {
const messages = [
storedMessage(assistantInfo({ providerID: 'kilo', modelID: 'claude-sonnet-4-20260101' })),
];
expect(getChildSessionModelLabel(messages, [catalogOption])).toBe('claude-sonnet-4');
});

it('returns null for legacy blank providerID/modelID', () => {
const messages = [storedMessage(assistantInfo({ providerID: '', modelID: '' }))];
expect(getChildSessionModelLabel(messages, [catalogOption])).toBeNull();
});

it('prefers the routed model on the last step-finish part', () => {
const info = assistantInfo({
id: 'm1',
providerID: 'kilo',
modelID: 'kilo-auto/efficient',
});
const finish = stepFinishWithRouted(
{ providerID: 'anthropic', modelID: 'claude-sonnet-4' },
{ id: 'sf-1' }
);
const messages = [storedMessage(info, [finish])];
expect(getChildSessionModelLabel(messages, [catalogOption])).toBe('Claude Sonnet 4');
});

it('lets the last assistant message with model data win', () => {
const first = storedMessage(
assistantInfo({ id: 'm1', providerID: 'openai', modelID: 'gpt-4o' })
);
const second = storedMessage(
assistantInfo({ id: 'm2', providerID: 'anthropic', modelID: 'claude-sonnet-4' })
);
expect(getChildSessionModelLabel([first, second], [catalogOption])).toBe('Claude Sonnet 4');
});
});
27 changes: 27 additions & 0 deletions apps/mobile/src/components/agents/child-session-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { type StoredMessage } from '@kilocode/cloud-agent-sdk';

import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';

import { resolveMessageDisplayModel } from './message-model-label';
import { friendlyModelName } from './session-model-display';

/**
* Resolve the display model for a child session from its transcript. The last
* assistant message with model data wins; returns `null` when no model data
* exists.
*/
export function getChildSessionModelLabel(
childMessages: StoredMessage[],
modelOptions: SessionModelOption[]
): string | null {
for (let i = childMessages.length - 1; i >= 0; i -= 1) {
const message = childMessages[i];
if (message) {
const resolved = resolveMessageDisplayModel(message);
if (resolved) {
return friendlyModelName(resolved.providerID, resolved.modelID, modelOptions);
}
}
}
return null;
}
Loading