Merge pull request #655 from antons/fix/reasoning-imsg

Fix reasoning in iMessage
This commit is contained in:
Peter Steinberger
2026-01-10 14:39:14 +00:00
committed by GitHub
5 changed files with 123 additions and 26 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 <think> 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);
}

View File

@@ -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", () => {

View File

@@ -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()