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
This commit is contained in:
Vincent Koc 2026-03-29 00:58:49 -07:00 committed by GitHub
parent f4d60478c9
commit aec58d4cde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 120 additions and 11 deletions

View File

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

View File

@ -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<string, { access?: string; refresh?: string }>;
};
expect(persisted.profiles["anthropic:default"]).toMatchObject({
access: "access-2",
refresh: "refresh-2",
});
} finally {
clearRuntimeAuthProfileStoreSnapshots();
await fs.rm(agentDir, { recursive: true, force: true });
}
});
});

View File

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

View File

@ -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([]));

View File

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