Skip to content

Commit 91b79b0

Browse files
authored
fix: preserve interleaved reasoning (#1543)
1 parent 07aae86 commit 91b79b0

5 files changed

Lines changed: 297 additions & 25 deletions

File tree

src/main/presenter/agentRuntimePresenter/contextBuilder.ts

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,33 @@ export function recordToChatMessages(
269269
.filter((block) => block.type === 'reasoning_content')
270270
.map((block) => block.content)
271271
.join('')
272+
const contentParts = blocks
273+
.filter(
274+
(block): block is AssistantMessageBlock & { content: string } =>
275+
block.type === 'content' && typeof block.content === 'string' && block.content.length > 0
276+
)
277+
.map((block) => {
278+
const providerOptions = getBlockProviderOptions(block)
279+
return {
280+
type: 'text' as const,
281+
text: block.content,
282+
...(providerOptions ? { provider_options: providerOptions } : {})
283+
}
284+
})
285+
const assistantContent = contentParts.some((part) => part.provider_options) ? contentParts : text
286+
const applyReasoningContent = (assistantMessage: ChatMessage): ChatMessage => {
287+
if (preserveInterleavedReasoning && reasoning) {
288+
assistantMessage.reasoning_content = reasoning
289+
const reasoningProviderOptions = blocks
290+
.filter((block) => block.type === 'reasoning_content')
291+
.map((block) => getBlockProviderOptions(block))
292+
.find(Boolean)
293+
if (reasoningProviderOptions) {
294+
assistantMessage.reasoning_provider_options = reasoningProviderOptions
295+
}
296+
}
297+
return assistantMessage
298+
}
272299

273300
const toolCallBlocks = blocks.filter(
274301
(block) =>
@@ -281,6 +308,9 @@ export function recordToChatMessages(
281308
)
282309

283310
if (toolCallBlocks.length === 0) {
311+
if (preserveInterleavedReasoning && reasoning) {
312+
return [applyReasoningContent({ role: 'assistant', content: assistantContent })]
313+
}
284314
return [{ role: 'assistant', content: combinedText }]
285315
}
286316

@@ -301,38 +331,18 @@ export function recordToChatMessages(
301331
}
302332

303333
if (toolCalls.length === 0) {
334+
if (preserveInterleavedReasoning && reasoning) {
335+
return [applyReasoningContent({ role: 'assistant', content: assistantContent })]
336+
}
304337
return [{ role: 'assistant', content: combinedText }]
305338
}
306339

307-
const contentParts = blocks
308-
.filter(
309-
(block): block is AssistantMessageBlock & { content: string } =>
310-
block.type === 'content' && typeof block.content === 'string' && block.content.length > 0
311-
)
312-
.map((block) => {
313-
const providerOptions = getBlockProviderOptions(block)
314-
return {
315-
type: 'text' as const,
316-
text: block.content,
317-
...(providerOptions ? { provider_options: providerOptions } : {})
318-
}
319-
})
320-
321340
const assistantMessage: ChatMessage = {
322341
role: 'assistant',
323-
content: contentParts.some((part) => part.provider_options) ? contentParts : text,
342+
content: assistantContent,
324343
tool_calls: toolCalls
325344
}
326-
if (preserveInterleavedReasoning && reasoning) {
327-
assistantMessage.reasoning_content = reasoning
328-
const reasoningProviderOptions = blocks
329-
.filter((block) => block.type === 'reasoning_content')
330-
.map((block) => getBlockProviderOptions(block))
331-
.find(Boolean)
332-
if (reasoningProviderOptions) {
333-
assistantMessage.reasoning_provider_options = reasoningProviderOptions
334-
}
335-
}
345+
applyReasoningContent(assistantMessage)
336346

337347
const result: ChatMessage[] = [assistantMessage]
338348
for (const block of toolCallBlocks) {

test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3231,6 +3231,80 @@ describe('AgentRuntimePresenter', () => {
32313231
expect(processStream).toHaveBeenCalledTimes(1)
32323232
})
32333233

3234+
it('preserves reasoning_content when resuming after a question answer', async () => {
3235+
await agent.initSession('s1', {
3236+
providerId: 'openai',
3237+
modelId: 'gpt-4',
3238+
generationSettings: { forceInterleavedThinkingCompat: true }
3239+
})
3240+
const row = makeAssistantRow({
3241+
blocks: [
3242+
{
3243+
type: 'reasoning_content',
3244+
content: 'Think before asking.',
3245+
status: 'success',
3246+
timestamp: 1
3247+
},
3248+
{
3249+
type: 'content',
3250+
content: 'Need a user choice.',
3251+
status: 'success',
3252+
timestamp: 2
3253+
},
3254+
{
3255+
type: 'tool_call',
3256+
status: 'pending',
3257+
timestamp: 3,
3258+
tool_call: { id: 'tc1', name: 'ask_question', params: '{}', response: '' }
3259+
},
3260+
{
3261+
type: 'action',
3262+
action_type: 'question_request',
3263+
status: 'pending',
3264+
timestamp: 4,
3265+
content: 'Pick one',
3266+
tool_call: { id: 'tc1', name: 'ask_question', params: '{}' },
3267+
extra: {
3268+
needsUserAction: true,
3269+
questionText: 'Pick one',
3270+
questionOptions: [{ label: 'A' }]
3271+
}
3272+
}
3273+
]
3274+
})
3275+
sqlitePresenter.deepchatMessagesTable.updateContent.mockImplementation(
3276+
(id: string, content: string) => {
3277+
if (id === row.id) {
3278+
row.content = content
3279+
}
3280+
}
3281+
)
3282+
3283+
const result = await agent.respondToolInteraction('s1', 'm1', 'tc1', {
3284+
kind: 'question_option',
3285+
optionLabel: 'A'
3286+
})
3287+
3288+
expect(result).toEqual({ resumed: true })
3289+
const callArgs = (processStream as ReturnType<typeof vi.fn>).mock.calls[0][0]
3290+
const assistantMessage = callArgs.messages.find(
3291+
(message: any) => message.role === 'assistant'
3292+
)
3293+
expect(callArgs.interleavedReasoning.preserveReasoningContent).toBe(true)
3294+
expect(assistantMessage).toEqual({
3295+
role: 'assistant',
3296+
content: 'Need a user choice.',
3297+
reasoning_content: 'Think before asking.',
3298+
tool_calls: [
3299+
{
3300+
id: 'tc1',
3301+
type: 'function',
3302+
function: { name: 'ask_question', arguments: '{}' }
3303+
}
3304+
]
3305+
})
3306+
})
3307+
32343308
it('treats an aborted resume signal as cancellation even for non-abort errors', async () => {
32353309
await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' })
32363310
makeAssistantRow({ blocks: [] })

test/main/presenter/agentRuntimePresenter/contextBuilder.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,23 @@ describe('buildContext', () => {
372372
})
373373
})
374374

375+
it('preserves reasoning_content separately for non-tool assistant history when enabled', () => {
376+
const messages = [
377+
makeUserRecord(1, 'Think about this'),
378+
makeAssistantWithReasoningRecord(2, 'The answer is 42', 'Let me think...')
379+
]
380+
const store = createMockMessageStore(messages)
381+
const result = buildContext('s1', 'Follow up', '', 10000, 4096, store, false, {
382+
preserveInterleavedReasoning: true
383+
})
384+
385+
expect(result[1]).toEqual({
386+
role: 'assistant',
387+
content: 'The answer is 42',
388+
reasoning_content: 'Let me think...'
389+
})
390+
})
391+
375392
it('preserves reasoning_content separately for settled tool calls when enabled', () => {
376393
const messages = [
377394
makeUserRecord(1, 'Use a tool'),
@@ -664,6 +681,73 @@ describe('buildResumeContext', () => {
664681
{ role: 'assistant', content: 'partial answer' }
665682
])
666683
})
684+
685+
it('preserves reasoning_content for pending resume targets with resolved tool calls', () => {
686+
const messages = [
687+
makeUserRecord(1, 'recent user'),
688+
{
689+
id: 'resume-target',
690+
sessionId: 's1',
691+
orderSeq: 2,
692+
role: 'assistant' as const,
693+
content: JSON.stringify([
694+
{
695+
type: 'reasoning_content',
696+
content: 'Need a tool first.',
697+
status: 'success',
698+
timestamp: Date.now()
699+
},
700+
{ type: 'content', content: 'Running tool...', status: 'success', timestamp: Date.now() },
701+
{
702+
type: 'tool_call',
703+
status: 'success',
704+
timestamp: Date.now(),
705+
tool_call: {
706+
id: 'tc-resume',
707+
name: 'example_tool',
708+
params: '{"foo":"bar"}',
709+
response: 'tool result'
710+
}
711+
},
712+
{
713+
type: 'action',
714+
action_type: 'question_request',
715+
status: 'success',
716+
timestamp: Date.now(),
717+
content: 'Pick one',
718+
tool_call: { id: 'tc-resume', name: 'example_tool', params: '{"foo":"bar"}' },
719+
extra: { needsUserAction: false, answerText: 'A' }
720+
}
721+
]),
722+
status: 'pending' as const,
723+
isContextEdge: 0,
724+
metadata: '{}',
725+
createdAt: Date.now(),
726+
updatedAt: Date.now()
727+
}
728+
]
729+
const store = createMockMessageStore(messages)
730+
const result = buildResumeContext('s1', 'resume-target', '', 10000, 4096, store, false, {
731+
preserveInterleavedReasoning: true
732+
})
733+
734+
expect(result).toEqual([
735+
{ role: 'user', content: 'recent user' },
736+
{
737+
role: 'assistant',
738+
content: 'Running tool...',
739+
reasoning_content: 'Need a tool first.',
740+
tool_calls: [
741+
{
742+
id: 'tc-resume',
743+
type: 'function',
744+
function: { name: 'example_tool', arguments: '{"foo":"bar"}' }
745+
}
746+
]
747+
},
748+
{ role: 'tool', tool_call_id: 'tc-resume', content: 'tool result' }
749+
])
750+
})
667751
})
668752

669753
describe('fitMessagesToContextWindow', () => {

test/main/presenter/agentRuntimePresenter/process.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,71 @@ describe('processStream', () => {
727727
expect(toolPresenter.callTool).toHaveBeenCalledTimes(2)
728728
})
729729

730+
it('passes reasoning_content back after each interleaved tool-call loop', async () => {
731+
let callCount = 0
732+
const toolPresenter = createMockToolPresenter({ get_weather: 'Sunny' })
733+
734+
const coreStream = vi.fn(function () {
735+
callCount++
736+
const round = callCount
737+
if (round <= 2) {
738+
return (async function* () {
739+
yield {
740+
type: 'reasoning',
741+
reasoning_content: `Think ${round}`
742+
} as LLMCoreStreamEvent
743+
yield {
744+
type: 'tool_call_start',
745+
tool_call_id: `tc${round}`,
746+
tool_call_name: 'get_weather'
747+
} as LLMCoreStreamEvent
748+
yield {
749+
type: 'tool_call_end',
750+
tool_call_id: `tc${round}`,
751+
tool_call_arguments_complete: `{"round":${round}}`
752+
} as LLMCoreStreamEvent
753+
yield { type: 'stop', stop_reason: 'tool_use' } as LLMCoreStreamEvent
754+
})()
755+
}
756+
757+
return (async function* () {
758+
yield { type: 'text', content: 'Final answer' } as LLMCoreStreamEvent
759+
yield { type: 'stop', stop_reason: 'complete' } as LLMCoreStreamEvent
760+
})()
761+
}) as unknown as ProcessParams['coreStream']
762+
763+
const params = createParams({
764+
coreStream,
765+
toolPresenter,
766+
tools: [makeTool('get_weather')],
767+
interleavedReasoning: {
768+
...DEFAULT_INTERLEAVED_REASONING,
769+
preserveReasoningContent: true,
770+
portraitInterleaved: true
771+
}
772+
})
773+
774+
const promise = processStream(params)
775+
await vi.runAllTimersAsync()
776+
await promise
777+
778+
expect(coreStream).toHaveBeenCalledTimes(3)
779+
const secondCallMessages = (coreStream as ReturnType<typeof vi.fn>).mock.calls[1][0]
780+
const firstAssistantMessage = secondCallMessages.find(
781+
(message: any) => message.role === 'assistant' && message.tool_calls?.[0]?.id === 'tc1'
782+
)
783+
expect(firstAssistantMessage.reasoning_content).toBe('Think 1')
784+
785+
const thirdCallMessages = (coreStream as ReturnType<typeof vi.fn>).mock.calls[2][0]
786+
const toolCallAssistantMessages = thirdCallMessages.filter(
787+
(message: any) => message.role === 'assistant' && message.tool_calls?.length
788+
)
789+
expect(toolCallAssistantMessages.map((message: any) => message.reasoning_content)).toEqual([
790+
'Think 1',
791+
'Think 2'
792+
])
793+
})
794+
730795
it('max tool calls limit', async () => {
731796
let callCount = 0
732797
const toolPresenter = createMockToolPresenter({ action: 'done' })

test/main/presenter/llmProviderPresenter/aiSdkMessageMapper.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,43 @@ describe('AI SDK message mapper', () => {
3535
}
3636
])
3737
})
38+
39+
it('maps interleaved reasoning and native tool calls into assistant parts', () => {
40+
const result = mapMessagesToModelMessages(
41+
[
42+
{
43+
role: 'assistant',
44+
content: 'I need current data.',
45+
reasoning_content: 'Plan the lookup first.',
46+
tool_calls: [
47+
{
48+
id: 'tc1',
49+
type: 'function',
50+
function: { name: 'search', arguments: '{"query":"weather"}' }
51+
}
52+
]
53+
}
54+
],
55+
{
56+
tools: [],
57+
supportsNativeTools: true
58+
}
59+
)
60+
61+
expect(result).toEqual([
62+
{
63+
role: 'assistant',
64+
content: [
65+
{ type: 'reasoning', text: 'Plan the lookup first.' },
66+
{ type: 'text', text: 'I need current data.' },
67+
{
68+
type: 'tool-call',
69+
toolCallId: 'tc1',
70+
toolName: 'search',
71+
input: { query: 'weather' }
72+
}
73+
]
74+
}
75+
])
76+
})
3877
})

0 commit comments

Comments
 (0)