@bytespell/shella 0.2.4 → 0.2.6

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 (53) hide show
  1. package/bundled-plugins/agent/AGENT_SPEC.md +611 -0
  2. package/bundled-plugins/agent/README.md +7 -0
  3. package/bundled-plugins/agent/components.json +24 -0
  4. package/bundled-plugins/agent/eslint.config.js +23 -0
  5. package/bundled-plugins/agent/index.html +13 -0
  6. package/bundled-plugins/agent/package-lock.json +12140 -0
  7. package/bundled-plugins/agent/package.json +62 -0
  8. package/bundled-plugins/agent/public/vite.svg +1 -0
  9. package/bundled-plugins/agent/server.js +631 -0
  10. package/bundled-plugins/agent/src/App.tsx +755 -0
  11. package/bundled-plugins/agent/src/assets/react.svg +1 -0
  12. package/bundled-plugins/agent/src/components/ui/alert-dialog.tsx +182 -0
  13. package/bundled-plugins/agent/src/components/ui/badge.tsx +45 -0
  14. package/bundled-plugins/agent/src/components/ui/button.tsx +60 -0
  15. package/bundled-plugins/agent/src/components/ui/card.tsx +94 -0
  16. package/bundled-plugins/agent/src/components/ui/combobox.tsx +294 -0
  17. package/bundled-plugins/agent/src/components/ui/dropdown-menu.tsx +253 -0
  18. package/bundled-plugins/agent/src/components/ui/field.tsx +225 -0
  19. package/bundled-plugins/agent/src/components/ui/input-group.tsx +147 -0
  20. package/bundled-plugins/agent/src/components/ui/input.tsx +19 -0
  21. package/bundled-plugins/agent/src/components/ui/label.tsx +24 -0
  22. package/bundled-plugins/agent/src/components/ui/select.tsx +185 -0
  23. package/bundled-plugins/agent/src/components/ui/separator.tsx +26 -0
  24. package/bundled-plugins/agent/src/components/ui/switch.tsx +31 -0
  25. package/bundled-plugins/agent/src/components/ui/textarea.tsx +18 -0
  26. package/bundled-plugins/agent/src/index.css +131 -0
  27. package/bundled-plugins/agent/src/lib/utils.ts +6 -0
  28. package/bundled-plugins/agent/src/main.tsx +11 -0
  29. package/bundled-plugins/agent/src/reducer.test.ts +359 -0
  30. package/bundled-plugins/agent/src/reducer.ts +255 -0
  31. package/bundled-plugins/agent/src/store.ts +379 -0
  32. package/bundled-plugins/agent/src/types.ts +98 -0
  33. package/bundled-plugins/agent/src/utils.test.ts +393 -0
  34. package/bundled-plugins/agent/src/utils.ts +158 -0
  35. package/bundled-plugins/agent/tsconfig.app.json +32 -0
  36. package/bundled-plugins/agent/tsconfig.json +13 -0
  37. package/bundled-plugins/agent/tsconfig.node.json +26 -0
  38. package/bundled-plugins/agent/vite.config.ts +14 -0
  39. package/bundled-plugins/agent/vitest.config.ts +17 -0
  40. package/bundled-plugins/terminal/README.md +7 -0
  41. package/bundled-plugins/terminal/index.html +24 -0
  42. package/bundled-plugins/terminal/package-lock.json +3346 -0
  43. package/bundled-plugins/terminal/package.json +38 -0
  44. package/bundled-plugins/terminal/server.ts +265 -0
  45. package/bundled-plugins/terminal/src/App.tsx +153 -0
  46. package/bundled-plugins/terminal/src/TERMINAL_SPEC.md +404 -0
  47. package/bundled-plugins/terminal/src/main.tsx +9 -0
  48. package/bundled-plugins/terminal/src/store.ts +114 -0
  49. package/bundled-plugins/terminal/tsconfig.json +22 -0
  50. package/bundled-plugins/terminal/vite.config.ts +10 -0
  51. package/dist/src/plugin-manager.js +1 -1
  52. package/dist/src/plugin-manager.js.map +1 -1
  53. package/package.json +1 -1
@@ -0,0 +1,359 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { appReducer, handleRpcEvent, initialState, type AppState, type RpcEvent } from './reducer'
3
+
4
+ describe('appReducer', () => {
5
+ describe('basic actions', () => {
6
+ it('SET_STATUS updates status', () => {
7
+ const state = appReducer(initialState, { type: 'SET_STATUS', status: 'connected' })
8
+ expect(state.status).toBe('connected')
9
+ })
10
+
11
+ it('SET_ERROR updates error', () => {
12
+ const state = appReducer(initialState, { type: 'SET_ERROR', error: 'Something went wrong' })
13
+ expect(state.error).toBe('Something went wrong')
14
+ })
15
+
16
+ it('SET_CWD updates currentCwd', () => {
17
+ const state = appReducer(initialState, { type: 'SET_CWD', cwd: '/home/user' })
18
+ expect(state.currentCwd).toBe('/home/user')
19
+ })
20
+
21
+ it('CWD_CHANGED clears messages and resets state', () => {
22
+ const stateWithMessages: AppState = {
23
+ ...initialState,
24
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
25
+ sessionStats: { tokens: { input: 100, output: 50, total: 150 } },
26
+ activeToolCalls: new Map([['call_1', { toolCallId: 'call_1', toolName: 'Bash', args: {}, status: 'running', partialOutput: null }]]),
27
+ }
28
+ const state = appReducer(stateWithMessages, { type: 'CWD_CHANGED', cwd: '/new/path' })
29
+ expect(state.currentCwd).toBe('/new/path')
30
+ expect(state.messages).toEqual([])
31
+ expect(state.sessionStats).toBeNull()
32
+ expect(state.activeToolCalls.size).toBe(0)
33
+ })
34
+
35
+ it('CLEAR_SESSION clears messages and stats', () => {
36
+ const stateWithData: AppState = {
37
+ ...initialState,
38
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
39
+ sessionStats: { cost: 0.01 },
40
+ }
41
+ const state = appReducer(stateWithData, { type: 'CLEAR_SESSION' })
42
+ expect(state.messages).toEqual([])
43
+ expect(state.sessionStats).toBeNull()
44
+ })
45
+ })
46
+ })
47
+
48
+ describe('handleRpcEvent', () => {
49
+ describe('response events', () => {
50
+ it('get_state response updates sessionState', () => {
51
+ const event: RpcEvent = {
52
+ type: 'response',
53
+ command: 'get_state',
54
+ success: true,
55
+ data: { isStreaming: false, model: { provider: 'anthropic', id: 'claude-3' } },
56
+ }
57
+ const state = handleRpcEvent(initialState, event)
58
+ expect(state.sessionState).toEqual({ isStreaming: false, model: { provider: 'anthropic', id: 'claude-3' } })
59
+ })
60
+
61
+ it('get_messages response sets messages', () => {
62
+ const event: RpcEvent = {
63
+ type: 'response',
64
+ command: 'get_messages',
65
+ success: true,
66
+ data: { messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }] },
67
+ }
68
+ const state = handleRpcEvent(initialState, event)
69
+ expect(state.messages).toHaveLength(1)
70
+ expect(state.messages[0]?.role).toBe('user')
71
+ })
72
+
73
+ it('failed response does not change state', () => {
74
+ const event: RpcEvent = {
75
+ type: 'response',
76
+ command: 'get_state',
77
+ success: false,
78
+ }
79
+ const state = handleRpcEvent(initialState, event)
80
+ expect(state).toBe(initialState)
81
+ })
82
+
83
+ it('new_session response clears messages', () => {
84
+ const stateWithMessages: AppState = {
85
+ ...initialState,
86
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
87
+ }
88
+ const event: RpcEvent = {
89
+ type: 'response',
90
+ command: 'new_session',
91
+ success: true,
92
+ }
93
+ const state = handleRpcEvent(stateWithMessages, event)
94
+ expect(state.messages).toEqual([])
95
+ })
96
+ })
97
+
98
+ describe('agent lifecycle events', () => {
99
+ it('agent_start sets isStreaming and clears activeToolCalls', () => {
100
+ const stateWithTools: AppState = {
101
+ ...initialState,
102
+ isStreaming: false,
103
+ activeToolCalls: new Map([['call_1', { toolCallId: 'call_1', toolName: 'Bash', args: {}, status: 'done', partialOutput: null }]]),
104
+ }
105
+ const event: RpcEvent = { type: 'agent_start' }
106
+ const state = handleRpcEvent(stateWithTools, event)
107
+ expect(state.isStreaming).toBe(true)
108
+ expect(state.activeToolCalls.size).toBe(0)
109
+ })
110
+
111
+ it('agent_end clears streaming state', () => {
112
+ const streamingState: AppState = {
113
+ ...initialState,
114
+ isStreaming: true,
115
+ streamingMessageIndex: 1,
116
+ activeToolCalls: new Map([['call_1', { toolCallId: 'call_1', toolName: 'Bash', args: {}, status: 'running', partialOutput: null }]]),
117
+ }
118
+ const event: RpcEvent = { type: 'agent_end' }
119
+ const state = handleRpcEvent(streamingState, event)
120
+ expect(state.isStreaming).toBe(false)
121
+ expect(state.streamingMessageIndex).toBeNull()
122
+ expect(state.activeToolCalls.size).toBe(0)
123
+ })
124
+ })
125
+
126
+ describe('message events', () => {
127
+ it('message_start appends message to array', () => {
128
+ const event: RpcEvent = {
129
+ type: 'message_start',
130
+ message: { role: 'user', content: [{ type: 'text', text: 'Hello' }] },
131
+ }
132
+ const state = handleRpcEvent(initialState, event)
133
+ expect(state.messages).toHaveLength(1)
134
+ expect(state.messages[0]?.role).toBe('user')
135
+ })
136
+
137
+ it('message_start for assistant sets streamingMessageIndex', () => {
138
+ const stateWithUserMsg: AppState = {
139
+ ...initialState,
140
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }],
141
+ }
142
+ const event: RpcEvent = {
143
+ type: 'message_start',
144
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Hi' }] },
145
+ }
146
+ const state = handleRpcEvent(stateWithUserMsg, event)
147
+ expect(state.streamingMessageIndex).toBe(1)
148
+ })
149
+
150
+ it('message_start for toolResult removes from activeToolCalls', () => {
151
+ const stateWithToolCall: AppState = {
152
+ ...initialState,
153
+ activeToolCalls: new Map([['call_1', { toolCallId: 'call_1', toolName: 'Bash', args: {}, status: 'running', partialOutput: null }]]),
154
+ }
155
+ const event: RpcEvent = {
156
+ type: 'message_start',
157
+ message: { role: 'toolResult', toolCallId: 'call_1', toolName: 'Bash', content: [{ type: 'text', text: 'output' }] },
158
+ }
159
+ const state = handleRpcEvent(stateWithToolCall, event)
160
+ expect(state.activeToolCalls.has('call_1')).toBe(false)
161
+ expect(state.messages).toHaveLength(1)
162
+ })
163
+
164
+ it('message_update replaces last message', () => {
165
+ const stateWithMsg: AppState = {
166
+ ...initialState,
167
+ messages: [{ role: 'assistant', content: [{ type: 'text', text: 'Hel' }] }],
168
+ streamingMessageIndex: 0,
169
+ }
170
+ const event: RpcEvent = {
171
+ type: 'message_update',
172
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] },
173
+ }
174
+ const state = handleRpcEvent(stateWithMsg, event)
175
+ expect(state.messages).toHaveLength(1)
176
+ expect((state.messages[0]?.content[0] as { type: 'text'; text: string }).text).toBe('Hello')
177
+ })
178
+
179
+ it('message_update on empty messages adds the message', () => {
180
+ const event: RpcEvent = {
181
+ type: 'message_update',
182
+ message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] },
183
+ }
184
+ const state = handleRpcEvent(initialState, event)
185
+ expect(state.messages).toHaveLength(1)
186
+ })
187
+
188
+ it('message_end clears streamingMessageIndex', () => {
189
+ const streamingState: AppState = {
190
+ ...initialState,
191
+ streamingMessageIndex: 1,
192
+ }
193
+ const event: RpcEvent = { type: 'message_end' }
194
+ const state = handleRpcEvent(streamingState, event)
195
+ expect(state.streamingMessageIndex).toBeNull()
196
+ })
197
+ })
198
+
199
+ describe('tool execution events', () => {
200
+ it('tool_execution_start adds to activeToolCalls', () => {
201
+ const event: RpcEvent = {
202
+ type: 'tool_execution_start',
203
+ toolCallId: 'call_1',
204
+ toolName: 'Bash',
205
+ args: { command: 'ls' },
206
+ }
207
+ const state = handleRpcEvent(initialState, event)
208
+ expect(state.activeToolCalls.has('call_1')).toBe(true)
209
+ const toolCall = state.activeToolCalls.get('call_1')
210
+ expect(toolCall?.status).toBe('running')
211
+ expect(toolCall?.toolName).toBe('Bash')
212
+ expect(toolCall?.args).toEqual({ command: 'ls' })
213
+ })
214
+
215
+ it('tool_execution_update updates partialOutput', () => {
216
+ const stateWithTool: AppState = {
217
+ ...initialState,
218
+ activeToolCalls: new Map([['call_1', { toolCallId: 'call_1', toolName: 'Bash', args: {}, status: 'running', partialOutput: null }]]),
219
+ }
220
+ const event: RpcEvent = {
221
+ type: 'tool_execution_update',
222
+ toolCallId: 'call_1',
223
+ partialResult: { content: [{ type: 'text', text: 'partial output' }] },
224
+ }
225
+ const state = handleRpcEvent(stateWithTool, event)
226
+ expect(state.activeToolCalls.get('call_1')?.partialOutput).toBe('partial output')
227
+ })
228
+
229
+ it('tool_execution_update ignores unknown toolCallId', () => {
230
+ const event: RpcEvent = {
231
+ type: 'tool_execution_update',
232
+ toolCallId: 'unknown',
233
+ partialResult: { content: [{ type: 'text', text: 'output' }] },
234
+ }
235
+ const state = handleRpcEvent(initialState, event)
236
+ expect(state).toBe(initialState)
237
+ })
238
+
239
+ it('tool_execution_end marks as done', () => {
240
+ const stateWithTool: AppState = {
241
+ ...initialState,
242
+ activeToolCalls: new Map([['call_1', { toolCallId: 'call_1', toolName: 'Bash', args: {}, status: 'running', partialOutput: null }]]),
243
+ }
244
+ const event: RpcEvent = {
245
+ type: 'tool_execution_end',
246
+ toolCallId: 'call_1',
247
+ result: { content: [{ type: 'text', text: 'final output' }] },
248
+ isError: false,
249
+ }
250
+ const state = handleRpcEvent(stateWithTool, event)
251
+ const toolCall = state.activeToolCalls.get('call_1')
252
+ expect(toolCall?.status).toBe('done')
253
+ expect(toolCall?.partialOutput).toBe('final output')
254
+ })
255
+
256
+ it('tool_execution_end marks as error when isError=true', () => {
257
+ const stateWithTool: AppState = {
258
+ ...initialState,
259
+ activeToolCalls: new Map([['call_1', { toolCallId: 'call_1', toolName: 'Bash', args: {}, status: 'running', partialOutput: null }]]),
260
+ }
261
+ const event: RpcEvent = {
262
+ type: 'tool_execution_end',
263
+ toolCallId: 'call_1',
264
+ result: { content: [{ type: 'text', text: 'error message' }] },
265
+ isError: true,
266
+ }
267
+ const state = handleRpcEvent(stateWithTool, event)
268
+ expect(state.activeToolCalls.get('call_1')?.status).toBe('error')
269
+ })
270
+ })
271
+
272
+ describe('full conversation flow', () => {
273
+ it('handles complete tool call cycle', () => {
274
+ let state = initialState
275
+
276
+ // User sends message
277
+ state = handleRpcEvent(state, {
278
+ type: 'message_start',
279
+ message: { role: 'user', content: [{ type: 'text', text: 'list files' }] },
280
+ })
281
+ expect(state.messages).toHaveLength(1)
282
+
283
+ // Agent starts
284
+ state = handleRpcEvent(state, { type: 'agent_start' })
285
+ expect(state.isStreaming).toBe(true)
286
+
287
+ // Assistant message with tool call starts
288
+ state = handleRpcEvent(state, {
289
+ type: 'message_start',
290
+ message: {
291
+ role: 'assistant',
292
+ content: [
293
+ { type: 'text', text: 'Let me list the files.' },
294
+ { type: 'toolCall', id: 'call_1', name: 'Bash', arguments: { command: 'ls' } },
295
+ ],
296
+ },
297
+ })
298
+ expect(state.messages).toHaveLength(2)
299
+ expect(state.streamingMessageIndex).toBe(1)
300
+
301
+ // Message streaming ends
302
+ state = handleRpcEvent(state, { type: 'message_end' })
303
+ expect(state.streamingMessageIndex).toBeNull()
304
+
305
+ // Tool execution starts
306
+ state = handleRpcEvent(state, {
307
+ type: 'tool_execution_start',
308
+ toolCallId: 'call_1',
309
+ toolName: 'Bash',
310
+ args: { command: 'ls' },
311
+ })
312
+ expect(state.activeToolCalls.has('call_1')).toBe(true)
313
+
314
+ // Tool execution updates with partial output
315
+ state = handleRpcEvent(state, {
316
+ type: 'tool_execution_update',
317
+ toolCallId: 'call_1',
318
+ partialResult: { content: [{ type: 'text', text: 'file1.txt' }] },
319
+ })
320
+ expect(state.activeToolCalls.get('call_1')?.partialOutput).toBe('file1.txt')
321
+
322
+ // Tool execution ends
323
+ state = handleRpcEvent(state, {
324
+ type: 'tool_execution_end',
325
+ toolCallId: 'call_1',
326
+ result: { content: [{ type: 'text', text: 'file1.txt\nfile2.txt' }] },
327
+ })
328
+ expect(state.activeToolCalls.get('call_1')?.status).toBe('done')
329
+
330
+ // Tool result message arrives - should remove from activeToolCalls
331
+ state = handleRpcEvent(state, {
332
+ type: 'message_start',
333
+ message: {
334
+ role: 'toolResult',
335
+ toolCallId: 'call_1',
336
+ toolName: 'Bash',
337
+ content: [{ type: 'text', text: 'file1.txt\nfile2.txt' }],
338
+ },
339
+ })
340
+ expect(state.activeToolCalls.has('call_1')).toBe(false)
341
+ expect(state.messages).toHaveLength(3)
342
+
343
+ // Assistant continues with analysis
344
+ state = handleRpcEvent(state, {
345
+ type: 'message_start',
346
+ message: {
347
+ role: 'assistant',
348
+ content: [{ type: 'text', text: 'There are 2 files.' }],
349
+ },
350
+ })
351
+ expect(state.messages).toHaveLength(4)
352
+
353
+ // Agent ends
354
+ state = handleRpcEvent(state, { type: 'agent_end' })
355
+ expect(state.isStreaming).toBe(false)
356
+ expect(state.activeToolCalls.size).toBe(0)
357
+ })
358
+ })
359
+ })
@@ -0,0 +1,255 @@
1
+ import type { AgentMessage, ActiveToolCall, SessionState, SessionStats, AvailableModel, ToolResultMessage, ContentBlock } from './types'
2
+
3
+ // =============================================================================
4
+ // State shape
5
+ // =============================================================================
6
+
7
+ export interface AppState {
8
+ // Connection
9
+ status: 'connecting' | 'connected' | 'disconnected' | 'error'
10
+ error: string | null
11
+ currentCwd: string | null
12
+
13
+ // Auth
14
+ authStatus: { authenticated: boolean; providers: string[] } | null
15
+
16
+ // Session
17
+ sessionState: SessionState | null
18
+ sessionStats: SessionStats | null
19
+ availableModels: AvailableModel[]
20
+
21
+ // Messages - THE source of truth for rendering
22
+ messages: AgentMessage[]
23
+
24
+ // Streaming indicators
25
+ isStreaming: boolean
26
+ streamingMessageIndex: number | null
27
+
28
+ // Active tool calls (for progress indicators during execution)
29
+ activeToolCalls: Map<string, ActiveToolCall>
30
+ }
31
+
32
+ export const initialState: AppState = {
33
+ status: 'connecting',
34
+ error: null,
35
+ currentCwd: null,
36
+ authStatus: null,
37
+ sessionState: null,
38
+ sessionStats: null,
39
+ availableModels: [],
40
+ messages: [],
41
+ isStreaming: false,
42
+ streamingMessageIndex: null,
43
+ activeToolCalls: new Map(),
44
+ }
45
+
46
+ // =============================================================================
47
+ // Actions
48
+ // =============================================================================
49
+
50
+ export type Action =
51
+ | { type: 'SET_STATUS'; status: AppState['status'] }
52
+ | { type: 'SET_ERROR'; error: string | null }
53
+ | { type: 'SET_CWD'; cwd: string }
54
+ | { type: 'CWD_CHANGED'; cwd: string }
55
+ | { type: 'SET_AUTH_STATUS'; authStatus: AppState['authStatus'] }
56
+ | { type: 'SET_SESSION_STATE'; sessionState: SessionState }
57
+ | { type: 'SET_SESSION_STATS'; sessionStats: SessionStats }
58
+ | { type: 'SET_AVAILABLE_MODELS'; models: AvailableModel[] }
59
+ | { type: 'SET_MESSAGES'; messages: AgentMessage[] }
60
+ | { type: 'CLEAR_SESSION' }
61
+ | { type: 'RPC_EVENT'; event: RpcEvent }
62
+
63
+ export type RpcEvent =
64
+ | { type: 'response'; command: string; success: boolean; data?: unknown }
65
+ | { type: 'agent_start' }
66
+ | { type: 'agent_end' }
67
+ | { type: 'message_start'; message: AgentMessage }
68
+ | { type: 'message_update'; message: AgentMessage }
69
+ | { type: 'message_end' }
70
+ | { type: 'tool_execution_start'; toolCallId: string; toolName: string; args: Record<string, unknown> }
71
+ | { type: 'tool_execution_update'; toolCallId: string; partialResult?: { content?: ContentBlock[] } }
72
+ | { type: 'tool_execution_end'; toolCallId: string; result?: { content?: ContentBlock[] }; isError?: boolean }
73
+
74
+ // =============================================================================
75
+ // Reducer
76
+ // =============================================================================
77
+
78
+ export function appReducer(state: AppState, action: Action): AppState {
79
+ switch (action.type) {
80
+ case 'SET_STATUS':
81
+ return { ...state, status: action.status }
82
+
83
+ case 'SET_ERROR':
84
+ return { ...state, error: action.error }
85
+
86
+ case 'SET_CWD':
87
+ return { ...state, currentCwd: action.cwd }
88
+
89
+ case 'CWD_CHANGED':
90
+ return {
91
+ ...state,
92
+ currentCwd: action.cwd,
93
+ messages: [],
94
+ sessionStats: null,
95
+ activeToolCalls: new Map(),
96
+ }
97
+
98
+ case 'SET_AUTH_STATUS':
99
+ return { ...state, authStatus: action.authStatus }
100
+
101
+ case 'SET_SESSION_STATE':
102
+ return { ...state, sessionState: action.sessionState }
103
+
104
+ case 'SET_SESSION_STATS':
105
+ return { ...state, sessionStats: action.sessionStats }
106
+
107
+ case 'SET_AVAILABLE_MODELS':
108
+ return { ...state, availableModels: action.models }
109
+
110
+ case 'SET_MESSAGES':
111
+ return { ...state, messages: action.messages }
112
+
113
+ case 'CLEAR_SESSION':
114
+ return { ...state, messages: [], sessionStats: null }
115
+
116
+ case 'RPC_EVENT':
117
+ return handleRpcEvent(state, action.event)
118
+
119
+ default:
120
+ return state
121
+ }
122
+ }
123
+
124
+ // =============================================================================
125
+ // RPC Event Handler - Core state logic following AGENT_SPEC.md
126
+ // =============================================================================
127
+
128
+ export function handleRpcEvent(state: AppState, event: RpcEvent): AppState {
129
+ switch (event.type) {
130
+ // -------------------------------------------------------------------------
131
+ // Response events
132
+ // -------------------------------------------------------------------------
133
+ case 'response': {
134
+ if (!event.success) return state
135
+
136
+ switch (event.command) {
137
+ case 'get_state':
138
+ return { ...state, sessionState: event.data as SessionState }
139
+ case 'get_messages':
140
+ return { ...state, messages: ((event.data as { messages?: AgentMessage[] })?.messages) || [] }
141
+ case 'get_available_models':
142
+ return { ...state, availableModels: ((event.data as { models?: AvailableModel[] })?.models) || [] }
143
+ case 'get_session_stats':
144
+ return { ...state, sessionStats: event.data as SessionStats }
145
+ case 'new_session':
146
+ return { ...state, messages: [], sessionStats: null }
147
+ default:
148
+ return state
149
+ }
150
+ }
151
+
152
+ // -------------------------------------------------------------------------
153
+ // Agent lifecycle events
154
+ // -------------------------------------------------------------------------
155
+ case 'agent_start':
156
+ return {
157
+ ...state,
158
+ isStreaming: true,
159
+ activeToolCalls: new Map(),
160
+ }
161
+
162
+ case 'agent_end':
163
+ return {
164
+ ...state,
165
+ isStreaming: false,
166
+ activeToolCalls: new Map(),
167
+ streamingMessageIndex: null,
168
+ }
169
+
170
+ // -------------------------------------------------------------------------
171
+ // Message events - Build messages incrementally
172
+ // -------------------------------------------------------------------------
173
+ case 'message_start': {
174
+ const msg = event.message
175
+ const newMessages = [...state.messages, msg]
176
+ let newState: AppState = {
177
+ ...state,
178
+ messages: newMessages,
179
+ }
180
+
181
+ // If assistant message, track streaming index
182
+ if (msg.role === 'assistant') {
183
+ newState.streamingMessageIndex = newMessages.length - 1
184
+ }
185
+
186
+ // If toolResult arrives, remove from activeToolCalls (it's now in messages)
187
+ if (msg.role === 'toolResult') {
188
+ const toolCallId = (msg as ToolResultMessage).toolCallId
189
+ const newActiveToolCalls = new Map(state.activeToolCalls)
190
+ newActiveToolCalls.delete(toolCallId)
191
+ newState.activeToolCalls = newActiveToolCalls
192
+ }
193
+
194
+ return newState
195
+ }
196
+
197
+ case 'message_update': {
198
+ const msg = event.message
199
+ if (state.messages.length === 0) {
200
+ return { ...state, messages: [msg] }
201
+ }
202
+ const updated = [...state.messages]
203
+ updated[updated.length - 1] = msg
204
+ return { ...state, messages: updated }
205
+ }
206
+
207
+ case 'message_end':
208
+ return { ...state, streamingMessageIndex: null }
209
+
210
+ // -------------------------------------------------------------------------
211
+ // Tool execution events - Track for progress indicators
212
+ // -------------------------------------------------------------------------
213
+ case 'tool_execution_start': {
214
+ const newActiveToolCalls = new Map(state.activeToolCalls)
215
+ newActiveToolCalls.set(event.toolCallId, {
216
+ toolCallId: event.toolCallId,
217
+ toolName: event.toolName,
218
+ args: event.args || {},
219
+ status: 'running',
220
+ partialOutput: null,
221
+ })
222
+ return { ...state, activeToolCalls: newActiveToolCalls }
223
+ }
224
+
225
+ case 'tool_execution_update': {
226
+ const existing = state.activeToolCalls.get(event.toolCallId)
227
+ if (!existing) return state
228
+
229
+ const text = event.partialResult?.content?.find((c): c is { type: 'text'; text: string } => c.type === 'text')?.text
230
+ const newActiveToolCalls = new Map(state.activeToolCalls)
231
+ newActiveToolCalls.set(event.toolCallId, {
232
+ ...existing,
233
+ partialOutput: text || null,
234
+ })
235
+ return { ...state, activeToolCalls: newActiveToolCalls }
236
+ }
237
+
238
+ case 'tool_execution_end': {
239
+ const existing = state.activeToolCalls.get(event.toolCallId)
240
+ if (!existing) return state
241
+
242
+ const text = event.result?.content?.find((c): c is { type: 'text'; text: string } => c.type === 'text')?.text
243
+ const newActiveToolCalls = new Map(state.activeToolCalls)
244
+ newActiveToolCalls.set(event.toolCallId, {
245
+ ...existing,
246
+ status: event.isError ? 'error' : 'done',
247
+ partialOutput: text || null,
248
+ })
249
+ return { ...state, activeToolCalls: newActiveToolCalls }
250
+ }
251
+
252
+ default:
253
+ return state
254
+ }
255
+ }