mu-coding 0.13.0 → 0.16.1

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 (118) hide show
  1. package/README.md +9 -123
  2. package/bin/coding-agent.ts +95 -0
  3. package/package.json +10 -21
  4. package/src/config.ts +122 -0
  5. package/src/harness.test.ts +159 -0
  6. package/src/main.ts +53 -3
  7. package/src/plugins.ts +49 -0
  8. package/src/systemPrompt.ts +22 -0
  9. package/src/ui/ChatApp.ts +959 -0
  10. package/src/ui/commands.ts +35 -0
  11. package/src/ui/editor.ts +166 -0
  12. package/src/ui/markdown.ts +363 -0
  13. package/src/ui/picker.ts +126 -0
  14. package/src/ui/status.ts +61 -0
  15. package/src/ui/theme.ts +241 -0
  16. package/src/ui/transcript.test.ts +121 -0
  17. package/src/ui/transcript.ts +399 -0
  18. package/tsconfig.json +8 -0
  19. package/bin/mu.js +0 -2
  20. package/prompts/SYSTEM.md +0 -16
  21. package/src/app/shutdown.ts +0 -94
  22. package/src/app/startApp.ts +0 -49
  23. package/src/cli/args.ts +0 -133
  24. package/src/cli/install.ts +0 -107
  25. package/src/cli/subcommands.ts +0 -29
  26. package/src/cli/update.ts +0 -205
  27. package/src/config/index.test.ts +0 -77
  28. package/src/config/index.ts +0 -199
  29. package/src/plugin.ts +0 -124
  30. package/src/runtime/codingTools/bash.ts +0 -114
  31. package/src/runtime/codingTools/edit-file.ts +0 -60
  32. package/src/runtime/codingTools/index.ts +0 -39
  33. package/src/runtime/codingTools/read-file.ts +0 -83
  34. package/src/runtime/codingTools/utils.ts +0 -21
  35. package/src/runtime/codingTools/write-file.ts +0 -42
  36. package/src/runtime/createRegistry.test.ts +0 -147
  37. package/src/runtime/createRegistry.ts +0 -195
  38. package/src/runtime/fileMentionProvider.ts +0 -117
  39. package/src/runtime/messageBus.test.ts +0 -62
  40. package/src/runtime/messageBus.ts +0 -78
  41. package/src/runtime/pluginLoader.ts +0 -153
  42. package/src/runtime/startupUpdateCheck.ts +0 -163
  43. package/src/runtime/updateCheck.ts +0 -136
  44. package/src/sessions/index.test.ts +0 -66
  45. package/src/sessions/index.ts +0 -183
  46. package/src/sessions/peek.test.ts +0 -88
  47. package/src/sessions/project.ts +0 -51
  48. package/src/tui/channel/tuiChannel.test.ts +0 -107
  49. package/src/tui/channel/tuiChannel.ts +0 -62
  50. package/src/tui/chat/ChatContext.ts +0 -10
  51. package/src/tui/chat/MessageRendererContext.ts +0 -44
  52. package/src/tui/chat/ToolDisplayContext.ts +0 -33
  53. package/src/tui/chat/useAbort.ts +0 -85
  54. package/src/tui/chat/useAttachment.ts +0 -74
  55. package/src/tui/chat/useChat.ts +0 -113
  56. package/src/tui/chat/useChatPanel.ts +0 -119
  57. package/src/tui/chat/useChatSession.ts +0 -382
  58. package/src/tui/chat/useModels.ts +0 -83
  59. package/src/tui/chat/usePluginStatus.ts +0 -44
  60. package/src/tui/chat/useSessionPersistence.ts +0 -84
  61. package/src/tui/chat/useStatusSegments.ts +0 -82
  62. package/src/tui/chat/useSubagentBrowser.ts +0 -133
  63. package/src/tui/components/chat/ChatPanel.tsx +0 -54
  64. package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
  65. package/src/tui/components/chat/Pickers.tsx +0 -44
  66. package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
  67. package/src/tui/components/messageView.tsx +0 -72
  68. package/src/tui/components/messages/EditOutput.tsx +0 -112
  69. package/src/tui/components/messages/ReadOutput.tsx +0 -48
  70. package/src/tui/components/messages/ToolHeader.tsx +0 -30
  71. package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
  72. package/src/tui/components/messages/WriteOutput.tsx +0 -64
  73. package/src/tui/components/messages/assistantMessage.tsx +0 -72
  74. package/src/tui/components/messages/markdown.tsx +0 -407
  75. package/src/tui/components/messages/messageItem.tsx +0 -43
  76. package/src/tui/components/messages/reasoningBlock.tsx +0 -18
  77. package/src/tui/components/messages/streamingOutput.tsx +0 -18
  78. package/src/tui/components/messages/toolCallBlock.tsx +0 -125
  79. package/src/tui/components/messages/userMessage.tsx +0 -44
  80. package/src/tui/components/primitives/dropdown.tsx +0 -125
  81. package/src/tui/components/primitives/modal.tsx +0 -47
  82. package/src/tui/components/primitives/pickerModal.tsx +0 -47
  83. package/src/tui/components/primitives/scrollbar.tsx +0 -27
  84. package/src/tui/components/primitives/toast.tsx +0 -100
  85. package/src/tui/components/statusBar.tsx +0 -41
  86. package/src/tui/components/ui/dialogLayer.tsx +0 -175
  87. package/src/tui/context/ThemeContext.tsx +0 -18
  88. package/src/tui/hooks/useChordKeyboard.ts +0 -87
  89. package/src/tui/hooks/useInputInfoSegments.ts +0 -22
  90. package/src/tui/hooks/useScroll.ts +0 -64
  91. package/src/tui/hooks/useTerminal.ts +0 -40
  92. package/src/tui/hooks/useUI.ts +0 -15
  93. package/src/tui/input/InputBox.tsx +0 -6
  94. package/src/tui/input/InputBoxView.tsx +0 -293
  95. package/src/tui/input/commands.test.ts +0 -71
  96. package/src/tui/input/commands.ts +0 -55
  97. package/src/tui/input/cursor.test.ts +0 -136
  98. package/src/tui/input/cursor.ts +0 -214
  99. package/src/tui/input/dumpContext.ts +0 -107
  100. package/src/tui/input/sanitize.ts +0 -33
  101. package/src/tui/input/useCommandExecutor.ts +0 -32
  102. package/src/tui/input/useInputBox.ts +0 -265
  103. package/src/tui/input/useInputHandler.ts +0 -455
  104. package/src/tui/input/useMentionPicker.ts +0 -133
  105. package/src/tui/input/usePluginShortcuts.ts +0 -29
  106. package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
  107. package/src/tui/plugins/InkApprovalChannel.ts +0 -30
  108. package/src/tui/plugins/InkUIService.ts +0 -188
  109. package/src/tui/renderApp.tsx +0 -64
  110. package/src/tui/theme/index.ts +0 -1
  111. package/src/tui/theme/merge.test.ts +0 -49
  112. package/src/tui/theme/merge.ts +0 -43
  113. package/src/tui/theme/presets.ts +0 -90
  114. package/src/tui/theme/types.ts +0 -138
  115. package/src/tui/update/runUpdateInTui.ts +0 -127
  116. package/src/utils/clipboard.ts +0 -97
  117. package/src/utils/diff.test.ts +0 -56
  118. package/src/utils/diff.ts +0 -81
@@ -0,0 +1,61 @@
1
+ import type { Component } from 'mu-tui';
2
+ import { truncateToWidth, visibleWidth } from 'mu-tui';
3
+ import type { AgentSessionEvent } from 'mu-harness';
4
+ import { styleToAnsi, type Theme } from './theme';
5
+
6
+ const RESET = '\x1b[0m';
7
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
8
+
9
+ export const spinnerFrame = (tick: number): string =>
10
+ SPINNER[((tick % SPINNER.length) + SPINNER.length) % SPINNER.length];
11
+
12
+ export const formatTokens = (n: number): string =>
13
+ n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(Math.round(n));
14
+
15
+ export interface StatusState {
16
+ label: string;
17
+ busy: boolean;
18
+ spinnerTick: number;
19
+ context: string;
20
+ }
21
+
22
+ export function statusFromEvent(event: AgentSessionEvent): string | undefined {
23
+ switch (event.type) {
24
+ case 'turn_start':
25
+ return 'thinking…';
26
+ case 'reasoning':
27
+ return 'reasoning…';
28
+ case 'text':
29
+ return 'responding…';
30
+ case 'tool_call':
31
+ return `calling ${event.name}…`;
32
+ case 'message':
33
+ return event.message.role === 'assistant' ? undefined : 'running…';
34
+ case 'turn_end':
35
+ case 'done':
36
+ return 'ready';
37
+ case 'error':
38
+ return 'error';
39
+ default:
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ export function statusComponent(state: StatusState, theme: Theme): Component {
45
+ return {
46
+ render: (s) => {
47
+ if (s.width <= 0) return;
48
+ const muted = styleToAnsi(theme.styles.muted);
49
+ const spinner = `${muted}${spinnerFrame(state.spinnerTick)}${RESET}`;
50
+ const left = state.busy ? (state.label ? `${spinner} ${muted}${state.label}${RESET}` : spinner) : '';
51
+ const right = state.context ? `${muted}${state.context}${RESET}` : '';
52
+ if (!left && !right) {
53
+ s.text(0, 0, '');
54
+ return;
55
+ }
56
+ const gap = Math.max(1, s.width - visibleWidth(left) - visibleWidth(right));
57
+ const line = right ? `${left}${' '.repeat(gap)}${right}` : left;
58
+ s.text(0, 0, visibleWidth(line) > s.width ? truncateToWidth(line, s.width) : line);
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,241 @@
1
+ import type { Color } from 'mu-tui';
2
+
3
+ export interface TextStyle {
4
+ fg?: Color;
5
+ bg?: Color;
6
+ bold?: boolean;
7
+ dim?: boolean;
8
+ italic?: boolean;
9
+ underline?: boolean;
10
+ }
11
+
12
+ export interface ThemeColors {
13
+ background: Color;
14
+ surface: Color;
15
+ surfaceMuted: Color;
16
+ border: Color;
17
+ text: Color;
18
+ textMuted: Color;
19
+ accent: Color;
20
+ success: Color;
21
+ warning: Color;
22
+ danger: Color;
23
+ }
24
+
25
+ export interface ThemeStyles {
26
+ body: TextStyle;
27
+ muted: TextStyle;
28
+ title: TextStyle;
29
+ userMessage: TextStyle;
30
+ assistantMessage: TextStyle;
31
+ reasoning: TextStyle;
32
+ commandPaletteItem: TextStyle;
33
+ commandPaletteHover: TextStyle;
34
+ commandPaletteSelected: TextStyle;
35
+ bashPrompt: TextStyle;
36
+ errorLine: TextStyle;
37
+ errorPrefix: TextStyle;
38
+ }
39
+
40
+ export interface Theme {
41
+ name: string;
42
+ colors: ThemeColors;
43
+ styles: ThemeStyles;
44
+ }
45
+
46
+ export const palette = {
47
+ neutral: {
48
+ 0: '#ffffff' as Color,
49
+ 50: '#fafafa' as Color,
50
+ 100: '#f4f4f5' as Color,
51
+ 200: '#e4e4e7' as Color,
52
+ 300: '#d4d4d8' as Color,
53
+ 400: '#a1a1aa' as Color,
54
+ 500: '#71717a' as Color,
55
+ 600: '#52525b' as Color,
56
+ 700: '#3f3f46' as Color,
57
+ 800: '#27272a' as Color,
58
+ 900: '#18181b' as Color,
59
+ 925: '#111114' as Color,
60
+ 950: '#0b0b0e' as Color,
61
+ },
62
+ blue: { 300: '#93c5fd' as Color, 400: '#60a5fa' as Color, 600: '#2563eb' as Color },
63
+ red: { 400: '#f87171' as Color, 600: '#dc2626' as Color },
64
+ green: { 400: '#4ade80' as Color, 600: '#16a34a' as Color, 800: '#2d6a3f' as Color },
65
+ yellow: { 300: '#f0ae5d' as Color, 400: '#e89b24' as Color, 500: '#c87f12' as Color },
66
+ } as const;
67
+
68
+ export const darkTheme: Theme = {
69
+ name: 'dark',
70
+ colors: {
71
+ background: palette.neutral[950],
72
+ surface: palette.neutral[900],
73
+ surfaceMuted: palette.neutral[800],
74
+ border: palette.neutral[700],
75
+ text: palette.neutral[100],
76
+ textMuted: palette.neutral[400],
77
+ accent: palette.blue[400],
78
+ success: palette.green[400],
79
+ warning: palette.yellow[400],
80
+ danger: palette.red[400],
81
+ },
82
+ styles: {
83
+ body: { fg: palette.neutral[100] },
84
+ muted: { fg: palette.neutral[400], dim: true },
85
+ title: { fg: palette.neutral[0], bold: true },
86
+ userMessage: { fg: palette.neutral[100], bg: palette.neutral[925] },
87
+ assistantMessage: { fg: palette.neutral[100] },
88
+ reasoning: { fg: palette.neutral[400], italic: true },
89
+ commandPaletteItem: { fg: palette.neutral[100], bg: palette.neutral[900] },
90
+ commandPaletteHover: { fg: palette.neutral[100], bg: palette.neutral[800] },
91
+ commandPaletteSelected: { fg: palette.neutral[950], bg: palette.yellow[300], bold: true },
92
+ bashPrompt: { fg: palette.neutral[0] },
93
+ errorLine: { fg: palette.neutral[100] },
94
+ errorPrefix: { fg: palette.red[400], bold: true },
95
+ },
96
+ };
97
+
98
+ export const lightTheme: Theme = {
99
+ name: 'light',
100
+ colors: {
101
+ background: palette.neutral[0],
102
+ surface: palette.neutral[50],
103
+ surfaceMuted: palette.neutral[100],
104
+ border: palette.neutral[300],
105
+ text: palette.neutral[900],
106
+ textMuted: palette.neutral[600],
107
+ accent: palette.blue[600],
108
+ success: palette.green[600],
109
+ warning: palette.yellow[500],
110
+ danger: palette.red[600],
111
+ },
112
+ styles: {
113
+ body: { fg: palette.neutral[900] },
114
+ muted: { fg: palette.neutral[500] },
115
+ title: { fg: palette.neutral[950], bold: true },
116
+ userMessage: { fg: palette.neutral[900] },
117
+ assistantMessage: { fg: palette.neutral[900] },
118
+ reasoning: { fg: palette.neutral[600], italic: true },
119
+ commandPaletteItem: { fg: palette.neutral[900], bg: palette.neutral[50] },
120
+ commandPaletteHover: { fg: palette.neutral[900], bg: palette.neutral[100] },
121
+ commandPaletteSelected: { fg: palette.neutral[950], bg: palette.yellow[300], bold: true },
122
+ bashPrompt: { fg: palette.neutral[950] },
123
+ errorLine: { fg: palette.neutral[900] },
124
+ errorPrefix: { fg: palette.red[600], bold: true },
125
+ },
126
+ };
127
+
128
+ export const themesByName: Record<string, Theme> = { dark: darkTheme, light: lightTheme };
129
+
130
+ const NAMED_FG: Record<string, string> = {
131
+ black: '\x1b[30m',
132
+ red: '\x1b[31m',
133
+ green: '\x1b[32m',
134
+ yellow: '\x1b[33m',
135
+ blue: '\x1b[34m',
136
+ magenta: '\x1b[35m',
137
+ cyan: '\x1b[36m',
138
+ white: '\x1b[37m',
139
+ brightBlack: '\x1b[90m',
140
+ brightRed: '\x1b[91m',
141
+ brightGreen: '\x1b[92m',
142
+ brightYellow: '\x1b[93m',
143
+ brightBlue: '\x1b[94m',
144
+ brightMagenta: '\x1b[95m',
145
+ brightCyan: '\x1b[96m',
146
+ brightWhite: '\x1b[97m',
147
+ };
148
+
149
+ const NAMED_BG: Record<string, string> = {
150
+ black: '\x1b[40m',
151
+ red: '\x1b[41m',
152
+ green: '\x1b[42m',
153
+ yellow: '\x1b[43m',
154
+ blue: '\x1b[44m',
155
+ magenta: '\x1b[45m',
156
+ cyan: '\x1b[46m',
157
+ white: '\x1b[47m',
158
+ brightBlack: '\x1b[100m',
159
+ brightRed: '\x1b[101m',
160
+ brightGreen: '\x1b[102m',
161
+ brightYellow: '\x1b[103m',
162
+ brightBlue: '\x1b[104m',
163
+ brightMagenta: '\x1b[105m',
164
+ brightCyan: '\x1b[106m',
165
+ brightWhite: '\x1b[107m',
166
+ };
167
+
168
+ function hexToRgb(hex: string): [number, number, number] | undefined {
169
+ let h = hex.startsWith('#') ? hex.slice(1) : hex;
170
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
171
+ if (h.length !== 6) return undefined;
172
+ const value = Number.parseInt(h, 16);
173
+ if (!Number.isFinite(value)) return undefined;
174
+ return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
175
+ }
176
+
177
+ export function fgToAnsi(color: Color): string {
178
+ if (color.startsWith('#')) {
179
+ const rgb = hexToRgb(color);
180
+ return rgb ? `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m` : '';
181
+ }
182
+ return NAMED_FG[color] ?? '';
183
+ }
184
+
185
+ export function bgToAnsi(color: Color): string {
186
+ if (color.startsWith('#')) {
187
+ const rgb = hexToRgb(color);
188
+ return rgb ? `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m` : '';
189
+ }
190
+ return NAMED_BG[color] ?? '';
191
+ }
192
+
193
+ export function styleToAnsi(style: TextStyle): string {
194
+ let out = '';
195
+ if (style.bold) out += '\x1b[1m';
196
+ if (style.dim) out += '\x1b[2m';
197
+ if (style.italic) out += '\x1b[3m';
198
+ if (style.underline) out += '\x1b[4m';
199
+ if (style.fg) out += fgToAnsi(style.fg);
200
+ if (style.bg) out += bgToAnsi(style.bg);
201
+ return out;
202
+ }
203
+
204
+ export function asHexColor(value: string | undefined): `#${string}` | undefined {
205
+ if (value && value.startsWith('#')) return value as `#${string}`;
206
+ return undefined;
207
+ }
208
+
209
+ export type ThemeSubscriber = (theme: Theme) => void;
210
+
211
+ export class ThemeProvider {
212
+ private theme: Theme;
213
+ private readonly subscribers = new Set<ThemeSubscriber>();
214
+
215
+ constructor(initial: Theme) {
216
+ this.theme = initial;
217
+ }
218
+
219
+ current(): Theme {
220
+ return this.theme;
221
+ }
222
+
223
+ setTheme(next: Theme): void {
224
+ if (this.theme === next) return;
225
+ this.theme = next;
226
+ for (const subscriber of this.subscribers) {
227
+ try {
228
+ subscriber(next);
229
+ } catch {
230
+ // a faulty subscriber must not break theme switching
231
+ }
232
+ }
233
+ }
234
+
235
+ subscribe(fn: ThemeSubscriber): () => void {
236
+ this.subscribers.add(fn);
237
+ return () => {
238
+ this.subscribers.delete(fn);
239
+ };
240
+ }
241
+ }
@@ -0,0 +1,121 @@
1
+ import { expect } from '@std/expect';
2
+ import { describe, it } from '@std/testing/bdd';
3
+ import type { AgentSessionEvent } from 'mu-harness';
4
+ import type { Message } from 'mu-core';
5
+ import { formatToolArgs, Transcript } from './transcript';
6
+
7
+ const userMsg = (text: string): Message => ({ role: 'user', content: [{ type: 'text', text }] });
8
+
9
+ describe('Transcript', () => {
10
+ it('builds the streamed user + assistant entries from the events', () => {
11
+ const t = new Transcript();
12
+ t.appendUser('hi');
13
+ const events: AgentSessionEvent[] = [
14
+ { type: 'turn_start', input: userMsg('hi') },
15
+ { type: 'text', text: 'Hello ' },
16
+ { type: 'text', text: 'world' },
17
+ { type: 'message', message: { role: 'assistant', content: [{ type: 'text', text: 'Hello world' }] } },
18
+ { type: 'turn_end' },
19
+ ];
20
+ for (const event of events) t.applyEvent(event);
21
+
22
+ expect(t.entries.length).toBe(2);
23
+ expect(t.entries[0]).toEqual({ kind: 'user', text: 'hi' });
24
+ expect(t.entries[1]).toEqual({ kind: 'assistant', text: 'Hello world' });
25
+ });
26
+
27
+ it('accumulates streamed reasoning into a reasoning entry before the response', () => {
28
+ const t = new Transcript();
29
+ t.appendUser('hi');
30
+ const events: AgentSessionEvent[] = [
31
+ { type: 'turn_start', input: userMsg('hi') },
32
+ { type: 'reasoning', text: 'Let me ' },
33
+ { type: 'reasoning', text: 'think.' },
34
+ { type: 'text', text: 'Hello' },
35
+ { type: 'message', message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] } },
36
+ { type: 'turn_end' },
37
+ ];
38
+ for (const event of events) t.applyEvent(event);
39
+
40
+ expect(t.entries.map((e) => e.kind)).toEqual(['user', 'reasoning', 'assistant']);
41
+ const reasoning = t.entries[1];
42
+ expect(reasoning.kind === 'reasoning' && reasoning.text).toBe('Let me think.');
43
+ });
44
+
45
+ it('records the tool calls but hides the raw tool results in the transcript', () => {
46
+ const t = new Transcript();
47
+ t.appendUser('read it');
48
+ t.applyEvent({ type: 'turn_start', input: userMsg('read it') });
49
+ t.applyEvent({ type: 'tool_call', id: 'c1', name: 'read', input: { path: 'a.ts' } });
50
+ t.applyEvent({
51
+ type: 'message',
52
+ message: { role: 'assistant', content: [{ type: 'tool_call', id: 'c1', name: 'read', input: { path: 'a.ts' } }] },
53
+ });
54
+ t.applyEvent({
55
+ type: 'message',
56
+ message: {
57
+ role: 'user',
58
+ content: [{ type: 'tool_result', id: 'c1', content: [{ type: 'text', text: 'line 1\nline 2' }] }],
59
+ },
60
+ });
61
+
62
+ const kinds = t.entries.map((e) => e.kind);
63
+ expect(kinds).toEqual(['user', 'tool_call']);
64
+ const call = t.entries[1];
65
+ expect(call.kind === 'tool_call' && call.name).toBe('read');
66
+ });
67
+
68
+ it('initializes from an existing message list, ignoring the system message', () => {
69
+ const t = new Transcript();
70
+ t.seed([
71
+ { role: 'system', content: [{ type: 'text', text: 'sys' }] },
72
+ userMsg('hey'),
73
+ { role: 'assistant', content: [{ type: 'text', text: 'yo' }] },
74
+ ]);
75
+ expect(t.entries).toEqual([
76
+ { kind: 'user', text: 'hey' },
77
+ { kind: 'assistant', text: 'yo' },
78
+ ]);
79
+ });
80
+
81
+ it('formats the tool arguments according to the tool', () => {
82
+ expect(formatToolArgs('read', { path: 'src/a.ts' })).toBe('src/a.ts');
83
+ expect(formatToolArgs('bash', { cmd: 'ls -la' })).toBe('ls -la');
84
+ expect(formatToolArgs('subagent', { agent: 'reviewer', task: 'x' })).toBe('reviewer');
85
+ });
86
+
87
+ it('suppresses the live subagent tool line (the card replaces it)', () => {
88
+ const t = new Transcript();
89
+ t.appendUser('delegate');
90
+ t.applyEvent({ type: 'turn_start', input: userMsg('delegate') });
91
+ t.applyEvent({ type: 'tool_call', id: 'c1', name: 'subagent', input: { agent: 'reviewer', task: 'x' } });
92
+ expect(t.entries.map((e) => e.kind)).toEqual(['user']);
93
+ });
94
+
95
+ it('tracks a sub-agent run through its handle (tools, then result)', () => {
96
+ const t = new Transcript();
97
+ const handle = t.appendSubAgent('reviewer');
98
+ const entry = t.entries[0];
99
+ expect(entry.kind === 'subagent' && entry.status).toBe('running');
100
+
101
+ handle.addTool('bash ls');
102
+ handle.addTool('read a.ts');
103
+ expect(entry.kind === 'subagent' && entry.tools).toBe(2);
104
+ expect(entry.kind === 'subagent' && entry.activity).toBe('read a.ts');
105
+ expect(entry.kind === 'subagent' && entry.log).toEqual(['bash ls', 'read a.ts']);
106
+
107
+ handle.finish('all good');
108
+ expect(entry.kind === 'subagent' && entry.status).toBe('done');
109
+ expect(entry.kind === 'subagent' && entry.result).toBe('all good');
110
+ expect(entry.kind === 'subagent' && entry.activity).toBe('');
111
+ });
112
+
113
+ it('marks a failed sub-agent run as an error with its message', () => {
114
+ const t = new Transcript();
115
+ const handle = t.appendSubAgent('reviewer');
116
+ handle.fail('boom');
117
+ const entry = t.entries[0];
118
+ expect(entry.kind === 'subagent' && entry.status).toBe('error');
119
+ expect(entry.kind === 'subagent' && entry.result).toBe('boom');
120
+ });
121
+ });