diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b0a3e17..f4e8a0ccc 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. - Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan. diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index 0a0035716..038aa273a 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -347,6 +347,61 @@ 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 ", + }, + }); + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_delta", + delta: "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 = { @@ -627,6 +682,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 b8d41040a..50b330d3b 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -304,6 +304,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; @@ -430,6 +431,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(); @@ -487,6 +489,7 @@ export function subscribeEmbeddedPiSession(params: { lastStreamedAssistant = undefined; lastStreamedReasoning = undefined; lastBlockReplyText = undefined; + suppressBlockChunks = false; assistantTextBaseline = 0; }; @@ -508,6 +511,7 @@ export function subscribeEmbeddedPiSession(params: { lastBlockReplyText = undefined; lastStreamedReasoning = undefined; lastReasoningSent = undefined; + suppressBlockChunks = false; assistantTextBaseline = assistantTexts.length; } } @@ -834,9 +838,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); } diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index bb1789839..f13b3649f 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -111,6 +111,12 @@ describe("sessions", () => { updatedAt: 123, systemSent: true, thinkingLevel: "low", + responseUsage: "on", + queueDebounceMs: 1234, + reasoningLevel: "on", + elevatedLevel: "on", + authProfileOverride: "auth-1", + compactionCount: 2, }, }, null, @@ -131,6 +137,12 @@ 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"); + 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..922df7461 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -487,31 +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, - thinkingLevel: existing?.thinkingLevel, - verboseLevel: existing?.verboseLevel, - providerOverride: existing?.providerOverride, - modelOverride: existing?.modelOverride, - sendPolicy: existing?.sendPolicy, - queueMode: existing?.queueMode, - inputTokens: existing?.inputTokens, - outputTokens: existing?.outputTokens, - totalTokens: existing?.totalTokens, - modelProvider: existing?.modelProvider, - model: existing?.model, - contextTokens: existing?.contextTokens, - 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()