diff --git a/CHANGELOG.md b/CHANGELOG.md index f52f8cb2b19..8fbc6d58f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Providers/OpenAI: separate API-key and Codex sign-in onboarding groups, and avoid replaying stale OpenAI Responses reasoning blocks after a model route switch. - Discord/subagents: preserve thread-bound completion delivery by keeping the requester-agent announce path primary and falling back to direct thread sends only when the announce produces no visible output. (#71064) Thanks @DolencLuka. - Browser/tool: give Chrome MCP existing-session manage calls a longer default timeout, pass explicit tool timeouts through tab management, and recover stale selected-page MCP sessions instead of forcing a manual reset. Thanks @steipete. - Browser/sandbox: clean up idle tracked tabs opened by primary-agent browser sessions, while preserving active tab reuse and lifecycle cleanup for subagents, cron, and ACP sessions. Fixes #71165. Thanks @dwbutler. diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index 66a66f519e2..b65e913b205 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -112,7 +112,7 @@ external end-user instructions. **OpenAI / OpenAI Codex** - Image sanitization only. -- Drop orphaned reasoning signatures (standalone reasoning items without a following content block) for OpenAI Responses/Codex transcripts. +- Drop orphaned reasoning signatures (standalone reasoning items without a following content block) for OpenAI Responses/Codex transcripts, and drop replayable OpenAI reasoning after a model route switch. - No tool call id sanitization. - No tool result pairing repair. - No turn validation or reordering. diff --git a/extensions/openai/auth-choice-copy.ts b/extensions/openai/auth-choice-copy.ts index e9f3d73f296..6b872a34a42 100644 --- a/extensions/openai/auth-choice-copy.ts +++ b/extensions/openai/auth-choice-copy.ts @@ -4,8 +4,14 @@ export const OPENAI_CODEX_LOGIN_HINT = "Sign in with OpenAI in your browser"; export const OPENAI_CODEX_DEVICE_PAIRING_LABEL = "OpenAI Codex Device Pairing"; export const OPENAI_CODEX_DEVICE_PAIRING_HINT = "Pair in browser with a device code"; -export const OPENAI_WIZARD_GROUP = { +export const OPENAI_API_KEY_WIZARD_GROUP = { groupId: "openai", groupLabel: "OpenAI", - groupHint: "API key or Codex sign-in", + groupHint: "Direct API key", +} as const; + +export const OPENAI_CODEX_WIZARD_GROUP = { + groupId: "openai-codex", + groupLabel: "OpenAI Codex", + groupHint: "ChatGPT/Codex sign-in", } as const; diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index bab32e65eaa..406717258c0 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -117,11 +117,15 @@ describe("openai codex provider", () => { expect(oauth?.wizard).toMatchObject({ choiceLabel: "OpenAI Codex Browser Login", - groupHint: "API key or Codex sign-in", + groupId: "openai-codex", + groupLabel: "OpenAI Codex", + groupHint: "ChatGPT/Codex sign-in", }); expect(deviceCode?.wizard).toMatchObject({ choiceLabel: "OpenAI Codex Device Pairing", - groupHint: "API key or Codex sign-in", + groupId: "openai-codex", + groupLabel: "OpenAI Codex", + groupHint: "ChatGPT/Codex sign-in", }); }); diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 53b0ad417de..032fee6d599 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -25,7 +25,7 @@ import { OPENAI_CODEX_DEVICE_PAIRING_LABEL, OPENAI_CODEX_LOGIN_HINT, OPENAI_CODEX_LOGIN_LABEL, - OPENAI_WIZARD_GROUP, + OPENAI_CODEX_WIZARD_GROUP, } from "./auth-choice-copy.js"; import { isOpenAIApiBaseUrl, isOpenAICodexBaseUrl } from "./base-url.js"; import { OPENAI_CODEX_DEFAULT_MODEL } from "./default-models.js"; @@ -426,7 +426,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { choiceLabel: OPENAI_CODEX_LOGIN_LABEL, choiceHint: OPENAI_CODEX_LOGIN_HINT, assistantPriority: OPENAI_CODEX_LOGIN_ASSISTANT_PRIORITY, - ...OPENAI_WIZARD_GROUP, + ...OPENAI_CODEX_WIZARD_GROUP, }, run: async (ctx) => await runOpenAICodexOAuth(ctx), }, @@ -440,7 +440,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL, choiceHint: OPENAI_CODEX_DEVICE_PAIRING_HINT, assistantPriority: OPENAI_CODEX_DEVICE_PAIRING_ASSISTANT_PRIORITY, - ...OPENAI_WIZARD_GROUP, + ...OPENAI_CODEX_WIZARD_GROUP, }, run: async (ctx) => { try { diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index af69f829092..fb5855e9390 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -55,7 +55,9 @@ describe("buildOpenAIProvider", () => { expect(apiKey?.wizard).toMatchObject({ choiceLabel: "OpenAI API Key", - groupHint: "API key or Codex sign-in", + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "Direct API key", }); }); diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index 0595777ea7c..2dd10e07046 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -10,7 +10,7 @@ import { type ProviderPlugin, } from "openclaw/plugin-sdk/provider-model-shared"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { OPENAI_API_KEY_LABEL, OPENAI_WIZARD_GROUP } from "./auth-choice-copy.js"; +import { OPENAI_API_KEY_LABEL, OPENAI_API_KEY_WIZARD_GROUP } from "./auth-choice-copy.js"; import { isOpenAIApiBaseUrl } from "./base-url.js"; import { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "./default-models.js"; import { @@ -222,7 +222,7 @@ export function buildOpenAIProvider(): ProviderPlugin { wizard: { choiceId: "openai-api-key", choiceLabel: OPENAI_API_KEY_LABEL, - ...OPENAI_WIZARD_GROUP, + ...OPENAI_API_KEY_WIZARD_GROUP, }, }), ], diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 100fed03b2b..5ca93949ae4 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -18,9 +18,9 @@ "choiceLabel": "OpenAI Codex Browser Login", "choiceHint": "Sign in with OpenAI in your browser", "assistantPriority": -30, - "groupId": "openai", - "groupLabel": "OpenAI", - "groupHint": "API key or Codex sign-in" + "groupId": "openai-codex", + "groupLabel": "OpenAI Codex", + "groupHint": "ChatGPT/Codex sign-in" }, { "provider": "openai-codex", @@ -29,9 +29,9 @@ "choiceLabel": "OpenAI Codex Device Pairing", "choiceHint": "Pair in browser with a device code", "assistantPriority": -10, - "groupId": "openai", - "groupLabel": "OpenAI", - "groupHint": "API key or Codex sign-in" + "groupId": "openai-codex", + "groupLabel": "OpenAI Codex", + "groupHint": "ChatGPT/Codex sign-in" }, { "provider": "openai", @@ -41,7 +41,7 @@ "assistantPriority": -40, "groupId": "openai", "groupLabel": "OpenAI", - "groupHint": "API key or Codex sign-in", + "groupHint": "Direct API key", "optionKey": "openaiApiKey", "cliFlag": "--openai-api-key", "cliOption": "--openai-api-key ", diff --git a/extensions/openai/openclaw.plugin.test.ts b/extensions/openai/openclaw.plugin.test.ts index 953e201a11f..86ebdf12ff2 100644 --- a/extensions/openai/openclaw.plugin.test.ts +++ b/extensions/openai/openclaw.plugin.test.ts @@ -74,21 +74,28 @@ describe("OpenAI plugin manifest", () => { expect(codexBrowserLogin).toMatchObject({ choiceLabel: "OpenAI Codex Browser Login", choiceHint: "Sign in with OpenAI in your browser", - groupHint: "API key or Codex sign-in", + groupId: "openai-codex", + groupLabel: "OpenAI Codex", + groupHint: "ChatGPT/Codex sign-in", }); expect(codexDeviceCode).toMatchObject({ choiceLabel: "OpenAI Codex Device Pairing", choiceHint: "Pair in browser with a device code", - groupHint: "API key or Codex sign-in", + groupId: "openai-codex", + groupLabel: "OpenAI Codex", + groupHint: "ChatGPT/Codex sign-in", }); expect(apiKey).toMatchObject({ choiceLabel: "OpenAI API Key", - groupHint: "API key or Codex sign-in", + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "Direct API key", }); expect(choices.map((choice) => choice.choiceLabel)).not.toContain( "OpenAI Codex (ChatGPT OAuth)", ); expect(choices.map((choice) => choice.groupHint)).not.toContain("Codex OAuth + API key"); + expect(choices.map((choice) => choice.groupHint)).not.toContain("API key or Codex sign-in"); }); it("keeps auth choice copy aligned with provider wizard metadata", () => { diff --git a/extensions/openai/provider-contract-api.ts b/extensions/openai/provider-contract-api.ts index 622ca58e607..f2aab961253 100644 --- a/extensions/openai/provider-contract-api.ts +++ b/extensions/openai/provider-contract-api.ts @@ -5,7 +5,8 @@ import { OPENAI_CODEX_DEVICE_PAIRING_LABEL, OPENAI_CODEX_LOGIN_HINT, OPENAI_CODEX_LOGIN_LABEL, - OPENAI_WIZARD_GROUP, + OPENAI_API_KEY_WIZARD_GROUP, + OPENAI_CODEX_WIZARD_GROUP, } from "./auth-choice-copy.js"; const noopAuth = async () => ({ profiles: [] }); @@ -33,7 +34,7 @@ export function createOpenAICodexProvider(): ProviderPlugin { choiceLabel: OPENAI_CODEX_LOGIN_LABEL, choiceHint: OPENAI_CODEX_LOGIN_HINT, assistantPriority: -30, - ...OPENAI_WIZARD_GROUP, + ...OPENAI_CODEX_WIZARD_GROUP, }, }, { @@ -47,7 +48,7 @@ export function createOpenAICodexProvider(): ProviderPlugin { choiceLabel: OPENAI_CODEX_DEVICE_PAIRING_LABEL, choiceHint: OPENAI_CODEX_DEVICE_PAIRING_HINT, assistantPriority: -10, - ...OPENAI_WIZARD_GROUP, + ...OPENAI_CODEX_WIZARD_GROUP, }, }, ], @@ -72,7 +73,7 @@ export function createOpenAIProvider(): ProviderPlugin { choiceId: "openai-api-key", choiceLabel: OPENAI_API_KEY_LABEL, assistantPriority: -40, - ...OPENAI_WIZARD_GROUP, + ...OPENAI_API_KEY_WIZARD_GROUP, }, }, ], diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts index be73ad35513..9c3ea4b9c2a 100644 --- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts @@ -453,6 +453,26 @@ describe("downgradeOpenAIReasoningBlocks", () => { expect(downgradeOpenAIReasoningBlocks(input as any)).toEqual(input); }); + it("drops replayable reasoning when requested even with following content", () => { + const input = [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + thinkingSignature: JSON.stringify({ id: "rs_123", type: "reasoning" }), + }, + { type: "text", text: "answer" }, + ], + }, + ]; + + expect(downgradeOpenAIReasoningBlocks(input as any, { dropReplayableReasoning: true })).toEqual( + [{ role: "assistant", content: [{ type: "text", text: "answer" }] }], + ); + }); + it("drops orphaned reasoning blocks without following content", () => { const input = [ { diff --git a/src/agents/pi-embedded-helpers/openai.ts b/src/agents/pi-embedded-helpers/openai.ts index c96151b18b6..13bfb224876 100644 --- a/src/agents/pi-embedded-helpers/openai.ts +++ b/src/agents/pi-embedded-helpers/openai.ts @@ -16,6 +16,10 @@ type OpenAIReasoningSignature = { type: string; }; +type DowngradeOpenAIReasoningBlocksOptions = { + dropReplayableReasoning?: boolean; +}; + function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature | null { if (!value) { return null; @@ -201,12 +205,15 @@ export function downgradeOpenAIFunctionCallReasoningPairs( /** * OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id - * without the required following item. + * without the required following item, or stale encrypted reasoning after a model route switch. * * OpenClaw persists provider-specific reasoning metadata in `thinkingSignature`; if that metadata - * is incomplete, drop the block to keep history usable. + * is incomplete or no longer replay-safe, drop the block to keep history usable. */ -export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentMessage[] { +export function downgradeOpenAIReasoningBlocks( + messages: AgentMessage[], + options: DowngradeOpenAIReasoningBlocksOptions = {}, +): AgentMessage[] { let anyChanged = false; const out: AgentMessage[] = []; @@ -248,6 +255,10 @@ export function downgradeOpenAIReasoningBlocks(messages: AgentMessage[]): AgentM nextContent.push(block); continue; } + if (options.dropReplayableReasoning) { + changed = true; + continue; + } if (hasFollowingNonThinkingBlock(assistantMsg.content, i)) { nextContent.push(block); continue; diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts index 58649597dee..5f6420381c8 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts @@ -10,6 +10,7 @@ import { sanitizeSnapshotChangedOpenAIReasoning, sanitizeWithOpenAIResponses, } from "./pi-embedded-runner.sanitize-session-history.test-harness.js"; +import { makeZeroUsageSnapshot } from "./usage.js"; vi.mock( "./pi-embedded-helpers.js", @@ -73,6 +74,12 @@ describe("sanitizeSessionHistory e2e smoke", () => { sanitizeSessionHistory, }); - expect(result).toEqual([]); + expect(result).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "answer" }], + usage: makeZeroUsageSnapshot(), + }, + ]); }); }); diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts index 2e73db49ad9..268f54439ec 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts @@ -113,24 +113,29 @@ export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise> = [ + { + type: "thinking", + thinking: "reasoning", + thinkingSignature, + }, + ]; + if (opts?.includeText) { + content.push({ type: "text", text: "answer" }); + } // Intentional: we want to build message payloads that can carry non-string // signatures, but core typing currently expects a string. const messages = [ { role: "assistant", - content: [ - { - type: "thinking", - thinking: "reasoning", - thinkingSignature, - }, - ], + content, }, ]; @@ -178,7 +183,7 @@ export function makeSnapshotChangedOpenAIReasoningScenario() { ]; return { sessionManager: makeInMemorySessionManager(sessionEntries), - messages: makeReasoningAssistantMessages({ thinkingSignature: "object" }), + messages: makeReasoningAssistantMessages({ thinkingSignature: "object", includeText: true }), modelId: "gpt-5.4", }; } diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 86ddf7f0278..8c9a54b5caa 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -789,7 +789,42 @@ describe("sanitizeSessionHistory", () => { sanitizeSessionHistory, }); - expect(result).toEqual([]); + expect(result).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "answer" }], + usage: makeZeroUsageSnapshot(), + }, + ]); + }); + + it("keeps paired openai reasoning when the model snapshot stays the same", async () => { + const sessionEntries = [ + makeModelSnapshotEntry({ + provider: "openai", + modelApi: "openai-responses", + modelId: "gpt-5.4", + }), + ]; + const sessionManager = makeInMemorySessionManager(sessionEntries); + const messages = makeReasoningAssistantMessages({ + thinkingSignature: "json", + includeText: true, + }); + + const result = await sanitizeWithOpenAIResponses({ + sanitizeSessionHistory, + messages, + modelId: "gpt-5.4", + sessionManager, + }); + + expect(result).toEqual([ + { + ...(messages[0] as Record), + usage: makeZeroUsageSnapshot(), + }, + ]); }); it("drops orphaned toolResult entries when switching from openai history to anthropic", async () => { diff --git a/src/agents/pi-embedded-runner/replay-history.ts b/src/agents/pi-embedded-runner/replay-history.ts index 68d36827084..938f063f1ff 100644 --- a/src/agents/pi-embedded-runner/replay-history.ts +++ b/src/agents/pi-embedded-runner/replay-history.ts @@ -461,6 +461,16 @@ export async function sanitizeSessionHistory(params: { params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses" || params.modelApi === "azure-openai-responses"; + const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId); + const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null; + const modelChanged = priorSnapshot + ? !isSameModelSnapshot(priorSnapshot, { + timestamp: 0, + provider: params.provider, + modelApi: params.modelApi, + modelId: params.modelId, + }) + : false; const normalizedAssistantReplay = normalizeAssistantReplayContent(withInterSessionMarkers); const sanitizedImages = await sanitizeSessionMessagesImages( normalizedAssistantReplay, @@ -494,7 +504,9 @@ export async function sanitizeSessionHistory(params: { : sanitizedToolCalls; const openAISafeToolCalls = isOpenAIResponsesApi ? downgradeOpenAIFunctionCallReasoningPairs( - downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls), + downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls, { + dropReplayableReasoning: modelChanged, + }), ) : sanitizedToolCalls; const sanitizedToolIds = @@ -515,16 +527,6 @@ export async function sanitizeSessionHistory(params: { const sanitizedCompactionUsage = ensureAssistantUsageSnapshots( stripStaleAssistantUsageBeforeLatestCompaction(sanitizedToolResults), ); - const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId); - const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null; - const modelChanged = priorSnapshot - ? !isSameModelSnapshot(priorSnapshot, { - timestamp: 0, - provider: params.provider, - modelApi: params.modelApi, - modelId: params.modelId, - }) - : false; const provider = params.provider?.trim(); let providerSanitized: AgentMessage[] | undefined; if (provider && provider.length > 0) { diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index f5bd077a031..08a68128d8a 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -87,6 +87,7 @@ vi.mock("../../agents/agent-scope.js", () => ({ resolveAgentConfig: vi.fn(() => ({})), resolveAgentDir: vi.fn(() => "/tmp/agent"), resolveAgentEffectiveModelPrimary: vi.fn(() => undefined), + resolveAgentModelFallbacksOverride: vi.fn(() => undefined), resolveSessionAgentId: vi.fn(() => "main"), })); @@ -336,6 +337,16 @@ describe("/model chat UX", () => { expect(reply?.text).toContain("Switch: /model "); }); + it("treats /model list as a models browser alias, not a model id", async () => { + const reply = await resolveModelInfoReply({ + directives: parseInlineDirectives("/model list"), + }); + + expect(reply?.text).toContain("Providers:"); + expect(reply?.text).toContain("Use: /models "); + expect(reply?.text).toContain("Switch: /model "); + }); + it("shows active runtime model when different from selected model", async () => { const reply = await resolveModelInfoReply({ provider: "fireworks",