Skip to content

Commit ef0bc66

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/goal-runs
2 parents 7e46399 + 62e020d commit ef0bc66

2 files changed

Lines changed: 379 additions & 42 deletions

File tree

server/opencode-process.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)