diff --git a/apps/mobile/src/components/agents/child-session-card-state.test.ts b/apps/mobile/src/components/agents/child-session-card-state.test.ts new file mode 100644 index 0000000000..eafa58aea8 --- /dev/null +++ b/apps/mobile/src/components/agents/child-session-card-state.test.ts @@ -0,0 +1,312 @@ +import { type KiloSessionId, type StoredMessage, type ToolPart } from 'cloud-agent-sdk'; +import { describe, expect, it } from 'vitest'; + +import { + getChildSessionActivityLabel, + getChildSessionCardState, + getTaskToolSessionId, +} from './child-session-card-state'; + +const subagentSessionId = 'ses-child' as KiloSessionId; + +function makeToolPart(tool: string, state: ToolPart['state']): ToolPart { + return { + id: 'p1', + sessionID: 'ses-1', + messageID: 'msg-1', + type: 'tool', + tool, + callID: 'call-1', + state, + }; +} + +function makeTaskPart( + status: 'pending' | 'running' | 'completed' | 'error', + input: Record = {} +): ToolPart { + if (status === 'pending') { + return makeToolPart('task', { status: 'pending', input, raw: '' }); + } + if (status === 'running') { + return makeToolPart('task', { + status: 'running', + input, + time: { start: 1 }, + metadata: { sessionId: subagentSessionId }, + }); + } + if (status === 'completed') { + return makeToolPart('task', { + status: 'completed', + input, + output: 'done', + title: 'Task', + metadata: { sessionId: subagentSessionId }, + time: { start: 1, end: 2 }, + }); + } + return makeToolPart('task', { + status: 'error', + input, + error: 'failed', + metadata: { sessionId: subagentSessionId }, + time: { start: 1, end: 2 }, + }); +} + +function makeAssistantMessage(parts: ToolPart[], id = 'msg-1'): StoredMessage { + return { + info: { + id, + sessionID: 'ses-1', + role: 'assistant', + time: { created: 1 }, + parentID: 'msg-0', + modelID: 'claude', + providerID: 'anthropic', + mode: 'code', + agent: 'build', + path: { cwd: '/', root: '/' }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts, + }; +} + +describe('getChildSessionCardState', () => { + it('falls back to Subagent / Task / Waiting for activity for a pending task with empty input', () => { + const part = makeTaskPart('pending'); + expect(getChildSessionCardState(part, [])).toEqual({ + agentName: 'Subagent', + taskName: 'Task', + latestActivity: 'Waiting for activity', + }); + }); + + it('uses subagent_type and description from input', () => { + const part = makeTaskPart('pending', { subagent_type: 'Coder', description: 'Refactor auth' }); + expect(getChildSessionCardState(part, [])).toEqual({ + agentName: 'Coder', + taskName: 'Refactor auth', + latestActivity: 'Waiting for activity', + }); + }); + + it('truncates prompt when description is absent', () => { + const part = makeTaskPart('pending', { prompt: 'a'.repeat(100) }); + const state = getChildSessionCardState(part, []); + expect(state.taskName).toBe(`${'a'.repeat(60)}\u2026`); + }); + + it('reads a completed read tool from child messages and derives its filename context', () => { + const part = makeTaskPart('running', { + subagent_type: 'Researcher', + description: 'Check spec', + }); + const readPart = makeToolPart('read', { + status: 'completed', + input: { filePath: '/project/docs/spec.md' }, + output: 'content', + title: 'read', + metadata: {}, + time: { start: 1, end: 2 }, + }); + const messages = [makeAssistantMessage([readPart])]; + expect(getChildSessionCardState(part, messages)).toEqual({ + agentName: 'Researcher', + taskName: 'Check spec', + latestActivity: { tool: 'read', context: 'spec.md' }, + }); + }); + + it('keeps the latest completed or errored tool when it supersedes an older running tool', () => { + const part = makeTaskPart('running', { subagent_type: 'Builder', description: 'Fix bug' }); + const olderRunningTool = makeToolPart('bash', { + status: 'running', + input: { command: 'git status' }, + time: { start: 1 }, + metadata: {}, + }); + const newerCompletedTool = makeToolPart('edit', { + status: 'completed', + input: { filePath: '/project/src/auth.ts' }, + output: 'done', + title: 'edit', + metadata: {}, + time: { start: 2, end: 3 }, + }); + const messages = [makeAssistantMessage([olderRunningTool, newerCompletedTool])]; + expect(getChildSessionCardState(part, messages)).toEqual({ + agentName: 'Builder', + taskName: 'Fix bug', + latestActivity: { tool: 'edit', context: 'auth.ts' }, + }); + }); + + it('retains latest activity from a newer errored tool over an older running tool', () => { + const part = makeTaskPart('running', { subagent_type: 'Tester', description: 'Run tests' }); + const olderRunningTool = makeToolPart('bash', { + status: 'running', + input: { command: 'npm test' }, + time: { start: 1 }, + metadata: {}, + }); + const newerErrorTool = makeToolPart('glob', { + status: 'error', + input: { pattern: '**/*.test.tsx' }, + error: 'not found', + time: { start: 2, end: 3 }, + }); + const messages = [makeAssistantMessage([olderRunningTool, newerErrorTool])]; + expect(getChildSessionCardState(part, messages)).toEqual({ + agentName: 'Tester', + taskName: 'Run tests', + latestActivity: { tool: 'glob', context: '**/*.test.tsx' }, + }); + }); + + it('scans messages from newest to oldest, preferring the latest assistant message', () => { + const part = makeTaskPart('running', { subagent_type: 'Agent', description: 'Find files' }); + const newestTool = makeToolPart('grep', { + status: 'completed', + input: { pattern: 'handler' }, + output: 'matches', + title: 'grep', + metadata: {}, + time: { start: 3, end: 4 }, + }); + const oldestTool = makeToolPart('read', { + status: 'running', + input: { filePath: '/project/README.md' }, + time: { start: 1 }, + metadata: {}, + }); + const messages = [ + makeAssistantMessage([oldestTool], 'msg-1'), + makeAssistantMessage([newestTool], 'msg-2'), + ]; + expect(getChildSessionCardState(part, messages)).toEqual({ + agentName: 'Agent', + taskName: 'Find files', + latestActivity: { tool: 'grep', context: 'handler' }, + }); + }); + + it('truncates long bash commands to the first 20 characters', () => { + const part = makeTaskPart('running', { subagent_type: 'Shell', description: 'Deploy' }); + const bashTool = makeToolPart('bash', { + status: 'running', + input: { command: 'very-long-command-name --flag value' }, + time: { start: 1 }, + metadata: {}, + }); + const messages = [makeAssistantMessage([bashTool])]; + const state = getChildSessionCardState(part, messages); + expect(state.latestActivity).toEqual({ + tool: 'bash', + context: 'very-long-command-na\u2026', + }); + }); + + it('truncates long glob patterns to 25 characters', () => { + const part = makeTaskPart('running', { subagent_type: 'Finder', description: 'Locate' }); + const globTool = makeToolPart('glob', { + status: 'running', + input: { pattern: 'src/components/**/*.test.tsx' }, + time: { start: 1 }, + metadata: {}, + }); + const messages = [makeAssistantMessage([globTool])]; + const state = getChildSessionCardState(part, messages); + expect(state.latestActivity).toEqual({ + tool: 'glob', + context: 'src/components/**/*.test.\u2026', + }); + }); + + it('truncates long nested task descriptions to 30 characters', () => { + const part = makeTaskPart('running', { subagent_type: 'Planner', description: 'Plan' }); + const nestedTaskTool = makeToolPart('task', { + status: 'running', + input: { description: 'This is a very long nested task description indeed' }, + time: { start: 1 }, + metadata: {}, + }); + const messages = [makeAssistantMessage([nestedTaskTool])]; + const state = getChildSessionCardState(part, messages); + expect(state.latestActivity).toEqual({ + tool: 'task', + context: 'This is a very long nested tas\u2026', + }); + }); + + it('includes non-assistant messages without throwing, ignoring them for activity', () => { + const part = makeTaskPart('pending', { subagent_type: 'Helper', description: 'Help' }); + const userMessage: StoredMessage = { + info: { + id: 'msg-user', + sessionID: 'ses-1', + role: 'user', + time: { created: 1 }, + agent: 'build', + model: { providerID: 'a', modelID: 'b' }, + }, + parts: [], + }; + expect(getChildSessionCardState(part, [userMessage])).toEqual({ + agentName: 'Helper', + taskName: 'Help', + latestActivity: 'Waiting for activity', + }); + }); +}); + +describe('getChildSessionActivityLabel', () => { + it('returns the activity string with context when present', () => { + expect(getChildSessionActivityLabel({ tool: 'read', context: 'spec.md' })).toBe('read spec.md'); + }); + + it('returns the tool name when no context is present', () => { + expect(getChildSessionActivityLabel({ tool: 'bash' })).toBe('bash'); + }); + + it('passes through the waiting text unchanged', () => { + expect(getChildSessionActivityLabel('Waiting for activity')).toBe('Waiting for activity'); + }); +}); + +describe('getTaskToolSessionId', () => { + it('returns undefined for a pending task with no metadata', () => { + const part = makeTaskPart('pending'); + expect(getTaskToolSessionId(part)).toBeUndefined(); + }); + + it('returns the session id from a running task metadata', () => { + const part = makeTaskPart('running'); + expect(getTaskToolSessionId(part)).toBe(subagentSessionId); + }); + + it('returns the session id from a completed task metadata', () => { + const part = makeTaskPart('completed'); + expect(getTaskToolSessionId(part)).toBe(subagentSessionId); + }); + + it('returns the session id from an errored task metadata', () => { + const part = makeTaskPart('error'); + expect(getTaskToolSessionId(part)).toBe(subagentSessionId); + }); + + it('returns undefined for non-task tools', () => { + const readPart = makeToolPart('read', { + status: 'completed', + input: { filePath: 'x' }, + output: 'y', + title: 'read', + metadata: {}, + time: { start: 1, end: 2 }, + }); + expect(getTaskToolSessionId(readPart)).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/components/agents/child-session-card-state.ts b/apps/mobile/src/components/agents/child-session-card-state.ts new file mode 100644 index 0000000000..d4446fc3e9 --- /dev/null +++ b/apps/mobile/src/components/agents/child-session-card-state.ts @@ -0,0 +1,106 @@ +import { type KiloSessionId, type StoredMessage, type ToolPart } from 'cloud-agent-sdk'; + +import { isToolPart } from './part-types'; +import { getFilename, truncateText } from './tool-card-utils'; + +export type ChildSessionActivity = { tool: string; context?: string }; + +export type ChildSessionCardState = { + agentName: string; + taskName: string; + latestActivity: ChildSessionActivity | 'Waiting for activity'; +}; + +function getStringProperty(obj: unknown, key: string): string | undefined { + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { + return undefined; + } + const value = (obj as Record)[key]; + return typeof value === 'string' ? value : undefined; +} + +function getToolContext(p: ToolPart): string | undefined { + const input = p.state.input; + + if (p.tool === 'read' || p.tool === 'edit' || p.tool === 'write') { + const filePath = getStringProperty(input, 'filePath'); + return filePath ? getFilename(filePath) : undefined; + } + if (p.tool === 'bash') { + const command = getStringProperty(input, 'command'); + if (!command) { + return undefined; + } + const firstWord = command.split(/\s+/)[0]; + if (!firstWord) { + return undefined; + } + return truncateText(firstWord, 20); + } + if (p.tool === 'glob' || p.tool === 'grep') { + const pattern = getStringProperty(input, 'pattern'); + if (!pattern) { + return undefined; + } + return truncateText(pattern, 25); + } + if (p.tool === 'task') { + const description = getStringProperty(input, 'description'); + if (!description) { + return undefined; + } + return truncateText(description, 30); + } + return undefined; +} + +function findLatestToolPart(messages: StoredMessage[]): ToolPart | undefined { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const msg = messages[i]; + if (msg?.info.role === 'assistant') { + for (let j = msg.parts.length - 1; j >= 0; j -= 1) { + const part = msg.parts[j]; + if (part && isToolPart(part)) { + return part; + } + } + } + } + return undefined; +} + +export function getChildSessionCardState( + part: ToolPart, + childMessages: StoredMessage[] +): ChildSessionCardState { + const input = part.state.input; + const agentName = getStringProperty(input, 'subagent_type') ?? 'Subagent'; + const description = getStringProperty(input, 'description'); + const prompt = getStringProperty(input, 'prompt'); + const taskName = description ?? (prompt ? truncateText(prompt, 60) : 'Task'); + + const latestToolPart = findLatestToolPart(childMessages); + const latestActivity: ChildSessionActivity | 'Waiting for activity' = latestToolPart + ? { tool: latestToolPart.tool, context: getToolContext(latestToolPart) } + : 'Waiting for activity'; + + return { agentName, taskName, latestActivity }; +} + +export function getChildSessionActivityLabel(activity: ChildSessionActivity | string): string { + if (typeof activity === 'string') { + return activity; + } + return activity.context ? `${activity.tool} ${activity.context}` : activity.tool; +} + +export function getTaskToolSessionId(part: ToolPart): KiloSessionId | undefined { + if (part.tool !== 'task') { + return undefined; + } + const { state } = part; + if (state.status === 'running' || state.status === 'completed' || state.status === 'error') { + return getStringProperty(state.metadata, 'sessionId') as KiloSessionId | undefined; + } + return undefined; +} diff --git a/apps/mobile/src/components/agents/child-session-section.tsx b/apps/mobile/src/components/agents/child-session-section.tsx index 267f442a1b..18fb6ec904 100644 --- a/apps/mobile/src/components/agents/child-session-section.tsx +++ b/apps/mobile/src/components/agents/child-session-section.tsx @@ -8,9 +8,17 @@ import { SpinningIcon } from '@/components/ui/spinning-icon'; import { Text } from '@/components/ui/text'; import { type ThemeColors, useThemeColors } from '@/lib/hooks/use-theme-colors'; +import { + type ChildSessionCardState, + getChildSessionActivityLabel, + getChildSessionCardState, + getTaskToolSessionId, +} from './child-session-card-state'; import { MessageErrorBoundary } from './message-error-boundary'; import { isToolPart } from './part-types'; +export { getTaskToolSessionId } from './child-session-card-state'; + const MAX_NESTING_DEPTH = 5; export type RenderPartFn = (props: { @@ -27,78 +35,6 @@ type ChildSessionSectionProps = { onOpenChildSession: OpenChildSession; }; -function getStringProperty(obj: unknown, key: string): string | undefined { - if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { - return undefined; - } - const value = (obj as Record)[key]; - return typeof value === 'string' ? value : undefined; -} - -export function getTaskToolSessionId(part: ToolPart): KiloSessionId | undefined { - if (part.tool !== 'task') { - return undefined; - } - const { state } = part; - if (state.status === 'running' || state.status === 'completed') { - return getStringProperty(state.metadata, 'sessionId') as KiloSessionId | undefined; - } - return undefined; -} - -function findRunningToolInMessage( - msg: StoredMessage -): { tool: string; context?: string } | undefined { - for (let j = msg.parts.length - 1; j >= 0; j -= 1) { - const p = msg.parts[j]; - if (p && isToolPart(p) && (p.state.status === 'running' || p.state.status === 'pending')) { - return { tool: p.tool, context: getToolContext(p) }; - } - } - return undefined; -} - -function getCurrentRunningTool( - messages: StoredMessage[] -): { tool: string; context?: string } | undefined { - for (let i = messages.length - 1; i >= 0; i -= 1) { - const msg = messages[i]; - if (msg?.info.role === 'assistant') { - const result = findRunningToolInMessage(msg); - if (result) { - return result; - } - } - } - return undefined; -} - -function getToolContext(p: ToolPart): string | undefined { - const input = p.state.input; - - if (p.tool === 'read' || p.tool === 'edit' || p.tool === 'write') { - const filePath = getStringProperty(input, 'filePath'); - return filePath ? filePath.split('/').pop() : undefined; - } - if (p.tool === 'bash') { - const command = getStringProperty(input, 'command'); - if (!command) { - return undefined; - } - const firstWord = command.split(/\s+/)[0]; - return firstWord && firstWord.length > 20 ? `${firstWord.slice(0, 20)}…` : firstWord; - } - if (p.tool === 'glob' || p.tool === 'grep') { - const pattern = getStringProperty(input, 'pattern'); - return pattern && pattern.length > 25 ? `${pattern.slice(0, 25)}…` : pattern; - } - if (p.tool === 'task') { - const description = getStringProperty(input, 'description'); - return description && description.length > 30 ? `${description.slice(0, 30)}…` : description; - } - return undefined; -} - export function ChildSessionSection({ part, childMessages, @@ -106,15 +42,16 @@ export function ChildSessionSection({ }: Readonly) { const colors = useThemeColors(); - const description = getStringProperty(part.state.input, 'description'); - const prompt = getStringProperty(part.state.input, 'prompt'); - const subtitle = description ?? (prompt ? truncateText(prompt, 60) : 'task'); + const { agentName, taskName, latestActivity }: ChildSessionCardState = getChildSessionCardState( + part, + childMessages + ); + const latestActivityLabel = getChildSessionActivityLabel(latestActivity); + const { status } = part.state; const isRunning = status === 'running' || status === 'pending'; const sessionId = getTaskToolSessionId(part); - const currentTool = isRunning ? getCurrentRunningTool(childMessages) : undefined; - const borderColor = getStatusBorderColor(status, colors); return ( @@ -128,12 +65,12 @@ export function ChildSessionSection({ className="flex-row items-center gap-2 px-3 py-2 active:bg-secondary" onPress={() => { if (sessionId) { - onOpenChildSession(sessionId, subtitle); + onOpenChildSession(sessionId, taskName); } }} disabled={!sessionId} accessibilityRole="button" - accessibilityLabel={`${subtitle}, ${status}`} + accessibilityLabel={`${agentName}, ${taskName}, ${latestActivityLabel}, ${status}`} accessibilityHint={sessionId ? 'Open subagent session' : undefined} accessibilityState={{ disabled: !sessionId }} > @@ -146,21 +83,22 @@ export function ChildSessionSection({ )} - - {subtitle} + + {agentName} + + + {taskName} + + + {latestActivity === 'Waiting for activity' ? ( + latestActivity + ) : ( + <> + {latestActivity.tool} + {latestActivity.context ? ` ${latestActivity.context}` : ''} + + )} - {isRunning ? ( - - {currentTool ? ( - <> - {currentTool.tool} - {currentTool.context ? ` ${currentTool.context}` : ''} - - ) : ( - ' ' - )} - - ) : null} @@ -253,7 +191,3 @@ function getStatusTextClass(status: string): string { } return 'text-info'; } - -function truncateText(text: string, maxLength: number): string { - return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text; -}