feat(coding-agent/session): preserved signed thinking blocks during demotion

- Updated the demotion logic to treat complete, signed thinking blocks as stable content that terminates an interrupted stream.
- Protected signed thinking runs from being stripped when processing interrupted agent messages.
This commit is contained in:
can1357
2026-06-28 08:26:24 +02:00
parent 670d60fa60
commit da07a3ac5f
4 changed files with 48 additions and 6 deletions
+1
View File
@@ -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.
@@ -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",
]);
});
});
@@ -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<AssistantMessage, "content">,
@@ -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--;
@@ -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,
});
});
});