11import {
22 AgentKit ,
3+ type AssistantMessage ,
34 createAssistantMessageEventStream ,
45 getMessageText ,
56 type ModelDescriptor ,
@@ -20,6 +21,29 @@ function createFakeModel(): ModelDescriptor {
2021 } ;
2122}
2223
24+ function createAssistantMessage (
25+ overrides : Partial < AssistantMessage > = { }
26+ ) : AssistantMessage {
27+ return {
28+ role : 'assistant' ,
29+ api : 'fake-api' ,
30+ provider : 'fake' ,
31+ model : 'demo' ,
32+ usage : {
33+ input : 0 ,
34+ output : 0 ,
35+ cacheRead : 0 ,
36+ cacheWrite : 0 ,
37+ totalTokens : 0 ,
38+ cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
39+ } ,
40+ stopReason : 'stop' ,
41+ timestamp : Date . now ( ) ,
42+ content : [ { type : 'text' , text : 'hello world' } ] ,
43+ ...overrides ,
44+ } ;
45+ }
46+
2347describe ( 'agentic-kit core' , ( ) => {
2448 it ( 'transforms cross-provider thinking and inserts orphaned tool results' , ( ) => {
2549 const sourceModel = createFakeModel ( ) ;
@@ -150,6 +174,67 @@ describe('agentic-kit core', () => {
150174 } ) ;
151175 } ) ;
152176
177+ it ( 'normalizes short mistral tool-call ids without hanging and keeps tool results aligned' , ( ) => {
178+ const sourceModel = createFakeModel ( ) ;
179+ const targetModel : ModelDescriptor = {
180+ ...sourceModel ,
181+ provider : 'mistral' ,
182+ api : 'openai-compatible' ,
183+ id : 'mistral-demo' ,
184+ compat : {
185+ toolCallIdFormat : 'mistral9' ,
186+ } ,
187+ } ;
188+
189+ const transformed = transformMessages (
190+ [
191+ {
192+ ...createAssistantMessage ( {
193+ api : sourceModel . api ,
194+ provider : sourceModel . provider ,
195+ model : sourceModel . id ,
196+ stopReason : 'toolUse' ,
197+ content : [ { type : 'toolCall' , id : '%' , name : 'lookup' , arguments : { city : 'Paris' } } ] ,
198+ } ) ,
199+ } ,
200+ {
201+ role : 'toolResult' ,
202+ toolCallId : '%' ,
203+ toolName : 'lookup' ,
204+ content : [ { type : 'text' , text : 'ok' } ] ,
205+ isError : false ,
206+ timestamp : Date . now ( ) ,
207+ } ,
208+ ] ,
209+ targetModel
210+ ) ;
211+
212+ expect ( transformed ) . toHaveLength ( 2 ) ;
213+ expect ( transformed [ 0 ] ?. role ) . toBe ( 'assistant' ) ;
214+ expect ( transformed [ 1 ] ) . toMatchObject ( {
215+ role : 'toolResult' ,
216+ toolName : 'lookup' ,
217+ isError : false ,
218+ } ) ;
219+
220+ const assistant = transformed [ 0 ] ;
221+ if ( ! assistant || assistant . role !== 'assistant' ) {
222+ throw new Error ( 'Expected assistant message' ) ;
223+ }
224+
225+ const toolCall = assistant . content [ 0 ] ;
226+ if ( ! toolCall || toolCall . type !== 'toolCall' ) {
227+ throw new Error ( 'Expected tool call content' ) ;
228+ }
229+
230+ expect ( toolCall . id ) . toMatch ( / ^ [ A - Z a - z 0 - 9 ] { 9 } $ / ) ;
231+ expect ( toolCall . id ) . not . toContain ( '_' ) ;
232+ expect ( transformed [ 1 ] ) . toMatchObject ( {
233+ role : 'toolResult' ,
234+ toolCallId : toolCall . id ,
235+ } ) ;
236+ } ) ;
237+
153238 it ( 'keeps the legacy AgentKit generate API working through structured streams' , async ( ) => {
154239 const provider : ProviderAdapter & { name : string } = {
155240 api : 'fake-api' ,
@@ -158,23 +243,7 @@ describe('agentic-kit core', () => {
158243 createModel : ( ) => createFakeModel ( ) ,
159244 stream : ( ) => {
160245 const stream = createAssistantMessageEventStream ( ) ;
161- const message = {
162- role : 'assistant' as const ,
163- api : 'fake-api' ,
164- provider : 'fake' ,
165- model : 'demo' ,
166- usage : {
167- input : 0 ,
168- output : 0 ,
169- cacheRead : 0 ,
170- cacheWrite : 0 ,
171- totalTokens : 0 ,
172- cost : { input : 0 , output : 0 , cacheRead : 0 , cacheWrite : 0 , total : 0 } ,
173- } ,
174- stopReason : 'stop' as const ,
175- timestamp : Date . now ( ) ,
176- content : [ { type : 'text' as const , text : 'hello world' } ] ,
177- } ;
246+ const message = createAssistantMessage ( ) ;
178247
179248 queueMicrotask ( ( ) => {
180249 stream . push ( { type : 'start' , partial : { ...message , content : [ { type : 'text' , text : '' } ] } } ) ;
@@ -214,6 +283,108 @@ describe('agentic-kit core', () => {
214283 await expect ( kit . generate ( { model : 'demo' , prompt : 'hi' } ) ) . resolves . toBe ( 'hello world' ) ;
215284 } ) ;
216285
286+ it ( 'rejects legacy generate when a provider returns a terminal error in non-stream mode' , async ( ) => {
287+ const provider : ProviderAdapter & { name : string } = {
288+ api : 'fake-api' ,
289+ provider : 'fake' ,
290+ name : 'fake' ,
291+ createModel : ( ) => createFakeModel ( ) ,
292+ stream : ( ) => {
293+ const stream = createAssistantMessageEventStream ( ) ;
294+ const failure = createAssistantMessage ( {
295+ stopReason : 'error' ,
296+ errorMessage : 'provider failed' ,
297+ content : [ { type : 'text' , text : '' } ] ,
298+ } ) ;
299+
300+ queueMicrotask ( ( ) => {
301+ stream . push ( { type : 'error' , reason : 'error' , error : failure } ) ;
302+ stream . end ( failure ) ;
303+ } ) ;
304+
305+ return stream ;
306+ } ,
307+ } ;
308+
309+ const kit = new AgentKit ( ) . addProvider ( provider ) ;
310+ const onComplete = jest . fn ( ) ;
311+ const onError = jest . fn ( ) ;
312+ const onStateChange = jest . fn ( ) ;
313+
314+ await expect (
315+ kit . generate (
316+ { model : 'demo' , prompt : 'hi' } ,
317+ { onComplete, onError, onStateChange }
318+ )
319+ ) . rejects . toThrow ( 'provider failed' ) ;
320+
321+ expect ( onError ) . toHaveBeenCalledTimes ( 1 ) ;
322+ expect ( onComplete ) . not . toHaveBeenCalled ( ) ;
323+ expect ( onStateChange ) . not . toHaveBeenCalledWith ( 'complete' ) ;
324+ } ) ;
325+
326+ it ( 'rejects legacy generate when a provider returns a terminal error in stream mode' , async ( ) => {
327+ const provider : ProviderAdapter & { name : string } = {
328+ api : 'fake-api' ,
329+ provider : 'fake' ,
330+ name : 'fake' ,
331+ createModel : ( ) => createFakeModel ( ) ,
332+ stream : ( ) => {
333+ const stream = createAssistantMessageEventStream ( ) ;
334+ const partial = createAssistantMessage ( {
335+ content : [ { type : 'text' , text : 'partial' } ] ,
336+ } ) ;
337+ const failure = createAssistantMessage ( {
338+ stopReason : 'error' ,
339+ errorMessage : 'provider failed' ,
340+ content : [ { type : 'text' , text : 'partial' } ] ,
341+ } ) ;
342+
343+ queueMicrotask ( ( ) => {
344+ stream . push ( { type : 'start' , partial : { ...partial , content : [ { type : 'text' , text : '' } ] } } ) ;
345+ stream . push ( {
346+ type : 'text_start' ,
347+ contentIndex : 0 ,
348+ partial : { ...partial , content : [ { type : 'text' , text : '' } ] } ,
349+ } ) ;
350+ stream . push ( {
351+ type : 'text_delta' ,
352+ contentIndex : 0 ,
353+ delta : 'partial' ,
354+ partial,
355+ } ) ;
356+ stream . push ( { type : 'error' , reason : 'error' , error : failure } ) ;
357+ stream . end ( failure ) ;
358+ } ) ;
359+
360+ return stream ;
361+ } ,
362+ } ;
363+
364+ const kit = new AgentKit ( ) . addProvider ( provider ) ;
365+ const chunks : string [ ] = [ ] ;
366+ const onComplete = jest . fn ( ) ;
367+ const onError = jest . fn ( ) ;
368+ const onStateChange = jest . fn ( ) ;
369+
370+ await expect (
371+ kit . generate (
372+ { model : 'demo' , prompt : 'hi' , stream : true } ,
373+ {
374+ onChunk : ( chunk ) => chunks . push ( chunk ) ,
375+ onComplete,
376+ onError,
377+ onStateChange,
378+ }
379+ )
380+ ) . rejects . toThrow ( 'provider failed' ) ;
381+
382+ expect ( chunks ) . toEqual ( [ 'partial' ] ) ;
383+ expect ( onStateChange ) . toHaveBeenCalledWith ( 'streaming' ) ;
384+ expect ( onComplete ) . not . toHaveBeenCalled ( ) ;
385+ expect ( onError ) . toHaveBeenCalledTimes ( 1 ) ;
386+ } ) ;
387+
217388 it ( 'extracts assistant text from mixed content blocks' , ( ) => {
218389 const text = getMessageText ( {
219390 role : 'assistant' ,
0 commit comments