@bytespell/shella 0.2.4 → 0.2.5
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.
- package/bundled-plugins/agent/AGENT_SPEC.md +611 -0
- package/bundled-plugins/agent/README.md +7 -0
- package/bundled-plugins/agent/components.json +24 -0
- package/bundled-plugins/agent/eslint.config.js +23 -0
- package/bundled-plugins/agent/index.html +13 -0
- package/bundled-plugins/agent/package-lock.json +12140 -0
- package/bundled-plugins/agent/package.json +62 -0
- package/bundled-plugins/agent/public/vite.svg +1 -0
- package/bundled-plugins/agent/server.js +631 -0
- package/bundled-plugins/agent/src/App.tsx +755 -0
- package/bundled-plugins/agent/src/assets/react.svg +1 -0
- package/bundled-plugins/agent/src/components/ui/alert-dialog.tsx +182 -0
- package/bundled-plugins/agent/src/components/ui/badge.tsx +45 -0
- package/bundled-plugins/agent/src/components/ui/button.tsx +60 -0
- package/bundled-plugins/agent/src/components/ui/card.tsx +94 -0
- package/bundled-plugins/agent/src/components/ui/combobox.tsx +294 -0
- package/bundled-plugins/agent/src/components/ui/dropdown-menu.tsx +253 -0
- package/bundled-plugins/agent/src/components/ui/field.tsx +225 -0
- package/bundled-plugins/agent/src/components/ui/input-group.tsx +147 -0
- package/bundled-plugins/agent/src/components/ui/input.tsx +19 -0
- package/bundled-plugins/agent/src/components/ui/label.tsx +24 -0
- package/bundled-plugins/agent/src/components/ui/select.tsx +185 -0
- package/bundled-plugins/agent/src/components/ui/separator.tsx +26 -0
- package/bundled-plugins/agent/src/components/ui/switch.tsx +31 -0
- package/bundled-plugins/agent/src/components/ui/textarea.tsx +18 -0
- package/bundled-plugins/agent/src/index.css +131 -0
- package/bundled-plugins/agent/src/lib/utils.ts +6 -0
- package/bundled-plugins/agent/src/main.tsx +11 -0
- package/bundled-plugins/agent/src/reducer.test.ts +359 -0
- package/bundled-plugins/agent/src/reducer.ts +255 -0
- package/bundled-plugins/agent/src/store.ts +379 -0
- package/bundled-plugins/agent/src/types.ts +98 -0
- package/bundled-plugins/agent/src/utils.test.ts +393 -0
- package/bundled-plugins/agent/src/utils.ts +158 -0
- package/bundled-plugins/agent/tsconfig.app.json +32 -0
- package/bundled-plugins/agent/tsconfig.json +13 -0
- package/bundled-plugins/agent/tsconfig.node.json +26 -0
- package/bundled-plugins/agent/vite.config.ts +14 -0
- package/bundled-plugins/agent/vitest.config.ts +17 -0
- package/bundled-plugins/terminal/README.md +7 -0
- package/bundled-plugins/terminal/index.html +24 -0
- package/bundled-plugins/terminal/package-lock.json +3346 -0
- package/bundled-plugins/terminal/package.json +38 -0
- package/bundled-plugins/terminal/server.ts +265 -0
- package/bundled-plugins/terminal/src/App.tsx +153 -0
- package/bundled-plugins/terminal/src/TERMINAL_SPEC.md +404 -0
- package/bundled-plugins/terminal/src/main.tsx +9 -0
- package/bundled-plugins/terminal/src/store.ts +114 -0
- package/bundled-plugins/terminal/tsconfig.json +22 -0
- package/bundled-plugins/terminal/vite.config.ts +10 -0
- 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
|
+
}
|