clawdex-mobile 2.0.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.github/workflows/pages.yml +41 -0
  2. package/AGENTS.md +263 -110
  3. package/README.md +1 -1
  4. package/apps/mobile/.env.example +2 -2
  5. package/apps/mobile/App.tsx +175 -14
  6. package/apps/mobile/app.json +27 -9
  7. package/apps/mobile/eas.json +14 -4
  8. package/apps/mobile/package.json +13 -13
  9. package/apps/mobile/src/api/__tests__/chatMapping.test.ts +219 -0
  10. package/apps/mobile/src/api/__tests__/client.test.ts +579 -6
  11. package/apps/mobile/src/api/__tests__/ws.test.ts +27 -0
  12. package/apps/mobile/src/api/account.ts +47 -0
  13. package/apps/mobile/src/api/chatMapping.ts +435 -18
  14. package/apps/mobile/src/api/client.ts +296 -36
  15. package/apps/mobile/src/api/rateLimits.ts +143 -0
  16. package/apps/mobile/src/api/types.ts +106 -0
  17. package/apps/mobile/src/api/ws.ts +10 -1
  18. package/apps/mobile/src/components/ChatHeader.tsx +12 -12
  19. package/apps/mobile/src/components/ChatInput.tsx +154 -88
  20. package/apps/mobile/src/components/ChatMessage.tsx +548 -93
  21. package/apps/mobile/src/components/ComposerUsageLimits.tsx +167 -0
  22. package/apps/mobile/src/components/SelectionSheet.tsx +466 -0
  23. package/apps/mobile/src/components/ToolBlock.tsx +17 -15
  24. package/apps/mobile/src/components/VoiceRecordingWaveform.tsx +181 -0
  25. package/apps/mobile/src/components/WorkspacePickerModal.tsx +572 -0
  26. package/apps/mobile/src/components/__tests__/chat-input-layout.test.ts +35 -0
  27. package/apps/mobile/src/components/__tests__/chatImageSource.test.ts +44 -0
  28. package/apps/mobile/src/components/__tests__/composerUsageLimits.test.ts +138 -0
  29. package/apps/mobile/src/components/__tests__/voiceWaveform.test.ts +31 -0
  30. package/apps/mobile/src/components/chat-input-layout.ts +59 -0
  31. package/apps/mobile/src/components/chatImageSource.ts +86 -0
  32. package/apps/mobile/src/components/usageLimitBadges.ts +109 -0
  33. package/apps/mobile/src/components/voiceWaveform.ts +46 -0
  34. package/apps/mobile/src/config.ts +9 -2
  35. package/apps/mobile/src/hooks/useVoiceRecorder.ts +8 -1
  36. package/apps/mobile/src/navigation/DrawerContent.tsx +607 -457
  37. package/apps/mobile/src/navigation/__tests__/chatThreadTree.test.ts +89 -0
  38. package/apps/mobile/src/navigation/__tests__/drawerChats.test.ts +65 -0
  39. package/apps/mobile/src/navigation/chatThreadTree.ts +191 -0
  40. package/apps/mobile/src/navigation/drawerChats.ts +9 -0
  41. package/apps/mobile/src/screens/GitScreen.tsx +2 -0
  42. package/apps/mobile/src/screens/MainScreen.tsx +4244 -1237
  43. package/apps/mobile/src/screens/OnboardingScreen.tsx +2 -0
  44. package/apps/mobile/src/screens/SettingsScreen.tsx +256 -226
  45. package/apps/mobile/src/screens/TerminalScreen.tsx +2 -5
  46. package/apps/mobile/src/screens/__tests__/agentThreadDisplay.test.ts +80 -0
  47. package/apps/mobile/src/screens/__tests__/agentThreads.test.ts +170 -0
  48. package/apps/mobile/src/screens/__tests__/planCardState.test.ts +88 -0
  49. package/apps/mobile/src/screens/__tests__/subAgentTranscript.test.ts +102 -0
  50. package/apps/mobile/src/screens/__tests__/transcriptMessages.test.ts +97 -0
  51. package/apps/mobile/src/screens/agentThreadDisplay.ts +261 -0
  52. package/apps/mobile/src/screens/agentThreads.ts +167 -0
  53. package/apps/mobile/src/screens/planCardState.ts +40 -0
  54. package/apps/mobile/src/screens/subAgentTranscript.ts +149 -0
  55. package/apps/mobile/src/screens/transcriptMessages.ts +102 -0
  56. package/apps/mobile/src/theme.ts +6 -12
  57. package/docs/codex-app-server-cli-gap-tracker.md +14 -5
  58. package/docs/privacy-policy.md +54 -0
  59. package/docs/setup-and-operations.md +4 -3
  60. package/docs/terms-of-service.md +33 -0
  61. package/package.json +3 -3
  62. package/services/mac-bridge/package.json +6 -6
  63. package/services/rust-bridge/Cargo.lock +56 -47
  64. package/services/rust-bridge/Cargo.toml +1 -1
  65. package/services/rust-bridge/package.json +1 -1
  66. package/services/rust-bridge/src/main.rs +507 -9
  67. package/site/index.html +54 -0
  68. package/site/privacy/index.html +80 -0
  69. package/site/styles.css +135 -0
  70. package/site/support/index.html +51 -0
  71. package/site/terms/index.html +68 -0
@@ -0,0 +1,80 @@
1
+ import type { ChatSummary } from '../../api/types';
2
+ import {
3
+ buildAgentThreadDisplayState,
4
+ getAgentThreadAccentColor,
5
+ } from '../agentThreadDisplay';
6
+
7
+ function chat(
8
+ id: string,
9
+ partial: Partial<ChatSummary> = {}
10
+ ): ChatSummary {
11
+ return {
12
+ id,
13
+ title: partial.title ?? id,
14
+ status: partial.status ?? 'idle',
15
+ createdAt: partial.createdAt ?? '2026-03-20T00:00:00.000Z',
16
+ updatedAt: partial.updatedAt ?? '2026-03-20T00:00:00.000Z',
17
+ statusUpdatedAt: partial.statusUpdatedAt ?? '2026-03-20T00:00:00.000Z',
18
+ lastMessagePreview: partial.lastMessagePreview ?? '',
19
+ cwd: partial.cwd,
20
+ modelProvider: partial.modelProvider,
21
+ sourceKind: partial.sourceKind,
22
+ parentThreadId: partial.parentThreadId,
23
+ subAgentDepth: partial.subAgentDepth,
24
+ lastError: partial.lastError,
25
+ };
26
+ }
27
+
28
+ describe('agentThreadDisplay', () => {
29
+ it('uses live runtime activity for running child threads', () => {
30
+ const display = buildAgentThreadDisplayState(
31
+ chat('thr_worker', { status: 'idle' }),
32
+ {
33
+ activity: {
34
+ tone: 'running',
35
+ title: 'Reasoning',
36
+ detail: 'Inspecting files',
37
+ },
38
+ runWatchdogUntil: Date.parse('2026-03-20T10:00:30.000Z'),
39
+ },
40
+ Date.parse('2026-03-20T10:00:00.000Z')
41
+ );
42
+
43
+ expect(display.label).toBe('Reasoning');
44
+ expect(display.detail).toBe('Inspecting files');
45
+ expect(display.tone).toBe('running');
46
+ });
47
+
48
+ it('shows pending approvals as active waiting state', () => {
49
+ const display = buildAgentThreadDisplayState(chat('thr_worker'), {
50
+ pendingApproval: { id: 'appr-1' },
51
+ activity: {
52
+ tone: 'running',
53
+ title: 'Working',
54
+ },
55
+ });
56
+
57
+ expect(display.label).toBe('Needs approval');
58
+ expect(display.tone).toBe('running');
59
+ });
60
+
61
+ it('falls back to error details from the chat summary', () => {
62
+ const display = buildAgentThreadDisplayState(
63
+ chat('thr_worker', {
64
+ status: 'error',
65
+ lastError: 'Command exited 1',
66
+ }),
67
+ null
68
+ );
69
+
70
+ expect(display.label).toBe('Error');
71
+ expect(display.detail).toBe('Command exited 1');
72
+ expect(display.tone).toBe('error');
73
+ });
74
+
75
+ it('assigns stable accent colors per thread id', () => {
76
+ expect(getAgentThreadAccentColor('thr_worker')).toBe(
77
+ getAgentThreadAccentColor('thr_worker')
78
+ );
79
+ });
80
+ });
@@ -0,0 +1,170 @@
1
+ import type { ChatSummary } from '../../api/types';
2
+ import {
3
+ collectLiveAgentPanelThreadIds,
4
+ collectRelatedAgentThreads,
5
+ describeAgentThreadSource,
6
+ findMatchingAgentThread,
7
+ } from '../agentThreads';
8
+
9
+ function chat(
10
+ id: string,
11
+ partial: Partial<ChatSummary> = {}
12
+ ): ChatSummary {
13
+ return {
14
+ id,
15
+ title: partial.title ?? id,
16
+ status: partial.status ?? 'complete',
17
+ createdAt: partial.createdAt ?? '2026-03-20T00:00:00.000Z',
18
+ updatedAt: partial.updatedAt ?? '2026-03-20T00:00:00.000Z',
19
+ statusUpdatedAt: partial.statusUpdatedAt ?? '2026-03-20T00:00:00.000Z',
20
+ lastMessagePreview: partial.lastMessagePreview ?? '',
21
+ cwd: partial.cwd,
22
+ modelProvider: partial.modelProvider,
23
+ agentNickname: partial.agentNickname,
24
+ agentRole: partial.agentRole,
25
+ sourceKind: partial.sourceKind,
26
+ parentThreadId: partial.parentThreadId,
27
+ subAgentDepth: partial.subAgentDepth,
28
+ lastError: partial.lastError,
29
+ };
30
+ }
31
+
32
+ describe('agentThreads', () => {
33
+ it('collects the full related thread tree for a spawned sub-agent', () => {
34
+ const root = chat('thr_root', {
35
+ title: 'Main task',
36
+ sourceKind: 'appServer',
37
+ updatedAt: '2026-03-20T00:00:01.000Z',
38
+ });
39
+ const child = chat('thr_child', {
40
+ title: 'Worker one',
41
+ sourceKind: 'subAgentThreadSpawn',
42
+ parentThreadId: 'thr_root',
43
+ subAgentDepth: 1,
44
+ status: 'running',
45
+ updatedAt: '2026-03-20T00:00:03.000Z',
46
+ });
47
+ const grandchild = chat('thr_grandchild', {
48
+ title: 'Nested worker',
49
+ sourceKind: 'subAgentThreadSpawn',
50
+ parentThreadId: 'thr_child',
51
+ subAgentDepth: 2,
52
+ updatedAt: '2026-03-20T00:00:02.000Z',
53
+ });
54
+ const unrelated = chat('thr_other', {
55
+ title: 'Other thread',
56
+ sourceKind: 'appServer',
57
+ updatedAt: '2026-03-20T00:00:04.000Z',
58
+ });
59
+
60
+ const result = collectRelatedAgentThreads(
61
+ [root, child, grandchild, unrelated],
62
+ grandchild
63
+ );
64
+
65
+ expect(result.rootThreadId).toBe('thr_root');
66
+ expect(result.threads.map((entry) => entry.id)).toEqual([
67
+ 'thr_root',
68
+ 'thr_child',
69
+ 'thr_grandchild',
70
+ ]);
71
+ });
72
+
73
+ it('keeps the full related sub-agent history for the agent selector', () => {
74
+ const root = chat('thr_root', {
75
+ title: 'Main task',
76
+ sourceKind: 'appServer',
77
+ updatedAt: '2026-03-18T00:00:00.000Z',
78
+ });
79
+ const recentChild = chat('thr_recent', {
80
+ title: 'Recent worker',
81
+ sourceKind: 'subAgentThreadSpawn',
82
+ parentThreadId: 'thr_root',
83
+ subAgentDepth: 1,
84
+ updatedAt: '2026-03-20T11:00:00.000Z',
85
+ });
86
+ const oldRunningChild = chat('thr_running', {
87
+ title: 'Running worker',
88
+ sourceKind: 'subAgentThreadSpawn',
89
+ parentThreadId: 'thr_root',
90
+ subAgentDepth: 1,
91
+ status: 'running',
92
+ updatedAt: '2026-03-17T00:00:00.000Z',
93
+ });
94
+ const oldFocusedChild = chat('thr_focused', {
95
+ title: 'Focused worker',
96
+ sourceKind: 'subAgentThreadSpawn',
97
+ parentThreadId: 'thr_root',
98
+ subAgentDepth: 1,
99
+ updatedAt: '2026-03-17T00:00:00.000Z',
100
+ });
101
+ const oldCompletedChild = chat('thr_old', {
102
+ title: 'Old worker',
103
+ sourceKind: 'subAgentThreadSpawn',
104
+ parentThreadId: 'thr_root',
105
+ subAgentDepth: 1,
106
+ updatedAt: '2026-03-16T00:00:00.000Z',
107
+ });
108
+
109
+ const result = collectRelatedAgentThreads(
110
+ [root, recentChild, oldRunningChild, oldFocusedChild, oldCompletedChild],
111
+ oldFocusedChild
112
+ );
113
+
114
+ expect(result.threads.map((entry) => entry.id)).toEqual([
115
+ 'thr_root',
116
+ 'thr_running',
117
+ 'thr_recent',
118
+ 'thr_focused',
119
+ 'thr_old',
120
+ ]);
121
+ });
122
+
123
+ it('matches agent threads by id, title, or preview text', () => {
124
+ const root = chat('thr_root', { title: 'Main task' });
125
+ const child = chat('thr_child', {
126
+ title: 'Docs worker',
127
+ agentNickname: 'Atlas',
128
+ agentRole: 'explorer',
129
+ lastMessagePreview: 'OpenAI docs search',
130
+ });
131
+
132
+ expect(findMatchingAgentThread([root, child], 'thr_child')?.id).toBe('thr_child');
133
+ expect(findMatchingAgentThread([root, child], 'atlas')?.id).toBe('thr_child');
134
+ expect(findMatchingAgentThread([root, child], 'explorer')?.id).toBe('thr_child');
135
+ expect(findMatchingAgentThread([root, child], 'docs worker')?.id).toBe('thr_child');
136
+ expect(findMatchingAgentThread([root, child], 'docs search')?.id).toBe('thr_child');
137
+ });
138
+
139
+ it('describes root and sub-agent source kinds for the selector', () => {
140
+ const root = chat('thr_root', { sourceKind: 'appServer' });
141
+ const review = chat('thr_review', {
142
+ sourceKind: 'subAgentReview',
143
+ parentThreadId: 'thr_root',
144
+ });
145
+
146
+ expect(describeAgentThreadSource(root, 'thr_root')).toBe('Main thread');
147
+ expect(describeAgentThreadSource(review, 'thr_root')).toBe('Review agent');
148
+ });
149
+
150
+ it('includes the main thread in the live agent panel when sub-agents are active', () => {
151
+ expect(
152
+ collectLiveAgentPanelThreadIds([
153
+ { id: 'thr_root', isRootThread: true, isActive: false },
154
+ { id: 'thr_child_1', isRootThread: false, isActive: true },
155
+ { id: 'thr_child_2', isRootThread: false, isActive: true },
156
+ { id: 'thr_child_3', isRootThread: false, isActive: false },
157
+ ])
158
+ ).toEqual(['thr_root', 'thr_child_1', 'thr_child_2']);
159
+ });
160
+
161
+ it('keeps the live agent panel hidden when no sub-agent is active', () => {
162
+ expect(
163
+ collectLiveAgentPanelThreadIds([
164
+ { id: 'thr_root', isRootThread: true, isActive: true },
165
+ { id: 'thr_child_1', isRootThread: false, isActive: false },
166
+ { id: 'thr_child_2', isRootThread: false, isActive: false },
167
+ ])
168
+ ).toEqual([]);
169
+ });
170
+ });
@@ -0,0 +1,88 @@
1
+ import {
2
+ hasStructuredPlanCardContent,
3
+ resolveWorkflowCardMode,
4
+ } from '../planCardState';
5
+
6
+ describe('planCardState', () => {
7
+ it('hides the plan card when only raw delta text exists', () => {
8
+ expect(
9
+ hasStructuredPlanCardContent({
10
+ explanation: null,
11
+ steps: [],
12
+ })
13
+ ).toBe(false);
14
+ });
15
+
16
+ it('shows the plan card when an explanation exists', () => {
17
+ expect(
18
+ hasStructuredPlanCardContent({
19
+ explanation: 'Tighten the release flow before implementation.',
20
+ steps: [],
21
+ })
22
+ ).toBe(true);
23
+ });
24
+
25
+ it('shows the plan card when structured steps exist', () => {
26
+ expect(
27
+ hasStructuredPlanCardContent({
28
+ explanation: null,
29
+ steps: [
30
+ {
31
+ step: 'Audit the current dependency set',
32
+ status: 'pending',
33
+ },
34
+ ],
35
+ })
36
+ ).toBe(true);
37
+ });
38
+
39
+ it('prefers approval mode when plan approval is pending', () => {
40
+ expect(
41
+ resolveWorkflowCardMode({
42
+ collaborationMode: 'plan',
43
+ hasStructuredPlan: true,
44
+ hasPlanApprovalPrompt: true,
45
+ })
46
+ ).toBe('approval');
47
+ });
48
+
49
+ it('uses execution mode after leaving plan mode with a structured plan', () => {
50
+ expect(
51
+ resolveWorkflowCardMode({
52
+ collaborationMode: 'default',
53
+ hasStructuredPlan: true,
54
+ hasPlanApprovalPrompt: false,
55
+ })
56
+ ).toBe('execution');
57
+ });
58
+
59
+ it('hides the top workflow card when only queued execution exists', () => {
60
+ expect(
61
+ resolveWorkflowCardMode({
62
+ collaborationMode: 'plan',
63
+ hasStructuredPlan: false,
64
+ hasPlanApprovalPrompt: false,
65
+ })
66
+ ).toBeNull();
67
+ });
68
+
69
+ it('uses execution mode when queued execution exists alongside a structured plan', () => {
70
+ expect(
71
+ resolveWorkflowCardMode({
72
+ collaborationMode: 'default',
73
+ hasStructuredPlan: true,
74
+ hasPlanApprovalPrompt: false,
75
+ })
76
+ ).toBe('execution');
77
+ });
78
+
79
+ it('uses plan mode for structured plans before implementation', () => {
80
+ expect(
81
+ resolveWorkflowCardMode({
82
+ collaborationMode: 'plan',
83
+ hasStructuredPlan: true,
84
+ hasPlanApprovalPrompt: false,
85
+ })
86
+ ).toBe('plan');
87
+ });
88
+ });
@@ -0,0 +1,102 @@
1
+ import type { ChatMessage } from '../../api/types';
2
+ import { trimInheritedParentMessages } from '../subAgentTranscript';
3
+
4
+ function message(
5
+ id: string,
6
+ role: ChatMessage['role'],
7
+ content: string,
8
+ options?: {
9
+ systemKind?: ChatMessage['systemKind'];
10
+ subAgentMeta?: ChatMessage['subAgentMeta'];
11
+ }
12
+ ): ChatMessage {
13
+ return {
14
+ id,
15
+ role,
16
+ content,
17
+ systemKind: options?.systemKind,
18
+ subAgentMeta: options?.subAgentMeta,
19
+ createdAt: '2026-03-20T00:00:00.000Z',
20
+ };
21
+ }
22
+
23
+ describe('trimInheritedParentMessages', () => {
24
+ it('anchors a spawned sub-agent transcript at the child prompt', () => {
25
+ const parentMessages = [
26
+ message('m1', 'user', 'Parent question'),
27
+ message('m2', 'assistant', 'Parent answer'),
28
+ message('m3', 'system', '• Spawned sub-agent', {
29
+ systemKind: 'subAgent',
30
+ subAgentMeta: {
31
+ tool: 'spawn_agent',
32
+ prompt: 'Inspect the settings architecture',
33
+ receiverThreadIds: ['child-thread'],
34
+ },
35
+ }),
36
+ ];
37
+ const childMessages = [
38
+ message('c1', 'user', 'Parent question'),
39
+ message('c2', 'assistant', 'Parent answer'),
40
+ message('c3', 'user', 'Inspect the settings architecture'),
41
+ message('c4', 'assistant', 'The setting should live in App.tsx.'),
42
+ ];
43
+
44
+ expect(trimInheritedParentMessages(parentMessages, childMessages, 'child-thread')).toEqual({
45
+ messages: childMessages.slice(2),
46
+ hiddenInheritedMessageCount: 2,
47
+ });
48
+ });
49
+
50
+ it('matches spawned prompts even when the child message includes attachment markers', () => {
51
+ const parentMessages = [
52
+ message('m1', 'system', '• Spawned sub-agent', {
53
+ systemKind: 'subAgent',
54
+ subAgentMeta: {
55
+ tool: 'spawn_agent',
56
+ prompt: 'Review the websocket implementation',
57
+ receiverThreadIds: ['child-thread'],
58
+ },
59
+ }),
60
+ ];
61
+ const childMessages = [
62
+ message('c1', 'assistant', 'Older inherited answer'),
63
+ message(
64
+ 'c2',
65
+ 'user',
66
+ 'Review the websocket implementation\n[file: apps/mobile/src/api/ws.ts]'
67
+ ),
68
+ message('c3', 'assistant', 'Here is the websocket review.'),
69
+ ];
70
+
71
+ expect(trimInheritedParentMessages(parentMessages, childMessages, 'child-thread')).toEqual({
72
+ messages: childMessages.slice(1),
73
+ hiddenInheritedMessageCount: 1,
74
+ });
75
+ });
76
+
77
+ it('falls back to shared-prefix trimming when no spawn prompt metadata is available', () => {
78
+ const parentMessages = [message('m1', 'user', 'Parent question')];
79
+ const childMessages = [
80
+ message('m1-copy', 'user', 'Parent question'),
81
+ message('m2', 'user', 'Child-only question'),
82
+ ];
83
+
84
+ expect(trimInheritedParentMessages(parentMessages, childMessages)).toEqual({
85
+ messages: childMessages.slice(1),
86
+ hiddenInheritedMessageCount: 1,
87
+ });
88
+ });
89
+
90
+ it('does not hide the entire child transcript when every message matches', () => {
91
+ const parentMessages = [
92
+ message('m1', 'user', 'Shared prompt'),
93
+ message('m2', 'assistant', 'Shared answer'),
94
+ ];
95
+ const childMessages = [...parentMessages];
96
+
97
+ expect(trimInheritedParentMessages(parentMessages, childMessages)).toEqual({
98
+ messages: childMessages,
99
+ hiddenInheritedMessageCount: 0,
100
+ });
101
+ });
102
+ });
@@ -0,0 +1,97 @@
1
+ import type { ChatMessage } from '../../api/types';
2
+ import {
3
+ getVisibleTranscriptMessages,
4
+ syncVisibleSubAgentStatuses,
5
+ } from '../transcriptMessages';
6
+
7
+ function message(
8
+ id: string,
9
+ role: ChatMessage['role'],
10
+ content: string,
11
+ extras?: Partial<ChatMessage>
12
+ ): ChatMessage {
13
+ return {
14
+ id,
15
+ role,
16
+ content,
17
+ createdAt: '2026-03-19T00:00:00.000Z',
18
+ ...extras,
19
+ };
20
+ }
21
+
22
+ describe('getVisibleTranscriptMessages', () => {
23
+ it('hides system timeline rows when tool calls are disabled', () => {
24
+ const messages = [
25
+ message('u1', 'user', 'Investigate this bug'),
26
+ message('s1', 'system', '• Searched web for "react native flatlist"'),
27
+ message('a1', 'assistant', 'Found the issue.'),
28
+ ];
29
+
30
+ expect(getVisibleTranscriptMessages(messages, false).map((entry) => entry.id)).toEqual([
31
+ 'u1',
32
+ 'a1',
33
+ ]);
34
+ });
35
+
36
+ it('shows system timeline rows when tool calls are enabled', () => {
37
+ const messages = [
38
+ message('u1', 'user', 'Investigate this bug'),
39
+ message('s1', 'system', '• Searched web for "react native flatlist"'),
40
+ message('s2', 'system', '• Called tool `openaiDeveloperDocs / search_openai_docs`'),
41
+ message('a1', 'assistant', 'Found the issue.'),
42
+ ];
43
+
44
+ expect(getVisibleTranscriptMessages(messages, true).map((entry) => entry.id)).toEqual([
45
+ 'u1',
46
+ 's1',
47
+ 's2',
48
+ 'a1',
49
+ ]);
50
+ });
51
+
52
+ it('keeps sub-agent system rows visible when tool calls are disabled', () => {
53
+ const messages = [
54
+ message('u1', 'user', 'Review this repository'),
55
+ message('s1', 'system', '• Spawned sub-agent\n Prompt: Review the mobile app', {
56
+ systemKind: 'subAgent',
57
+ }),
58
+ message('a1', 'assistant', 'Done.'),
59
+ ];
60
+
61
+ expect(getVisibleTranscriptMessages(messages, false).map((entry) => entry.id)).toEqual([
62
+ 'u1',
63
+ 's1',
64
+ 'a1',
65
+ ]);
66
+ });
67
+
68
+ it('keeps only the last message in a consecutive assistant run', () => {
69
+ const messages = [
70
+ message('u1', 'user', 'Answer this'),
71
+ message('a1', 'assistant', 'Working...'),
72
+ message('a2', 'assistant', 'Final answer'),
73
+ ];
74
+
75
+ expect(getVisibleTranscriptMessages(messages, false).map((entry) => entry.id)).toEqual([
76
+ 'u1',
77
+ 'a2',
78
+ ]);
79
+ });
80
+
81
+ it('replaces stale sub-agent status lines with the latest thread status', () => {
82
+ const messages = [
83
+ message('s1', 'system', '• Spawned sub-agent\n Thread: child\n Status: running', {
84
+ systemKind: 'subAgent',
85
+ subAgentMeta: {
86
+ receiverThreadIds: ['child'],
87
+ agentStatus: 'running',
88
+ },
89
+ }),
90
+ ];
91
+
92
+ const synced = syncVisibleSubAgentStatuses(messages, new Map([['child', 'complete']]));
93
+
94
+ expect(synced[0]?.content).toContain('Status: complete');
95
+ expect(synced[0]?.subAgentMeta?.agentStatus).toBe('complete');
96
+ });
97
+ });