From 4c86da044e0562d45615347f1cb31b6a494b4654 Mon Sep 17 00:00:00 2001 From: Anton Sotkov Date: Sat, 10 Jan 2026 14:55:47 +0200 Subject: [PATCH 1/3] fix(sessions): persist reasoning/elevated across DMs --- src/config/sessions.test.ts | 8 ++++++++ src/config/sessions.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index bb1789839..c93040434 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -111,6 +111,10 @@ describe("sessions", () => { updatedAt: 123, systemSent: true, thinkingLevel: "low", + reasoningLevel: "on", + elevatedLevel: "on", + authProfileOverride: "auth-1", + compactionCount: 2, }, }, null, @@ -131,6 +135,10 @@ describe("sessions", () => { expect(store[mainSessionKey]?.updatedAt).toBeGreaterThanOrEqual(123); expect(store[mainSessionKey]?.lastProvider).toBe("telegram"); expect(store[mainSessionKey]?.lastTo).toBe("12345"); + expect(store[mainSessionKey]?.reasoningLevel).toBe("on"); + expect(store[mainSessionKey]?.elevatedLevel).toBe("on"); + expect(store[mainSessionKey]?.authProfileOverride).toBe("auth-1"); + expect(store[mainSessionKey]?.compactionCount).toBe(2); }); it("derives session transcripts dir from CLAWDBOT_STATE_DIR", () => { diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 052658afb..50f7d67d6 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -493,10 +493,16 @@ export async function updateLastRoute(params: { sessionFile: existing?.sessionFile, systemSent: existing?.systemSent, abortedLastRun: existing?.abortedLastRun, + spawnedBy: existing?.spawnedBy, thinkingLevel: existing?.thinkingLevel, verboseLevel: existing?.verboseLevel, + reasoningLevel: existing?.reasoningLevel, + elevatedLevel: existing?.elevatedLevel, providerOverride: existing?.providerOverride, modelOverride: existing?.modelOverride, + authProfileOverride: existing?.authProfileOverride, + groupActivation: existing?.groupActivation, + groupActivationNeedsSystemIntro: existing?.groupActivationNeedsSystemIntro, sendPolicy: existing?.sendPolicy, queueMode: existing?.queueMode, inputTokens: existing?.inputTokens, @@ -505,6 +511,7 @@ export async function updateLastRoute(params: { modelProvider: existing?.modelProvider, model: existing?.model, contextTokens: existing?.contextTokens, + compactionCount: existing?.compactionCount, displayName: existing?.displayName, chatType: existing?.chatType, provider: existing?.provider, From 3b5149ca39f2c8837c1914e1d83e4f546a7ee85a Mon Sep 17 00:00:00 2001 From: Anton Sotkov Date: Sat, 10 Jan 2026 14:55:52 +0200 Subject: [PATCH 2/3] fix: send only final answer with reasoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When reasoning is enabled on non‑block providers, we now ignore interim streaming chunks and send only the final assistant answer at completion, so replies aren’t partial or duplicated. --- src/agents/pi-embedded-subscribe.test.ts | 78 ++++++++++++++++++++++++ src/agents/pi-embedded-subscribe.ts | 24 +++++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index c238b2b8b..2b34439f7 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -347,6 +347,53 @@ describe("subscribeEmbeddedPiSession", () => { expect(combined).toBe("Final answer"); }); + it("keeps assistantTexts to the final answer when block replies are disabled", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const subscription = subscribeEmbeddedPiSession({ + session: session as unknown as Parameters< + typeof subscribeEmbeddedPiSession + >[0]["session"], + runId: "run", + reasoningMode: "on", + }); + + handler?.({ type: "message_start", message: { role: "assistant" } }); + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_delta", + delta: "Final answer", + }, + }); + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_end", + }, + }); + + const assistantMessage = { + role: "assistant", + content: [ + { type: "thinking", thinking: "Because it helps" }, + { type: "text", text: "Final answer" }, + ], + } as AssistantMessage; + + handler?.({ type: "message_end", message: assistantMessage }); + + expect(subscription.assistantTexts).toEqual(["Final answer"]); + }); + it("emits block replies on text_end and does not duplicate on message_end", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { @@ -580,6 +627,37 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["Hello world"]); }); + it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => { + let handler: SessionEventHandler | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const subscription = subscribeEmbeddedPiSession({ + session: session as unknown as Parameters< + typeof subscribeEmbeddedPiSession + >[0]["session"], + runId: "run", + reasoningMode: "on", + }); + + const assistantMessage = { + role: "assistant", + content: [ + { type: "thinking", thinking: "Because" }, + { type: "text", text: "Hello world" }, + ], + } as AssistantMessage; + + handler?.({ type: "message_end", message: assistantMessage }); + handler?.({ type: "message_end", message: assistantMessage }); + + expect(subscription.assistantTexts).toEqual(["Hello world"]); + }); + it("populates assistantTexts for non-streaming models with chunking enabled", () => { // Non-streaming models (e.g. zai/glm-4.7): no text_delta events; message_end // must still populate assistantTexts so providers can deliver a final reply. diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 1f2350e11..10a084520 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -294,6 +294,7 @@ export function subscribeEmbeddedPiSession(params: { let lastStreamedReasoning: string | undefined; let lastBlockReplyText: string | undefined; let assistantTextBaseline = 0; + let suppressBlockChunks = false; // Avoid late chunk inserts after final text merge. let compactionInFlight = false; let pendingCompactionRetry = 0; let compactionRetryResolve: (() => void) | undefined; @@ -419,6 +420,7 @@ export function subscribeEmbeddedPiSession(params: { }; const emitBlockChunk = (text: string) => { + if (suppressBlockChunks) return; // Strip blocks across chunk boundaries to avoid leaking reasoning. const strippedText = stripBlockThinkingSegments(text); const chunk = strippedText.trimEnd(); @@ -476,6 +478,7 @@ export function subscribeEmbeddedPiSession(params: { lastStreamedAssistant = undefined; lastStreamedReasoning = undefined; lastBlockReplyText = undefined; + suppressBlockChunks = false; assistantTextBaseline = 0; }; @@ -497,6 +500,7 @@ export function subscribeEmbeddedPiSession(params: { lastBlockReplyText = undefined; lastStreamedReasoning = undefined; lastReasoningSent = undefined; + suppressBlockChunks = false; assistantTextBaseline = assistantTexts.length; } } @@ -818,9 +822,23 @@ export function subscribeEmbeddedPiSession(params: { const addedDuringMessage = assistantTexts.length > assistantTextBaseline; const chunkerHasBuffered = blockChunker?.hasBuffered() ?? false; - // Non-streaming models (no text_delta): ensure assistantTexts gets the - // final text when the chunker has nothing buffered to drain. - if (!addedDuringMessage && !chunkerHasBuffered && text) { + // If we're not streaming block replies, ensure the final payload + // includes the final text even when deltas already populated assistantTexts. + if (includeReasoning && text && !params.onBlockReply) { + if (assistantTexts.length > assistantTextBaseline) { + assistantTexts.splice( + assistantTextBaseline, + assistantTexts.length - assistantTextBaseline, + text, + ); + } else { + const last = assistantTexts.at(-1); + if (!last || last !== text) assistantTexts.push(text); + } + suppressBlockChunks = true; + } else if (!addedDuringMessage && !chunkerHasBuffered && text) { + // Non-streaming models (no text_delta): ensure assistantTexts gets the + // final text when the chunker has nothing buffered to drain. const last = assistantTexts.at(-1); if (!last || last !== text) assistantTexts.push(text); } From 236f8560b3613dcd9f6b74d7144f21b739ba26c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 15:31:57 +0100 Subject: [PATCH 3/3] fix: reasoning iMessage sessions + final reply (#655) (thanks @antons) --- CHANGELOG.md | 1 + src/agents/pi-embedded-subscribe.test.ts | 10 ++++++- src/config/sessions.test.ts | 4 +++ src/config/sessions.ts | 33 +++--------------------- 4 files changed, 17 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 399369fde..4653e63af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage. - CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output. - Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs). +- iMessage: fix reasoning persistence across DMs; avoid partial/duplicate replies when reasoning is enabled. (#655) — thanks @antons. - Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75. ## 2026.1.9 diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index 2b34439f7..a5b44ea0f 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -370,7 +370,15 @@ describe("subscribeEmbeddedPiSession", () => { message: { role: "assistant" }, assistantMessageEvent: { type: "text_delta", - delta: "Final answer", + delta: "Final ", + }, + }); + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_delta", + delta: "answer", }, }); handler?.({ diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index c93040434..f13b3649f 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -111,6 +111,8 @@ describe("sessions", () => { updatedAt: 123, systemSent: true, thinkingLevel: "low", + responseUsage: "on", + queueDebounceMs: 1234, reasoningLevel: "on", elevatedLevel: "on", authProfileOverride: "auth-1", @@ -135,6 +137,8 @@ describe("sessions", () => { expect(store[mainSessionKey]?.updatedAt).toBeGreaterThanOrEqual(123); expect(store[mainSessionKey]?.lastProvider).toBe("telegram"); expect(store[mainSessionKey]?.lastTo).toBe("12345"); + expect(store[mainSessionKey]?.responseUsage).toBe("on"); + expect(store[mainSessionKey]?.queueDebounceMs).toBe(1234); expect(store[mainSessionKey]?.reasoningLevel).toBe("on"); expect(store[mainSessionKey]?.elevatedLevel).toBe("on"); expect(store[mainSessionKey]?.authProfileOverride).toBe("auth-1"); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 50f7d67d6..922df7461 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -487,38 +487,11 @@ export async function updateLastRoute(params: { const store = loadSessionStore(storePath); const existing = store[sessionKey]; const now = Date.now(); + const sessionId = existing?.sessionId ?? crypto.randomUUID(); const next: SessionEntry = { - sessionId: existing?.sessionId ?? crypto.randomUUID(), + ...(existing ?? { sessionId, updatedAt: 0 }), + sessionId, updatedAt: Math.max(existing?.updatedAt ?? 0, now), - sessionFile: existing?.sessionFile, - systemSent: existing?.systemSent, - abortedLastRun: existing?.abortedLastRun, - spawnedBy: existing?.spawnedBy, - thinkingLevel: existing?.thinkingLevel, - verboseLevel: existing?.verboseLevel, - reasoningLevel: existing?.reasoningLevel, - elevatedLevel: existing?.elevatedLevel, - providerOverride: existing?.providerOverride, - modelOverride: existing?.modelOverride, - authProfileOverride: existing?.authProfileOverride, - groupActivation: existing?.groupActivation, - groupActivationNeedsSystemIntro: existing?.groupActivationNeedsSystemIntro, - sendPolicy: existing?.sendPolicy, - queueMode: existing?.queueMode, - inputTokens: existing?.inputTokens, - outputTokens: existing?.outputTokens, - totalTokens: existing?.totalTokens, - modelProvider: existing?.modelProvider, - model: existing?.model, - contextTokens: existing?.contextTokens, - compactionCount: existing?.compactionCount, - displayName: existing?.displayName, - chatType: existing?.chatType, - provider: existing?.provider, - subject: existing?.subject, - room: existing?.room, - space: existing?.space, - skillsSnapshot: existing?.skillsSnapshot, lastProvider: provider, lastTo: to?.trim() ? to.trim() : undefined, lastAccountId: accountId?.trim()