From ee07bb8e66f86579ab113dfe4ba2b856b076c494 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:07:38 -0600 Subject: [PATCH] fix(anthropic): keep adopted Claude CLI chats working after first turn (#113078) * fix(anthropic): resume adopted Claude CLI chats * fix(agents): limit CLI alias lookup to bound sessions * refactor(agents): keep stored override normalization bounded --- .../anthropic/session-catalog-history.test.ts | 36 ++++++ .../anthropic/session-catalog-history.ts | 14 ++- src/auto-reply/reply/model-selection.test.ts | 106 ++++++++++++++++++ src/auto-reply/reply/model-selection.ts | 9 +- src/auto-reply/reply/stored-model-override.ts | 24 ++++ 5 files changed, 184 insertions(+), 5 deletions(-) diff --git a/extensions/anthropic/session-catalog-history.test.ts b/extensions/anthropic/session-catalog-history.test.ts index 06c71b7c8bff..99da57ff1f70 100644 --- a/extensions/anthropic/session-catalog-history.test.ts +++ b/extensions/anthropic/session-catalog-history.test.ts @@ -75,4 +75,40 @@ describe("importClaudeHistory", () => { expect(assistantRow).toBeDefined(); expect(assistantRow?.["__openclaw"]).toBeUndefined(); }); + + it("omits empty native reasoning records instead of rendering a placeholder", async () => { + appended.length = 0; + await importClaudeHistory({ + items: [ + { type: "reasoning", content: [{ type: "thinking", thinking: "" }], uuid: "r-1" }, + { type: "assistantMessage", text: "AUTH_OK", uuid: "a-1" }, + ], + threadId: "thread-1", + sessionFile: "/tmp/unused.jsonl", + sessionId: "session-1", + sessionKey: "agent:main:catalog-adopt", + agentId: "main", + config: {} as OpenClawConfig, + }); + + expect(appended).toHaveLength(1); + expect(JSON.stringify(appended)).toContain("AUTH_OK"); + expect(JSON.stringify(appended)).not.toContain("Unsupported Claude transcript item"); + }); + + it("retains the placeholder for unsupported non-reasoning records", async () => { + appended.length = 0; + await importClaudeHistory({ + items: [{ type: "toolCall", content: [{ type: "tool_use" }], uuid: "t-1" }], + threadId: "thread-1", + sessionFile: "/tmp/unused.jsonl", + sessionId: "session-1", + sessionKey: "agent:main:catalog-adopt", + agentId: "main", + config: {} as OpenClawConfig, + }); + + expect(appended).toHaveLength(1); + expect(JSON.stringify(appended)).toContain("Unsupported Claude transcript item"); + }); }); diff --git a/extensions/anthropic/session-catalog-history.ts b/extensions/anthropic/session-catalog-history.ts index afb2432c365c..6ed516b4b701 100644 --- a/extensions/anthropic/session-catalog-history.ts +++ b/extensions/anthropic/session-catalog-history.ts @@ -7,10 +7,14 @@ import type { ClaudeTranscriptItem } from "./session-catalog-transcript.js"; function importedClaudeMessage( item: ClaudeTranscriptItem, fallbackTimestamp: number, -): AgentMessage { +): AgentMessage | undefined { const parsedTimestamp = item.timestamp ? Date.parse(item.timestamp) : Number.NaN; const timestamp = Number.isFinite(parsedTimestamp) ? parsedTimestamp : fallbackTimestamp; - const text = item.text?.trim() || "[Unsupported Claude transcript item]"; + const importedText = item.text?.trim(); + if (!importedText && item.type === "reasoning") { + return undefined; + } + const text = importedText || "[Unsupported Claude transcript item]"; if (item.type === "userMessage") { // Imported native rows are not OpenClaw-authored; mirrorOrigin excludes them // from self-echo provenance so a repeated native prompt stays observable. @@ -61,9 +65,13 @@ export async function importClaudeHistory(params: { const items = params.items.toReversed(); await withSessionTranscriptWriteLock(params, async (transcript) => { for (const [index, item] of items.entries()) { + const imported = importedClaudeMessage(item, Date.now() + index); + if (!imported) { + continue; + } // The idempotency key rides on the message so recovery re-imports dedupe. const message = { - ...(importedClaudeMessage(item, Date.now() + index) as unknown as Record), + ...(imported as unknown as Record), idempotencyKey: `claude-catalog:${params.threadId}:${item.uuid ?? index}`, } as unknown as AgentMessage; await transcript.appendMessage({ diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 7cdbdb42b606..e26e66d15aeb 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-support.js"; import { MODEL_CONTEXT_TOKEN_CACHE, providerContextTokenCacheKey, @@ -116,6 +117,7 @@ vi.mock("../../agents/auth-profiles/order.js", () => ({ afterEach(() => { MODEL_CONTEXT_TOKEN_CACHE.clear(); + cliBackendsTesting.resetDepsForTest(); vi.mocked(loadManifestModelCatalog).mockReset(); vi.mocked(loadManifestModelCatalog).mockReturnValue([]); authProfileStoreMock.reset(); @@ -1218,6 +1220,110 @@ describe("createModelSelectionState respects session model override", () => { }); }); + it("preserves a locked CLI runtime alias when its canonical model is allowed", async () => { + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [], + resolvePluginSetupCliBackend: ({ backend }) => + backend === "claude-cli" + ? ({ + pluginId: "anthropic", + backend: { + id: "claude-cli", + modelProvider: "anthropic", + config: { command: "claude" }, + bundleMcp: false, + }, + } as never) + : undefined, + }); + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-sol" }, + models: { + "openai/gpt-5.6-sol": {}, + "anthropic/claude-opus-4-8": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:plugin:anthropic:catalog-adopt:claude:test"; + const sessionEntry = makeEntry({ + providerOverride: "claude-cli", + modelOverride: "claude-opus-4-8", + modelSelectionLocked: true, + pluginOwnerId: "anthropic", + cliSessionBindings: { + "claude-cli": { + sessionId: "native-claude-session", + forceReuse: true, + forkNextResume: true, + }, + }, + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "openai", + defaultModel: "gpt-5.6-sol", + provider: "claude-cli", + model: "claude-opus-4-8", + hasModelDirective: false, + }); + + expect(state).toMatchObject({ + provider: "anthropic", + model: "claude-opus-4-8", + resetModelOverride: false, + }); + expect(sessionStore[sessionKey]).toMatchObject({ + providerOverride: "claude-cli", + modelOverride: "claude-opus-4-8", + modelSelectionLocked: true, + }); + }); + + it("keeps ordinary provider overrides off the CLI setup-registry path", async () => { + const resolvePluginSetupCliBackend = vi.fn(() => undefined); + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [], + resolvePluginSetupCliBackend, + }); + const cfg = { + agents: { + defaults: { + models: { "custom-provider/custom-model": {} }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:custom-provider"; + const sessionEntry = makeEntry({ + providerOverride: "custom-provider", + modelOverride: "custom-model", + }); + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + defaultProvider: "custom-provider", + defaultModel: "custom-model", + provider: "custom-provider", + model: "custom-model", + hasModelDirective: false, + }); + + expect(state).toMatchObject({ provider: "custom-provider", model: "custom-model" }); + expect(resolvePluginSetupCliBackend).not.toHaveBeenCalled(); + }); + it("adopts a concurrent valid model while repairing a stale override", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-repair-race-")); const storePath = path.join(tempRoot, "sessions.json"); diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 05e145711f76..d7fb84072294 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -50,6 +50,7 @@ export { } from "./model-selection-directive.js"; import { isStaleHeartbeatAutoFallbackOverride, + normalizeStoredRuntimeModelRef, resolveStoredModelOverride, } from "./stored-model-override.js"; @@ -340,9 +341,11 @@ export async function createModelSelectionState(params: { directStoredOverride && !hasOneTurnModelOverride ) { - const normalizedOverride = normalizeRuntimeModelRef( + const normalizedOverride = normalizeStoredRuntimeModelRef( directStoredOverride.provider, directStoredOverride.model, + cfg, + sessionEntry, ); const key = modelKey(normalizedOverride.provider, normalizedOverride.model); const overrideAllowed = visibilityPolicy.allowsKey(key); @@ -429,9 +432,11 @@ export async function createModelSelectionState(params: { (resetModelOverride && staleDirectStoredOverride && storedOverride?.source === "session"); if (storedOverride?.model && !skipStoredOverride) { - const normalizedStoredOverride = normalizeRuntimeModelRef( + const normalizedStoredOverride = normalizeStoredRuntimeModelRef( storedOverride.provider || defaultProvider, storedOverride.model, + cfg, + sessionEntry, ); const key = modelKey(normalizedStoredOverride.provider, normalizedStoredOverride.model); if (visibilityPolicy.allowsKey(key)) { diff --git a/src/auto-reply/reply/stored-model-override.ts b/src/auto-reply/reply/stored-model-override.ts index 2d3260ac934a..2dd6212791fa 100644 --- a/src/auto-reply/reply/stored-model-override.ts +++ b/src/auto-reply/reply/stored-model-override.ts @@ -1,14 +1,17 @@ // Persists and resolves per-session model override choices. import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { hasSessionAutoModelFallbackProvenance } from "../../agents/agent-scope.js"; +import { resolveCliRuntimeCanonicalProvider } from "../../agents/cli-backends.js"; import { modelKey, normalizeModelRef, normalizeStoredOverrideModel, resolvePersistedOverrideModelRef, } from "../../agents/model-selection.js"; +import { RUNTIME_MODEL_VISIBILITY_NORMALIZATION } from "../../agents/model-visibility-policy.js"; import { resolveSessionParentSessionKey } from "../../channels/plugins/session-conversation.js"; import type { SessionEntry } from "../../config/sessions/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; /** Model override loaded from the current session or its parent session. */ export type StoredModelOverride = { @@ -17,6 +20,27 @@ export type StoredModelOverride = { source: "session" | "parent"; }; +/** Normalizes a stored model ref, resolving runtime aliases only for CLI-bound sessions. */ +export function normalizeStoredRuntimeModelRef( + provider: string, + model: string, + cfg?: OpenClawConfig, + sessionEntry?: SessionEntry, +) { + const normalized = normalizeModelRef(provider, model, RUNTIME_MODEL_VISIBILITY_NORMALIZATION); + const hasCliSessionBinding = + sessionEntry?.cliSessionBindings?.[normalized.provider] !== undefined; + const canonicalProvider = + cfg && hasCliSessionBinding + ? resolveCliRuntimeCanonicalProvider({ + runtime: normalized.provider, + config: cfg, + includeSetupRegistry: true, + }) + : undefined; + return canonicalProvider ? { ...normalized, provider: canonicalProvider } : normalized; +} + function resolveParentSessionKeyCandidate(params: { sessionKey?: string; parentSessionKey?: string;