From aec58d4cde647502c2cb3cfc9cfd78fa8319b16a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 29 Mar 2026 00:58:49 -0700 Subject: [PATCH] fix(agents): repair btw reasoning and oauth snapshot refresh (#56001) * fix(agents): repair btw reasoning and oauth snapshot refresh * Update CHANGELOG.md * test(agents): strengthen btw reasoning assertion --- CHANGELOG.md | 2 + src/agents/auth-profiles.store.save.test.ts | 66 ++++++++++++++++++++- src/agents/auth-profiles/store.ts | 4 ++ src/agents/btw.test.ts | 46 ++++++++++++++ src/agents/btw.ts | 13 +--- 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fefd156876..5128f0e1ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,6 +195,8 @@ Docs: https://docs.openclaw.ai - Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev. - Runtime/install: lower the supported Node 22 floor to `22.14+` while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases. - CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release. +- Agents/BTW: force `/btw` side questions to disable provider reasoning so Anthropic adaptive-thinking sessions stop failing with `No BTW response generated`. Fixes #55376. Thanks @Catteres and @vincentkoc. +- Auth profiles/OAuth: refresh runtime auth snapshots when saving rotated credentials so OAuth providers do not reuse consumed refresh tokens after the first token rotation. Fixes #55389. Thanks @sam26880 and @vincentkoc. ### Fixes diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index 292921feaf1..d7e36d310ff 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -3,7 +3,12 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { resolveAuthStorePath } from "./auth-profiles/paths.js"; -import { saveAuthProfileStore } from "./auth-profiles/store.js"; +import { + clearRuntimeAuthProfileStoreSnapshots, + ensureAuthProfileStore, + replaceRuntimeAuthProfileStoreSnapshots, + saveAuthProfileStore, +} from "./auth-profiles/store.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; describe("saveAuthProfileStore", () => { @@ -61,4 +66,63 @@ describe("saveAuthProfileStore", () => { await fs.rm(agentDir, { recursive: true, force: true }); } }); + + it("refreshes the runtime snapshot when a saved store rotates oauth tokens", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-save-runtime-")); + try { + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir, + store: { + version: 1, + profiles: { + "anthropic:default": { + type: "oauth", + provider: "anthropic", + access: "access-1", + refresh: "refresh-1", + expires: 1, + }, + }, + }, + }, + ]); + + expect(ensureAuthProfileStore(agentDir).profiles["anthropic:default"]).toMatchObject({ + access: "access-1", + refresh: "refresh-1", + }); + + const rotatedStore: AuthProfileStore = { + version: 1, + profiles: { + "anthropic:default": { + type: "oauth", + provider: "anthropic", + access: "access-2", + refresh: "refresh-2", + expires: 2, + }, + }, + }; + + saveAuthProfileStore(rotatedStore, agentDir); + + expect(ensureAuthProfileStore(agentDir).profiles["anthropic:default"]).toMatchObject({ + access: "access-2", + refresh: "refresh-2", + }); + + const persisted = JSON.parse(await fs.readFile(resolveAuthStorePath(agentDir), "utf8")) as { + profiles: Record; + }; + expect(persisted.profiles["anthropic:default"]).toMatchObject({ + access: "access-2", + refresh: "refresh-2", + }); + } finally { + clearRuntimeAuthProfileStoreSnapshots(); + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 5dfe20d66b1..b66b4804670 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -566,6 +566,7 @@ export function ensureAuthProfileStore( export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void { const authPath = resolveAuthStorePath(agentDir); + const runtimeKey = resolveRuntimeStoreKey(agentDir); const profiles = Object.fromEntries( Object.entries(store.profiles).map(([profileId, credential]) => { if (credential.type === "api_key" && credential.keyRef && credential.key !== undefined) { @@ -590,4 +591,7 @@ export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string) } satisfies AuthProfileStore; saveJsonFile(authPath, payload); writeCachedAuthProfileStore(authPath, readAuthStoreMtimeMs(authPath), payload); + if (runtimeAuthStoreSnapshots.has(runtimeKey)) { + runtimeAuthStoreSnapshots.set(runtimeKey, cloneAuthProfileStore(payload)); + } } diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 37b19c6ffdc..b190e0881d2 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -124,6 +124,30 @@ function createDoneEvent(text: string) { }; } +function createThinkingOnlyDoneEvent(thinking: string) { + return { + type: "done", + reason: "stop", + message: { + role: "assistant", + content: [{ type: "thinking", thinking }], + provider: DEFAULT_PROVIDER, + api: "anthropic-messages", + model: DEFAULT_MODEL, + stopReason: "stop", + usage: { + input: 1, + output: 2, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 3, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + timestamp: Date.now(), + }, + }; +} + function mockDoneAnswer(text: string) { streamSimpleMock.mockReturnValue(makeAsyncEvents([createDoneEvent(text)])); } @@ -272,6 +296,28 @@ describe("runBtwSideQuestion", () => { expect(result).toEqual({ text: "Final answer." }); }); + it("forces provider reasoning off even when the session think level is adaptive", async () => { + streamSimpleMock.mockImplementation((_model, _input, options?: { reasoning?: unknown }) => { + return options?.reasoning === undefined + ? makeAsyncEvents([createDoneEvent("Final answer.")]) + : makeAsyncEvents([createThinkingOnlyDoneEvent("thinking only")]); + }); + + const result = await runSideQuestion({ resolvedThinkLevel: "adaptive" }); + + expect(result).toEqual({ text: "Final answer." }); + expect(streamSimpleMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ reasoning: undefined }), + ); + expect(streamSimpleMock).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.not.objectContaining({ reasoning: expect.anything() }), + ); + }); + it("fails when the current branch has no messages", async () => { clearBuiltSessionMessages(); streamSimpleMock.mockReturnValue(makeAsyncEvents([])); diff --git a/src/agents/btw.ts b/src/agents/btw.ts index d0f494277b1..b2c3b2766b2 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -2,7 +2,6 @@ import { streamSimple, type Api, type AssistantMessageEvent, - type ThinkingLevel as SimpleThinkingLevel, type Message, type Model, } from "@mariozechner/pi-ai"; @@ -22,7 +21,6 @@ import { ensureOpenClawModelsJson } from "./models-config.js"; import { EmbeddedBlockChunker, type BlockReplyChunking } from "./pi-embedded-block-chunker.js"; import { resolveModelWithRegistry } from "./pi-embedded-runner/model.js"; import { getActiveEmbeddedRunSnapshot } from "./pi-embedded-runner/runs.js"; -import { mapThinkingLevel } from "./pi-embedded-runner/utils.js"; import { discoverAuthStorage, discoverModels } from "./pi-model-discovery.js"; import { stripToolResultDetails } from "./session-transcript-repair.js"; @@ -97,13 +95,6 @@ function toSimpleContextMessages(messages: unknown[]): Message[] { ) as Message[]; } -function resolveSimpleThinkingLevel(level?: ThinkLevel): SimpleThinkingLevel | undefined { - if (!level || level === "off") { - return undefined; - } - return mapThinkingLevel(level) as SimpleThinkingLevel; -} - function resolveSessionTranscriptPath(params: { sessionId: string; sessionEntry?: SessionEntry; @@ -312,7 +303,9 @@ export async function runBtwSideQuestion( }, { apiKey, - reasoning: resolveSimpleThinkingLevel(params.resolvedThinkLevel), + // BTW is intentionally a lightweight side question path. Keep provider + // reasoning off so we reliably receive answer text instead of thinking-only output. + reasoning: undefined, signal: params.opts?.abortSignal, }, );