|
1 | | -import { createAssistantMessageEventStream, type Context, type ModelDescriptor } from 'agentic-kit'; |
| 1 | +import { |
| 2 | + createAssistantMessageEventStream, |
| 3 | + type AssistantMessage, |
| 4 | + type Context, |
| 5 | + type ModelDescriptor, |
| 6 | +} from 'agentic-kit'; |
2 | 7 |
|
3 | 8 | import { Agent } from '../src'; |
4 | 9 |
|
@@ -147,4 +152,180 @@ describe('@agentic-kit/agent', () => { |
147 | 152 | content: [{ type: 'text', text: 'done' }], |
148 | 153 | }); |
149 | 154 | }); |
| 155 | + |
| 156 | + it('turns tool argument validation failures into error tool results and continues', async () => { |
| 157 | + const responses = [ |
| 158 | + createAssistantResponse({ |
| 159 | + stopReason: 'toolUse', |
| 160 | + content: [{ type: 'toolCall', id: 'tool_1', name: 'echo', arguments: {} }], |
| 161 | + }), |
| 162 | + createAssistantResponse({ |
| 163 | + stopReason: 'stop', |
| 164 | + content: [{ type: 'text', text: 'recovered' }], |
| 165 | + }), |
| 166 | + ]; |
| 167 | + |
| 168 | + let callIndex = 0; |
| 169 | + const agent = new Agent({ |
| 170 | + initialState: { model: createModel() }, |
| 171 | + streamFn: () => streamMessage(responses[callIndex++]), |
| 172 | + }); |
| 173 | + |
| 174 | + const execute = jest.fn(async () => ({ |
| 175 | + content: [{ type: 'text' as const, text: 'should not run' }], |
| 176 | + })); |
| 177 | + |
| 178 | + agent.setTools([ |
| 179 | + { |
| 180 | + name: 'echo', |
| 181 | + label: 'Echo', |
| 182 | + description: 'Echo text', |
| 183 | + parameters: { |
| 184 | + type: 'object', |
| 185 | + properties: { |
| 186 | + text: { type: 'string' }, |
| 187 | + }, |
| 188 | + required: ['text'], |
| 189 | + }, |
| 190 | + execute, |
| 191 | + }, |
| 192 | + ]); |
| 193 | + |
| 194 | + await agent.prompt('hello'); |
| 195 | + |
| 196 | + expect(execute).not.toHaveBeenCalled(); |
| 197 | + expect(agent.state.messages[2]).toMatchObject({ |
| 198 | + role: 'toolResult', |
| 199 | + toolName: 'echo', |
| 200 | + isError: true, |
| 201 | + }); |
| 202 | + expect(agent.state.messages[2].content[0]).toMatchObject({ |
| 203 | + type: 'text', |
| 204 | + text: expect.stringContaining('Tool argument validation failed'), |
| 205 | + }); |
| 206 | + expect(agent.state.messages[3]).toMatchObject({ |
| 207 | + role: 'assistant', |
| 208 | + content: [{ type: 'text', text: 'recovered' }], |
| 209 | + }); |
| 210 | + }); |
| 211 | + |
| 212 | + it('records aborted assistant turns when the active stream is cancelled', async () => { |
| 213 | + const agent = new Agent({ |
| 214 | + initialState: { model: createModel() }, |
| 215 | + streamFn: (_model: ModelDescriptor, _context: Context, options) => { |
| 216 | + const stream = createAssistantMessageEventStream(); |
| 217 | + const partial = createAssistantResponse({ |
| 218 | + stopReason: 'stop', |
| 219 | + content: [{ type: 'text', text: '' }], |
| 220 | + }); |
| 221 | + |
| 222 | + queueMicrotask(() => { |
| 223 | + stream.push({ type: 'start', partial }); |
| 224 | + |
| 225 | + options?.signal?.addEventListener( |
| 226 | + 'abort', |
| 227 | + () => { |
| 228 | + const aborted = createAssistantResponse({ |
| 229 | + stopReason: 'aborted', |
| 230 | + errorMessage: 'aborted by test', |
| 231 | + content: [], |
| 232 | + }); |
| 233 | + stream.push({ type: 'error', reason: 'aborted', error: aborted }); |
| 234 | + stream.end(aborted); |
| 235 | + }, |
| 236 | + { once: true } |
| 237 | + ); |
| 238 | + }); |
| 239 | + |
| 240 | + return stream; |
| 241 | + }, |
| 242 | + }); |
| 243 | + |
| 244 | + const pending = agent.prompt('slow'); |
| 245 | + setTimeout(() => agent.abort(), 0); |
| 246 | + await pending; |
| 247 | + |
| 248 | + expect(agent.state.error).toBe('aborted by test'); |
| 249 | + expect(agent.state.messages.at(-1)).toMatchObject({ |
| 250 | + role: 'assistant', |
| 251 | + stopReason: 'aborted', |
| 252 | + errorMessage: 'aborted by test', |
| 253 | + }); |
| 254 | + expect(agent.state.isStreaming).toBe(false); |
| 255 | + expect(agent.state.streamMessage).toBeNull(); |
| 256 | + }); |
150 | 257 | }); |
| 258 | + |
| 259 | +function createAssistantResponse(overrides: Partial<AssistantMessage>): AssistantMessage { |
| 260 | + return { |
| 261 | + ...createAssistantResponseBase(), |
| 262 | + ...overrides, |
| 263 | + }; |
| 264 | +} |
| 265 | + |
| 266 | +function createAssistantResponseBase(): AssistantMessage { |
| 267 | + return { |
| 268 | + role: 'assistant' as const, |
| 269 | + api: 'fake', |
| 270 | + provider: 'fake', |
| 271 | + model: 'demo', |
| 272 | + usage: { |
| 273 | + input: 1, |
| 274 | + output: 1, |
| 275 | + cacheRead: 0, |
| 276 | + cacheWrite: 0, |
| 277 | + totalTokens: 2, |
| 278 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, |
| 279 | + }, |
| 280 | + stopReason: 'stop' as const, |
| 281 | + timestamp: Date.now(), |
| 282 | + content: [] as AssistantMessage['content'], |
| 283 | + }; |
| 284 | +} |
| 285 | + |
| 286 | +function streamMessage(message: AssistantMessage) { |
| 287 | + const stream = createAssistantMessageEventStream(); |
| 288 | + |
| 289 | + queueMicrotask(() => { |
| 290 | + stream.push({ type: 'start', partial: message }); |
| 291 | + if (message.content[0]?.type === 'toolCall') { |
| 292 | + stream.push({ |
| 293 | + type: 'toolcall_start', |
| 294 | + contentIndex: 0, |
| 295 | + partial: message, |
| 296 | + }); |
| 297 | + stream.push({ |
| 298 | + type: 'toolcall_end', |
| 299 | + contentIndex: 0, |
| 300 | + toolCall: message.content[0], |
| 301 | + partial: message, |
| 302 | + }); |
| 303 | + } else { |
| 304 | + stream.push({ |
| 305 | + type: 'text_start', |
| 306 | + contentIndex: 0, |
| 307 | + partial: message, |
| 308 | + }); |
| 309 | + stream.push({ |
| 310 | + type: 'text_delta', |
| 311 | + contentIndex: 0, |
| 312 | + delta: message.content[0]?.type === 'text' ? message.content[0].text : '', |
| 313 | + partial: message, |
| 314 | + }); |
| 315 | + stream.push({ |
| 316 | + type: 'text_end', |
| 317 | + contentIndex: 0, |
| 318 | + content: message.content[0]?.type === 'text' ? message.content[0].text : '', |
| 319 | + partial: message, |
| 320 | + }); |
| 321 | + } |
| 322 | + stream.push({ |
| 323 | + type: 'done', |
| 324 | + reason: message.stopReason === 'toolUse' ? 'toolUse' : 'stop', |
| 325 | + message, |
| 326 | + }); |
| 327 | + stream.end(message); |
| 328 | + }); |
| 329 | + |
| 330 | + return stream; |
| 331 | +} |
0 commit comments