diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9f31648b3..a131828ac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixed +- Fixed interrupted reasoning blocks being incorrectly stripped when they contained a valid signature - Fixed interrupted thinking being lost in LLM provider requests after user interrupts by properly stripping trailing reasoning blocks from assistant turns while preserving them in the UI and session history. - Fixed the live todo HUD going stale during long tool-use loops by introducing a mid-run reconciliation reminder that prompts the agent to update incomplete items. - Fixed resumed OpenAI and OpenAI-Codex sessions losing encrypted reasoning and native assistant turns during rehydration. diff --git a/packages/coding-agent/src/session/messages.test.ts b/packages/coding-agent/src/session/messages.test.ts index aaaa190cb..3462a8fa0 100644 --- a/packages/coding-agent/src/session/messages.test.ts +++ b/packages/coding-agent/src/session/messages.test.ts @@ -105,4 +105,21 @@ describe("convertToLlm", () => { "thinking", ]); }); + + it("keeps a signed (complete) trailing thinking block in the assistant LLM view even with a continuity message", () => { + const messages: AgentMessage[] = [ + abortedAssistant([ + { type: "text", text: "partial answer" }, + { type: "thinking", thinking: "complete reasoning", thinkingSignature: "sig" }, + ]), + interruptedThinkingContinuity(), + ]; + + const llm = convertToLlm(messages); + const assistant = llm.find(entry => entry.role === "assistant"); + expect(Array.isArray(assistant?.content) && assistant.content.map(block => block.type)).toEqual([ + "text", + "thinking", + ]); + }); }); diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index c51a5f9e0..b5eb34f2b 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -56,12 +56,14 @@ export interface DemotedInterruptedThinking { } /** - * Remove a trailing interrupted-thinking run from an assistant message. + * Demote a trailing run of *incomplete* interrupted-thinking from an assistant + * message — reasoning that was still streaming when the user aborted. * - * Only visible, non-empty `thinking` blocks at the meaningful tail are demoted. - * Trailing empty text placeholders are ignored and omitted from the stripped - * content. Text, tool-call, redacted/encrypted-only, and empty-thinking tails - * are left unchanged. + * A block joins the run only when it is a non-empty `thinking` block with no + * `thinkingSignature`. A signed/complete thinking block (Anthropic signature, + * OpenAI reasoning item id) is safely replayable, so it ends the run and stays + * in place — as do `redactedThinking` encrypted blobs, text, tool calls, + * empty-thinking blocks, and trailing empty text placeholders. */ export function demoteInterruptedThinking( message: Pick, @@ -79,7 +81,7 @@ export function demoteInterruptedThinking( let runStart = scanEnd; while (runStart > 0) { const block = content[runStart - 1]!; - if (block.type !== "thinking" || block.thinking.trim().length === 0) { + if (block.type !== "thinking" || block.thinking.trim().length === 0 || block.thinkingSignature) { break; } runStart--; diff --git a/packages/coding-agent/test/session/interrupted-thinking-demote.test.ts b/packages/coding-agent/test/session/interrupted-thinking-demote.test.ts index bfa640499..41c8a710b 100644 --- a/packages/coding-agent/test/session/interrupted-thinking-demote.test.ts +++ b/packages/coding-agent/test/session/interrupted-thinking-demote.test.ts @@ -69,4 +69,26 @@ describe("demoteInterruptedThinking", () => { expect(demoteContent([{ type: "thinking", thinking: " \n\t" }])).toBeUndefined(); expect(demoteContent([{ type: "redactedThinking", data: "encrypted" }])).toBeUndefined(); }); + + it("preserves a non-empty signed thinking tail as native replayable reasoning", () => { + expect( + demoteContent([ + { type: "text", text: "Visible answer." }, + { type: "thinking", thinking: "Complete signed reasoning", thinkingSignature: "sig" }, + ]), + ).toBeUndefined(); + }); + + it("demotes only the unsigned tail and keeps an earlier signed thinking block", () => { + expect( + demoteContent([ + { type: "thinking", thinking: "Complete signed reasoning", thinkingSignature: "sig" }, + { type: "thinking", thinking: "Interrupted unsigned tail" }, + ]), + ).toEqual({ + reasoning: "Interrupted unsigned tail", + strippedContent: [{ type: "thinking", thinking: "Complete signed reasoning", thinkingSignature: "sig" }], + blockCount: 1, + }); + }); });