@@ -305,6 +305,103 @@ describe('OpenCodeProcess', () => {
305305 expect ( thinkingHandler ) . not . toHaveBeenCalled ( )
306306 } )
307307
308+ it ( 'routes Kimi-style field=text deltas by partID (reasoning hidden, answer shown)' , async ( ) => {
309+ // Kimi via OpenCode streams BOTH reasoning and the answer as field=text
310+ // deltas, distinguished only by partID, and never sends
311+ // message.part.updated — so the part kind is resolved via a REST lookup.
312+ const textHandler = vi . fn ( )
313+ const thinkingHandler = vi . fn ( )
314+ ocp . on ( 'text' , textHandler )
315+ ocp . on ( 'thinking' , thinkingHandler )
316+ setSessionId ( ocp , 'oc-session-1' )
317+
318+ // classifyPart fetches GET /session/{sid}/message/{mid}, which returns the
319+ // message with all its parts and their types.
320+ mockFetch . mockImplementation ( ( url : string ) => {
321+ if ( url . includes ( '/message/msg_1' ) ) {
322+ return Promise . resolve ( {
323+ ok : true ,
324+ json : ( ) => Promise . resolve ( {
325+ parts : [
326+ { id : 'prt_reason' , type : 'reasoning' } ,
327+ { id : 'prt_answer' , type : 'text' } ,
328+ ] ,
329+ } ) ,
330+ } )
331+ }
332+ return Promise . resolve ( { ok : true , json : ( ) => Promise . resolve ( { } ) } )
333+ } )
334+
335+ for ( const d of [ 'The user ' , 'is greeting me. ' , 'I should respond.' ] ) {
336+ callHandleSSE ( ocp , {
337+ type : 'message.part.delta' ,
338+ properties : { sessionID : 'oc-session-1' , messageID : 'msg_1' , partID : 'prt_reason' , field : 'text' , delta : d } ,
339+ } )
340+ }
341+ // Deltas are buffered pending classification — nothing emitted yet.
342+ expect ( textHandler ) . not . toHaveBeenCalled ( )
343+ expect ( thinkingHandler ) . not . toHaveBeenCalled ( )
344+
345+ // Once the lookup resolves, reasoning becomes a thinking summary, never text.
346+ await vi . waitFor ( ( ) => expect ( thinkingHandler ) . toHaveBeenCalledTimes ( 1 ) )
347+ expect ( textHandler ) . not . toHaveBeenCalled ( )
348+
349+ // The answer part was classified by the same fetch, so its deltas show.
350+ for ( const d of [ 'Hello' , '! How can I help?' ] ) {
351+ callHandleSSE ( ocp , {
352+ type : 'message.part.delta' ,
353+ properties : { sessionID : 'oc-session-1' , messageID : 'msg_1' , partID : 'prt_answer' , field : 'text' , delta : d } ,
354+ } )
355+ }
356+ await vi . waitFor ( ( ) => expect ( textHandler ) . toHaveBeenCalled ( ) )
357+ const shown = textHandler . mock . calls . map ( c => c [ 0 ] as string ) . join ( '' )
358+ expect ( shown ) . toBe ( 'Hello! How can I help?' )
359+ expect ( shown ) . not . toContain ( 'greeting' )
360+ } )
361+
362+ it ( 'strips literal <think>...</think> tags emitted inside a text part' , ( ) => {
363+ // Kimi k2.7 sometimes wraps chain-of-thought in literal <think> tags inside
364+ // a visible text part, so part-kind classification can't hide it. The
365+ // streaming filter must drop the tags and their contents.
366+ const textHandler = vi . fn ( )
367+ const thinkingHandler = vi . fn ( )
368+ ocp . on ( 'text' , textHandler )
369+ ocp . on ( 'thinking' , thinkingHandler )
370+ setSessionId ( ocp , 'oc-session-1' )
371+
372+ callHandleSSE ( ocp , {
373+ type : 'message.part.delta' ,
374+ properties : {
375+ sessionID : 'oc-session-1' ,
376+ field : 'text' ,
377+ delta : '<think>Let me consider the options carefully.</think>The answer is 42.' ,
378+ } ,
379+ } )
380+ const shown = textHandler . mock . calls . map ( c => c [ 0 ] as string ) . join ( '' )
381+ expect ( shown ) . toBe ( 'The answer is 42.' )
382+ expect ( shown ) . not . toContain ( 'consider' )
383+ expect ( thinkingHandler ) . toHaveBeenCalled ( )
384+ } )
385+
386+ it ( 'strips <think> tags split across multiple text deltas' , ( ) => {
387+ const textHandler = vi . fn ( )
388+ ocp . on ( 'text' , textHandler )
389+ setSessionId ( ocp , 'oc-session-1' )
390+
391+ // Tags and contents arrive fragmented, including the tags themselves split
392+ // mid-token (e.g. "<thi" + "nk>", "</thi" + "nk>").
393+ for ( const d of [ 'Before. <thi' , 'nk>hidden rea' , 'soning here</thi' , 'nk>After.' ] ) {
394+ callHandleSSE ( ocp , {
395+ type : 'message.part.delta' ,
396+ properties : { sessionID : 'oc-session-1' , field : 'text' , delta : d } ,
397+ } )
398+ }
399+ const shown = textHandler . mock . calls . map ( c => c [ 0 ] as string ) . join ( '' )
400+ expect ( shown ) . toBe ( 'Before. After.' )
401+ expect ( shown ) . not . toContain ( 'hidden' )
402+ expect ( shown ) . not . toContain ( 'think' )
403+ } )
404+
308405 it ( 'maps running tool parts to tool_active events' , ( ) => {
309406 const toolActiveHandler = vi . fn ( )
310407 ocp . on ( 'tool_active' , toolActiveHandler )
0 commit comments