diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 425386253..613977ba3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -15,7 +15,7 @@ ### Fixed -- Fixed interrupted-turn continuity prompts resembling reasoning-extraction requests by reframing the preserved text as a natural user interruption. +- Refined interrupted-turn continuity prompts by omitting reasoning fragments under 60 characters, relying on native signed or encrypted thinking when available, and framing preserved text as a natural user interruption. - Fixed session-title generation regressing after prompt condensation: the telegraphic rewrite of `title-system.md` garbled small-model output (invented names, punctuation-only titles). Restored plain-sentence phrasing with a name-fidelity instruction, pinned the online title request to greedy decoding, and rejected punctuation-only titles in normalization. - Fixed Agent Control Center failing to open when an agent model override is configured as a YAML array. ([#8201](https://github.com/can1357/oh-my-pi/issues/8201)) - Fixed streaming and finalized transcript blocks exposing width-independent source boundaries so multiplexer pane resizes retain output queued during settlement without duplicating prior transcript history. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index ea3fcf31f..8fbf8301d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -452,6 +452,8 @@ function cloneMessageEndNotification(message: AgentMessage): AgentMessage { return snapshot as unknown as AgentMessage; } +const INTERRUPTED_THINKING_MIN_CHARS = 60; + export class AgentSession { readonly agent: Agent; readonly sessionManager: SessionManager; @@ -2348,19 +2350,19 @@ export class AgentSession { } /** - * On a user-interrupted (`Esc`) abort, copy the trailing thinking run into a - * hidden `display: false` continuity message for the next turn WITHOUT - * mutating the assistant message. The original thinking stays on the message - * so live render, reload, and display-reset rebuilds keep showing it; `convertToLlm` - * strips the run from the provider request (incomplete/unsigned thinking is - * rejected on resend) when this continuity message follows the assistant turn. + * On a user-interrupted (`Esc`) abort, copy a meaningful trailing thinking + * run into hidden continuity context for the next turn. Short fragments are + * omitted; `convertToLlm` still strips their incomplete thinking from replay. + * + * The original thinking stays on the assistant message so live render, reload, + * and display-reset rebuilds keep showing it. */ #demoteInterruptedThinkingOnUserInterrupt( message: AssistantMessage, ): CustomMessage | undefined { if (message.stopReason !== "aborted" || !isUserInterruptAbort(message)) return undefined; const demoted = demoteInterruptedThinking(message); - if (!demoted) return undefined; + if (!demoted || demoted.reasoning.length < INTERRUPTED_THINKING_MIN_CHARS) return undefined; const interruptedAt = Date.now(); return { role: "custom", diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts index acde7698b..031c25b24 100644 --- a/packages/coding-agent/src/session/messages.ts +++ b/packages/coding-agent/src/session/messages.ts @@ -406,11 +406,7 @@ function followedByInterruptedThinking(messages: AgentMessage[], index: number): return next !== undefined && next.role === "custom" && next.customType === INTERRUPTED_THINKING_MESSAGE_TYPE; } -/** - * Drop the demoted trailing thinking run from an assistant message for the LLM - * view only. The run is incomplete and unsigned, so providers reject it; the - * continuity message that follows carries the reasoning instead. - */ +/** Drop an incomplete trailing thinking run from an interrupted assistant in the LLM view. */ function stripDemotedThinkingForLlm(message: AssistantMessage): AssistantMessage { const demoted = demoteInterruptedThinking(message); return demoted ? { ...message, content: demoted.strippedContent } : message; @@ -1263,12 +1259,12 @@ function convertOne(m: AgentMessage, interruptedNext: boolean): Message[] { return converted ? [converted] : []; } case "assistant": { - // A user-interrupted turn keeps its trailing thinking run on the - // persisted/displayed message so reload and display-reset rebuilds still - // show it. That run is incomplete/unsigned and gets rejected on - // resend, so strip it here — LLM path only — when the hidden - // interrupted-thinking continuity message follows. - const source = interruptedNext ? stripDemotedThinkingForLlm(m) : m; + // Persisted/displayed messages retain interrupted thinking. Signed or + // encrypted blocks replay natively; incomplete unsigned runs are + // stripped whether or not they were long enough for a continuity note. + const userInterrupted = m.stopReason === "aborted" && isUserInterruptAbort(m); + const source = interruptedNext || userInterrupted ? stripDemotedThinkingForLlm(m) : m; + if (userInterrupted && !interruptedNext && source.content.length === 0) return []; const converted = convertMessageToLlm(source); return converted ? [converted] : []; } diff --git a/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts b/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts index 0ff4beb6c..96eed0867 100644 --- a/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts +++ b/packages/coding-agent/test/agent-session-interrupted-thinking.test.ts @@ -47,8 +47,8 @@ function baseAssistant(model: Model, content: AssistantMessage["content"]): }; } -function thinkingAssistant(model: Model, errorMessage: string): AssistantMessage { - const thinking: ThinkingContent = { type: "thinking", thinking: REASONING_TEXT }; +function thinkingAssistant(model: Model, errorMessage: string, reasoning = REASONING_TEXT): AssistantMessage { + const thinking: ThinkingContent = { type: "thinking", thinking: reasoning }; return { ...baseAssistant(model, [thinking]), errorMessage }; } @@ -204,6 +204,39 @@ describe("AgentSession interrupted thinking persistence", () => { const developerLlm = llm.filter(entry => entry.role === "developer"); expect(developerLlm.some(entry => JSON.stringify(entry.content).includes(REASONING_TEXT))).toBe(true); }); + it("skips hidden continuity for interrupted reasoning shorter than 60 characters", async () => { + const harness = createSession(); + const reasoning = "x".repeat(59); + await emitAssistantEnd( + harness.session, + harness.sessionManager, + thinkingAssistant(harness.model, USER_INTERRUPT_LABEL, reasoning), + entry => entry.type === "message" && entry.message.role === "assistant", + ); + + const messages = harness.session.agent.state.messages; + expect(messages.find(isAssistantMessage)?.content).toEqual([{ type: "thinking", thinking: reasoning }]); + expect(messages.some(isInterruptedThinkingMessage)).toBe(false); + const llm = convertToLlm(messages); + expect(llm.some(entry => entry.role === "assistant")).toBe(false); + expect(llm.some(entry => JSON.stringify(entry.content).includes(reasoning))).toBe(false); + }); + + it("keeps hidden continuity for exactly 60 characters", async () => { + const harness = createSession(); + const reasoning = "x".repeat(60); + await emitAssistantEnd( + harness.session, + harness.sessionManager, + thinkingAssistant(harness.model, USER_INTERRUPT_LABEL, reasoning), + entry => entry.type === "custom_message" && entry.customType === INTERRUPTED_THINKING_MESSAGE_TYPE, + ); + + const hidden = harness.session.agent.state.messages.find(isInterruptedThinkingMessage); + expect(typeof hidden?.content === "string" ? hidden.content : JSON.stringify(hidden?.content)).toContain( + reasoning, + ); + }); it("makes hidden continuity available in agent state before awaited message_end delivery finishes", async () => { const releaseExtension = Promise.withResolvers();