diff --git a/CHANGELOG.md b/CHANGELOG.md index 3feffb058..4d2f8dcbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixes - CLI/Status: make the “More” footer shorter and easier to scan (newlines + context-aware suggestions). - Docs/FAQ: make `clawdbot status` the first diagnostic step (and point to `status --all` for pasteable reports). +- CLI/Status: format non-JSON-serializable provider issue values more predictably. +- Gateway/Heartbeat: deliver reasoning even when the main heartbeat reply is `HEARTBEAT_OK`. (#694) — thanks @antons. ## 2026.1.11-7 diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 985a7110e..eccceca67 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -32,6 +32,7 @@ describe("getApiKeyForModel", () => { ); vi.resetModules(); + const { ensureAuthProfileStore } = await import("./auth-profiles.js"); const { getApiKeyForModel } = await import("./model-auth.js"); const model = { @@ -40,6 +41,9 @@ describe("getApiKeyForModel", () => { api: "openai-codex-responses", } as Model; + const store = ensureAuthProfileStore(process.env.CLAWDBOT_AGENT_DIR, { + allowKeychainPrompt: false, + }); const apiKey = await getApiKeyForModel({ model, cfg: { @@ -52,6 +56,8 @@ describe("getApiKeyForModel", () => { }, }, }, + store, + agentDir: process.env.CLAWDBOT_AGENT_DIR, }); expect(apiKey.apiKey).toBe(oauthFixture.access); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 89a1458c2..07952727a 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -129,6 +129,7 @@ export async function initSessionState(params: { let persistedThinking: string | undefined; let persistedVerbose: string | undefined; + let persistedReasoning: string | undefined; let persistedModelOverride: string | undefined; let persistedProviderOverride: string | undefined; @@ -194,6 +195,7 @@ export async function initSessionState(params: { abortedLastRun = entry.abortedLastRun ?? false; persistedThinking = entry.thinkingLevel; persistedVerbose = entry.verboseLevel; + persistedReasoning = entry.reasoningLevel; persistedModelOverride = entry.modelOverride; persistedProviderOverride = entry.providerOverride; } else { @@ -213,6 +215,7 @@ export async function initSessionState(params: { // Persist previously stored thinking/verbose levels when present. thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel, verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel, + reasoningLevel: persistedReasoning ?? baseEntry?.reasoningLevel, responseUsage: baseEntry?.responseUsage, modelOverride: persistedModelOverride ?? baseEntry?.modelOverride, providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride, diff --git a/src/infra/heartbeat-runner.test.ts b/src/infra/heartbeat-runner.test.ts index 24f882e94..a33d4a43d 100644 --- a/src/infra/heartbeat-runner.test.ts +++ b/src/infra/heartbeat-runner.test.ts @@ -321,6 +321,75 @@ describe("runHeartbeatOnce", () => { } }); + it("delivers reasoning even when the main heartbeat reply is HEARTBEAT_OK", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "sid", + updatedAt: Date.now(), + lastProvider: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + const cfg: ClawdbotConfig = { + agents: { + defaults: { + heartbeat: { + every: "5m", + target: "whatsapp", + to: "+1555", + includeReasoning: true, + }, + }, + }, + whatsapp: { allowFrom: ["*"] }, + session: { store: storePath }, + }; + + replySpy.mockResolvedValue([ + { text: "Reasoning:\nBecause it helps" }, + { text: "HEARTBEAT_OK" }, + ]); + const sendWhatsApp = vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + }); + + await runHeartbeatOnce({ + cfg, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(sendWhatsApp).toHaveBeenCalledTimes(1); + expect(sendWhatsApp).toHaveBeenNthCalledWith( + 1, + "+1555", + "Reasoning:\nBecause it helps", + expect.any(Object), + ); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("loads the default agent session from templated stores", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storeTemplate = path.join( diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index bbf86f902..c53435d09 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -262,6 +262,11 @@ export async function runHeartbeatOnce(opts: { const replyPayload = resolveHeartbeatReplyPayload(replyResult); const includeReasoning = cfg.agents?.defaults?.heartbeat?.includeReasoning === true; + const reasoningPayloads = includeReasoning + ? resolveHeartbeatReasoningPayloads(replyResult).filter( + (payload) => payload !== replyPayload, + ) + : []; if ( !replyPayload || @@ -291,7 +296,8 @@ export async function runHeartbeatOnce(opts: { ).responsePrefix, ackMaxChars, ); - if (normalized.shouldSkip && !normalized.hasMedia) { + const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia; + if (shouldSkipMain && reasoningPayloads.length === 0) { await restoreHeartbeatUpdatedAt({ storePath, sessionKey, @@ -309,18 +315,19 @@ export async function runHeartbeatOnce(opts: { const mediaUrls = replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []); - - const reasoningPayloads = includeReasoning - ? resolveHeartbeatReasoningPayloads(replyResult).filter( - (payload) => payload !== replyPayload, - ) - : []; + // Reasoning payloads are text-only; any attachments stay on the main reply. + const previewText = shouldSkipMain + ? reasoningPayloads + .map((payload) => payload.text) + .filter((text): text is string => Boolean(text?.trim())) + .join("\n") + : normalized.text; if (delivery.provider === "none" || !delivery.to) { emitHeartbeatEvent({ status: "skipped", reason: delivery.reason ?? "no-target", - preview: normalized.text?.slice(0, 200), + preview: previewText?.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, }); @@ -333,7 +340,7 @@ export async function runHeartbeatOnce(opts: { emitHeartbeatEvent({ status: "skipped", reason: readiness.reason, - preview: normalized.text?.slice(0, 200), + preview: previewText?.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, }); @@ -350,10 +357,14 @@ export async function runHeartbeatOnce(opts: { to: delivery.to, payloads: [ ...reasoningPayloads, - { - text: normalized.text, - mediaUrls, - }, + ...(shouldSkipMain + ? [] + : [ + { + text: normalized.text, + mediaUrls, + }, + ]), ], deps: opts.deps, }); @@ -361,7 +372,7 @@ export async function runHeartbeatOnce(opts: { emitHeartbeatEvent({ status: "sent", to: delivery.to, - preview: normalized.text?.slice(0, 200), + preview: previewText?.slice(0, 200), durationMs: Date.now() - startedAt, hasMedia: mediaUrls.length > 0, }); diff --git a/src/infra/providers-status-issues.ts b/src/infra/providers-status-issues.ts index fdaa331c7..4f1cd13af 100644 --- a/src/infra/providers-status-issues.ts +++ b/src/infra/providers-status-issues.ts @@ -67,7 +67,12 @@ function formatValue(value: unknown): string | undefined { try { return JSON.stringify(value); } catch { - return String(value); + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number" || typeof value === "boolean") { + return value.toString(); + } + if (typeof value === "symbol") return value.toString(); + return Object.prototype.toString.call(value); } }