From 6bd480ea1f38f78eb45d20936b4874de50cd2434 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 7 Apr 2026 16:12:25 -0700 Subject: [PATCH] fix: honor explicit auth profile selection (#62744) * Auth: fix native model profile selection Fix native `/model ...@profile` targeting so profile selections persist onto the intended session, and preserve explicit session auth-profile overrides even when stored auth order prefers another profile. Update the reply/session regressions to use placeholder example.test profile ids. Regeneration-Prompt: | Native `/model ...@profile` commands in chat were acknowledging the requested auth profile but later runs still used another account. Fix the target-session handling so native slash commands mutate the real chat session rather than a slash-session surrogate, and keep explicit session auth-profile overrides from being cleared just because stored provider order prefers another profile. Update the tests to cover the target-session path and the override-preservation behavior, and use placeholder profile ids instead of real email addresses in test fixtures. * Auth: honor explicit user-locked profiles in runner Allow an explicit user-selected auth profile to run even when per-agent auth-state order excludes it. Keep auth-state order for automatic selection and failover, and add an embedded runner regression that seeds stored order with one profile while verifying a different user-locked profile still executes. Regeneration-Prompt: | The remaining bug after fixing native `/model ...@profile` persistence was in the embedded runner itself. A user could explicitly select a valid auth profile for a provider, but the run still failed if per-agent auth-state order did not include that profile. Preserve the intended semantics by validating user-locked profiles directly for provider match and credential eligibility, then using them without requiring membership in resolved auto-order. Add a regression in the embedded auth-profile rotation suite where stored order only includes one OpenAI profile but a different user-locked profile is chosen and must still be used. * Changelog: note explicit auth profile selection fix Add the required Unreleased changelog line for the explicit auth-profile selection and runner honor fix in this PR. Regeneration-Prompt: | The PR needed a mandatory CHANGELOG.md entry under Unreleased/Fixes. Add a concise user-facing line describing that native `/model ...@profile` selections now persist on the target session and explicit user-locked OpenAI Codex auth profiles are honored even when per-agent auth order excludes them, and include the PR number plus thanks attribution for the PR author. --- CHANGELOG.md | 1 + .../auth-profiles/session-override.test.ts | 73 ++++++++++ src/agents/auth-profiles/session-override.ts | 18 +-- ...pi-agent.auth-profile-rotation.e2e.test.ts | 35 +++++ src/agents/pi-embedded-runner/run.ts | 15 +- ...ets-active-session-native-stop.e2e.test.ts | 132 ++++++++++++++++++ ....triggers.trigger-handling.test-harness.ts | 3 + src/auto-reply/reply/get-reply-fast-path.ts | 5 + .../reply/get-reply.fast-path.test.ts | 23 ++- src/auto-reply/reply/get-reply.ts | 1 - src/auto-reply/reply/session.test.ts | 48 +++++++ src/auto-reply/reply/session.ts | 18 ++- 12 files changed, 351 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3070d1534..f57809f75ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Docs: https://docs.openclaw.ai - Network/fetch guard: drop request bodies and body-describing headers on cross-origin `307` and `308` redirects by default, so attacker-controlled redirect hops cannot receive secret-bearing POST payloads from SSRF-guarded fetch flows unless a caller explicitly opts in. (#62357) Thanks @pgondhi987. - Media/base64 decode guards: enforce byte limits before decoding missed base64-backed Teams, Signal, QQ Bot, and image-tool payloads so oversized inbound media and data URLs no longer bypass pre-decode size checks. (#62007) Thanks @eleqtrizit. - Providers/Ollama: honor the selected provider's `baseUrl` during streaming so multi-Ollama setups stop routing every stream to the first configured Ollama endpoint. (#61678) +- Auth/OpenAI Codex OAuth: keep native `/model ...@profile` selections on the target session and honor explicit user-locked auth profiles even when per-agent auth order excludes them. (#62744) Thanks @jalehman. - Browser/remote CDP: retry the DevTools websocket once after remote browser restarts so healthy remote browser profiles do not fail availability checks during CDP warm-up. (#57397) Thanks @ThanhNguyxn07. - Browser/SSRF: treat main-frame `document` redirect hops as navigations even when Playwright does not flag them as `isNavigationRequest()`, so strict private-network blocking still stops forbidden redirect pivots before the browser reaches the internal target. (#62355) Thanks @pgondhi987. - Gateway/node pairing: require a fresh pairing request when a previously paired node reconnects with additional declared commands, and keep the live session pinned to the earlier approved command set until the upgrade is approved. (#62658) Thanks @eleqtrizit. diff --git a/src/agents/auth-profiles/session-override.test.ts b/src/agents/auth-profiles/session-override.test.ts index aeafd943e18..6de4f361506 100644 --- a/src/agents/auth-profiles/session-override.test.ts +++ b/src/agents/auth-profiles/session-override.test.ts @@ -24,6 +24,32 @@ async function writeAuthStore(agentDir: string) { await fs.writeFile(authPath, JSON.stringify(payload), "utf-8"); } +async function writeAuthStoreWithProfiles( + agentDir: string, + params: { + profiles: Record; + order?: Record; + }, +) { + const authPath = path.join(agentDir, "auth-profiles.json"); + await fs.writeFile( + authPath, + JSON.stringify( + { + version: 1, + profiles: params.profiles, + ...(params.order ? { order: params.order } : {}), + }, + null, + 2, + ), + "utf-8", + ); +} + +const TEST_PRIMARY_PROFILE_ID = "openai-codex:primary@example.test"; +const TEST_SECONDARY_PROFILE_ID = "openai-codex:secondary@example.test"; + describe("resolveSessionAuthProfileOverride", () => { it("returns early when no auth sources exist", async () => { await withStateDirEnv("openclaw-auth-", async ({ stateDir }) => { @@ -83,4 +109,51 @@ describe("resolveSessionAuthProfileOverride", () => { expect(sessionEntry.authProfileOverride).toBe("zai:work"); }); }); + + it("keeps explicit user override when stored order prefers another profile", async () => { + await withStateDirEnv("openclaw-auth-", async ({ stateDir }) => { + const agentDir = path.join(stateDir, "agent"); + await fs.mkdir(agentDir, { recursive: true }); + await writeAuthStoreWithProfiles(agentDir, { + profiles: { + [TEST_PRIMARY_PROFILE_ID]: { + type: "api_key", + provider: "openai-codex", + key: "sk-josh", + }, + [TEST_SECONDARY_PROFILE_ID]: { + type: "api_key", + provider: "openai-codex", + key: "sk-claude", + }, + }, + order: { + "openai-codex": [TEST_PRIMARY_PROFILE_ID], + }, + }); + + const sessionEntry: SessionEntry = { + sessionId: "s1", + updatedAt: Date.now(), + authProfileOverride: TEST_SECONDARY_PROFILE_ID, + authProfileOverrideSource: "user", + }; + const sessionStore = { "agent:main:main": sessionEntry }; + + const resolved = await resolveSessionAuthProfileOverride({ + cfg: {} as OpenClawConfig, + provider: "openai-codex", + agentDir, + sessionEntry, + sessionStore, + sessionKey: "agent:main:main", + storePath: undefined, + isNewSession: false, + }); + + expect(resolved).toBe(TEST_SECONDARY_PROFILE_ID); + expect(sessionEntry.authProfileOverride).toBe(TEST_SECONDARY_PROFILE_ID); + expect(sessionEntry.authProfileOverrideSource).toBe("user"); + }); + }); }); diff --git a/src/agents/auth-profiles/session-override.ts b/src/agents/auth-profiles/session-override.ts index 6692d3c61eb..e51af6fda44 100644 --- a/src/agents/auth-profiles/session-override.ts +++ b/src/agents/auth-profiles/session-override.ts @@ -85,6 +85,13 @@ export async function resolveSessionAuthProfileOverride(params: { const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false }); const order = resolveAuthProfileOrder({ cfg, store, provider }); let current = sessionEntry.authProfileOverride?.trim(); + const source = + sessionEntry.authProfileOverrideSource ?? + (typeof sessionEntry.authProfileOverrideCompactionCount === "number" + ? "auto" + : current + ? "user" + : undefined); if (current && !store.profiles[current]) { await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath }); @@ -96,7 +103,8 @@ export async function resolveSessionAuthProfileOverride(params: { current = undefined; } - if (current && order.length > 0 && !order.includes(current)) { + // Explicit user picks should survive provider rotation order changes. + if (current && order.length > 0 && !order.includes(current) && source !== "user") { await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath }); current = undefined; } @@ -126,14 +134,6 @@ export async function resolveSessionAuthProfileOverride(params: { typeof sessionEntry.authProfileOverrideCompactionCount === "number" ? sessionEntry.authProfileOverrideCompactionCount : compactionCount; - - const source = - sessionEntry.authProfileOverrideSource ?? - (typeof sessionEntry.authProfileOverrideCompactionCount === "number" - ? "auto" - : current - ? "user" - : undefined); if (source === "user" && current && !isNewSession) { return current; } diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts index b3a05af9f97..2250bb9cfd9 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.e2e.test.ts @@ -327,6 +327,7 @@ const writeAuthStore = async ( agentDir: string, opts?: { includeAnthropic?: boolean; + order?: Record; usageStats?: Record< string, { @@ -353,6 +354,7 @@ const writeAuthStore = async ( }; const statePayload = { version: 1, + ...(opts?.order ? { order: opts.order } : {}), usageStats: opts?.usageStats ?? ({ @@ -1058,6 +1060,39 @@ describe("runEmbeddedPiAgent auth profile rotation", () => { expect(usageStats["openai:p2"]?.lastUsed).toBe(2); }); + it("honors user-pinned profiles even when stored order excludes them", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir, { + order: { + openai: ["openai:p1"], + }, + }); + mockSingleSuccessfulAttempt(); + + await runEmbeddedPiAgentInline({ + sessionId: "session:test", + sessionKey: "agent:test:user-order-excluded", + sessionFile: path.join(workspaceDir, "session.jsonl"), + workspaceDir, + agentDir, + config: makeConfig(), + prompt: "hello", + provider: "openai", + model: "mock-1", + authProfileId: "openai:p2", + authProfileIdSource: "user", + timeoutMs: 5_000, + runId: "run:user-order-excluded", + }); + + expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1); + const usageStats = await readUsageStats(agentDir); + expect(usageStats["openai:p1"]?.lastUsed).toBe(1); + expect(typeof usageStats["openai:p2"]?.lastUsed).toBe("number"); + expect(usageStats["openai:p2"]?.lastUsed).not.toBe(2); + }); + }); + it("ignores user-locked profile when provider mismatches", async () => { await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir, { includeAnthropic: true }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index f40be28e134..a80764ec118 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -18,6 +18,7 @@ import { hasConfiguredModelFallbacks } from "../agent-scope.js"; import { type AuthProfileFailureReason, markAuthProfileFailure, + resolveAuthProfileEligibility, markAuthProfileGood, markAuthProfileUsed, } from "../auth-profiles.js"; @@ -294,6 +295,17 @@ export async function runEmbeddedPiAgent( lockedProfileId = undefined; } } + if (lockedProfileId) { + const eligibility = resolveAuthProfileEligibility({ + cfg: params.config, + store: authStore, + provider, + profileId: lockedProfileId, + }); + if (!eligibility.eligible) { + throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`); + } + } const profileOrder = shouldPreferExplicitConfigApiKeyAuth(params.config, provider) ? [] : resolveAuthProfileOrder({ @@ -302,9 +314,6 @@ export async function runEmbeddedPiAgent( provider, preferredProfile: preferredProfileId, }); - if (lockedProfileId && !profileOrder.includes(lockedProfileId)) { - throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`); - } const profileCandidates = lockedProfileId ? [lockedProfileId] : profileOrder.length > 0 diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts index bd9c9eff324..eb8c84fdf23 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts @@ -17,11 +17,15 @@ import { runGreetingPromptForBareNewOrReset, withTempHome, } from "./reply.triggers.trigger-handling.test-harness.js"; +import { withFullRuntimeReplyConfig } from "./reply/get-reply-fast-path.js"; import { enqueueFollowupRun, getFollowupQueueDepth, type FollowupRun } from "./reply/queue.js"; import { HEARTBEAT_TOKEN } from "./tokens.js"; type GetReplyFromConfig = typeof import("./reply.js").getReplyFromConfig; +const TEST_PRIMARY_PROFILE_ID = "openai-codex:primary@example.test"; +const TEST_SECONDARY_PROFILE_ID = "openai-codex:secondary@example.test"; + vi.mock("./reply/agent-runner.runtime.js", () => ({ runReplyAgent: async (params: { commandBody: string; @@ -29,6 +33,8 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ run: { provider: string; model: string; + authProfileId?: string; + authProfileIdSource?: "auto" | "user"; sessionId: string; sessionKey?: string; sessionFile: string; @@ -61,6 +67,8 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ prompt: params.commandBody, provider: params.followupRun.run.provider, model: params.followupRun.run.model, + authProfileId: params.followupRun.run.authProfileId, + authProfileIdSource: params.followupRun.run.authProfileIdSource, sessionId: params.followupRun.run.sessionId, sessionKey: params.followupRun.run.sessionKey, sessionFile: params.followupRun.run.sessionFile, @@ -528,6 +536,7 @@ describe("trigger handling", () => { ChatType: "direct", Provider: "telegram", Surface: "telegram", + SessionKey: targetSessionKey, }, {}, cfg, @@ -543,6 +552,129 @@ describe("trigger handling", () => { }); }); + it("applies native model auth profile overrides to the target session", async () => { + await withTempHome(async (home) => { + const cfg = withFullRuntimeReplyConfig({ + ...makeCfg(home), + session: { store: join(home, "native-model-auth.sessions.json") }, + }); + const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); + runEmbeddedPiAgentMock.mockReset(); + const storePath = cfg.session?.store; + if (!storePath) { + throw new Error("missing session store path"); + } + const authDir = join(home, ".openclaw", "agents", "main", "agent"); + await fs.mkdir(authDir, { recursive: true }); + await fs.writeFile( + join(authDir, "auth-profiles.json"), + JSON.stringify( + { + version: 1, + profiles: { + [TEST_PRIMARY_PROFILE_ID]: { + type: "oauth", + provider: "openai-codex", + access: "oauth-access-token-josh", + }, + [TEST_SECONDARY_PROFILE_ID]: { + type: "oauth", + provider: "openai-codex", + access: "oauth-access-token", + }, + }, + }, + null, + 2, + ), + ); + await fs.writeFile( + join(authDir, "auth-state.json"), + JSON.stringify( + { + version: 1, + order: { + "openai-codex": [TEST_PRIMARY_PROFILE_ID], + }, + }, + null, + 2, + ), + ); + + const slashSessionKey = "telegram:slash:111"; + const targetSessionKey = MAIN_SESSION_KEY; + + await fs.writeFile( + storePath, + JSON.stringify({ + [targetSessionKey]: { + sessionId: "session-target", + updatedAt: Date.now(), + }, + }), + ); + + const res = await getReplyFromConfig( + { + Body: `/model openai-codex/gpt-5.4@${TEST_SECONDARY_PROFILE_ID}`, + From: "telegram:111", + To: "telegram:111", + ChatType: "direct", + Provider: "telegram", + Surface: "telegram", + SessionKey: slashSessionKey, + CommandSource: "native", + CommandTargetSessionKey: targetSessionKey, + CommandAuthorized: true, + }, + {}, + cfg, + ); + + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain(`Auth profile set to ${TEST_SECONDARY_PROFILE_ID}`); + + const store = loadSessionStore(storePath); + expect(store[targetSessionKey]?.authProfileOverride).toBe(TEST_SECONDARY_PROFILE_ID); + expect(store[targetSessionKey]?.authProfileOverrideSource).toBe("user"); + expect(store[slashSessionKey]).toBeUndefined(); + + runEmbeddedPiAgentMock.mockReset(); + runEmbeddedPiAgentMock.mockResolvedValue({ + payloads: [{ text: "ok" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + await getReplyFromConfig( + { + Body: "hi", + From: "telegram:111", + To: "telegram:111", + ChatType: "direct", + Provider: "telegram", + Surface: "telegram", + SessionKey: targetSessionKey, + }, + {}, + cfg, + ); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + provider: "openai-codex", + model: "gpt-5.4", + authProfileId: TEST_SECONDARY_PROFILE_ID, + authProfileIdSource: "user", + }), + ); + }); + }); + it("handles bare session reset, inline commands, and unauthorized inline status", async () => { await withTempHome(async (home) => { await runGreetingPromptForBareNewOrReset({ home, body: "/new", getReplyFromConfig }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 19455bd5233..e1b151edb58 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -27,6 +27,7 @@ const piEmbeddedMocks = getSharedMocks("openclaw.trigger-handling.pi-embedded-mo compactEmbeddedPiSession: vi.fn(), runEmbeddedPiAgent: vi.fn(), queueEmbeddedPiMessage: vi.fn().mockReturnValue(false), + resolveActiveEmbeddedRunSessionId: vi.fn().mockReturnValue(undefined), isEmbeddedPiRunActive: vi.fn().mockReturnValue(false), isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false), })); @@ -55,6 +56,8 @@ const installPiEmbeddedMock = () => runEmbeddedPiAgent: (...args: unknown[]) => piEmbeddedMocks.runEmbeddedPiAgent(...args), queueEmbeddedPiMessage: (...args: unknown[]) => piEmbeddedMocks.queueEmbeddedPiMessage(...args), resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, + resolveActiveEmbeddedRunSessionId: (...args: unknown[]) => + piEmbeddedMocks.resolveActiveEmbeddedRunSessionId(...args), isEmbeddedPiRunActive: (...args: unknown[]) => piEmbeddedMocks.isEmbeddedPiRunActive(...args), isEmbeddedPiRunStreaming: (...args: unknown[]) => piEmbeddedMocks.isEmbeddedPiRunStreaming(...args), diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index 5f853440e7a..df680e4aca6 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -30,6 +30,11 @@ function isSlowReplyTestAllowed(env: NodeJS.ProcessEnv = process.env): boolean { } function resolveFastSessionKey(ctx: MsgContext): string { + const nativeCommandTarget = + ctx.CommandSource === "native" ? normalizeOptionalString(ctx.CommandTargetSessionKey) : ""; + if (nativeCommandTarget) { + return nativeCommandTarget; + } const existing = normalizeOptionalString(ctx.SessionKey); if (existing) { return existing; diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index d5c99abae54..df23b6a2ce4 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -4,7 +4,11 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { MsgContext } from "../templating.js"; -import { markCompleteReplyConfig, withFastReplyConfig } from "./get-reply-fast-path.js"; +import { + initFastReplySessionState, + markCompleteReplyConfig, + withFastReplyConfig, +} from "./get-reply-fast-path.js"; import { loadGetReplyModuleForTest } from "./get-reply.test-loader.js"; import "./get-reply.test-runtime-mocks.js"; @@ -183,4 +187,21 @@ describe("getReplyFromConfig fast test bootstrap", () => { expect(mocks.resolveReplyDirectives).not.toHaveBeenCalled(); expect(vi.mocked(runPreparedReplyMock)).toHaveBeenCalledOnce(); }); + + it("uses native command target session keys during fast bootstrap", () => { + const result = initFastReplySessionState({ + ctx: buildCtx({ + SessionKey: "telegram:slash:123", + CommandSource: "native", + CommandTargetSessionKey: "agent:main:main", + }), + cfg: { session: { store: "/tmp/sessions.json" } } as OpenClawConfig, + agentId: "main", + commandAuthorized: true, + workspaceDir: "/tmp/workspace", + }); + + expect(result.sessionKey).toBe("agent:main:main"); + expect(result.sessionCtx.SessionKey).toBe("agent:main:main"); + }); }); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 8505605d1d7..3dcd77e6a64 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -280,7 +280,6 @@ export async function getReplyFromConfig( triggerBodyNormalized, bodyStripped, } = sessionState; - if (resetTriggered && normalizeOptionalString(bodyStripped)) { const { applyResetModelOverride } = await loadSessionResetModelRuntime(); await applyResetModelOverride({ diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 5f313c51e26..970925b253a 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -79,6 +79,7 @@ async function makeStorePath(prefix: string): Promise { } const createStorePath = makeStorePath; +const TEST_NATIVE_MODEL_PROFILE_ID = "openai-codex:secondary@example.test"; async function writeSessionStoreFast( storePath: string, @@ -963,6 +964,53 @@ describe("initSessionState RawBody", () => { expect(result.sessionId).not.toBe(existingSessionId); }); + it("prefers native command target sessions over bound slash sessions", async () => { + const storePath = await createStorePath("native-command-target-session-"); + const boundSlashSessionKey = "slack:slash:123"; + const targetSessionKey = "agent:main:main"; + const cfg = { + session: { store: storePath }, + } as OpenClawConfig; + + setMinimalCurrentConversationBindingRegistryForTests(); + registerCurrentConversationBindingAdapterForTest({ + channel: "slack", + accountId: "default", + }); + await getSessionBindingService().bind({ + targetSessionKey: boundSlashSessionKey, + targetKind: "session", + conversation: { + channel: "slack", + accountId: "default", + conversationId: "channel:ops", + }, + placement: "current", + }); + + const result = await initSessionState({ + ctx: { + Body: `/model openai-codex/gpt-5.4@${TEST_NATIVE_MODEL_PROFILE_ID}`, + CommandBody: `/model openai-codex/gpt-5.4@${TEST_NATIVE_MODEL_PROFILE_ID}`, + Provider: "slack", + Surface: "slack", + AccountId: "default", + SenderId: "U123", + From: "slack:U123", + To: "channel:ops", + OriginatingTo: "channel:ops", + SessionKey: boundSlashSessionKey, + CommandSource: "native", + CommandTargetSessionKey: targetSessionKey, + }, + cfg, + commandAuthorized: true, + }); + + expect(result.sessionKey).toBe(targetSessionKey); + expect(result.sessionCtx.SessionKey).toBe(targetSessionKey); + }); + it("uses the default per-agent sessions store when config store is unset", async () => { const root = await makeCaseDir("openclaw-session-store-default-"); const stateDir = path.join(root, ".openclaw"); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 3086fd4f5ac..ab6d62a8c2e 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -224,12 +224,16 @@ export async function initSessionState(params: { ctx.CommandSource === "native" ? normalizeOptionalString(ctx.CommandTargetSessionKey) : undefined; + // Native slash/menu commands can arrive on a transport-specific "slash session" + // while explicitly targeting an existing chat session. Honor that explicit target + // before any binding lookup so command-side mutations land on the intended session. const targetSessionKey = + commandTargetSessionKey ?? resolveBoundConversationSessionKey({ cfg, ctx, bindingContext: conversationBindingContext, - }) ?? commandTargetSessionKey; + }); const sessionCtxForState = targetSessionKey && targetSessionKey !== ctx.SessionKey ? { ...ctx, SessionKey: targetSessionKey } @@ -695,16 +699,16 @@ export async function initSessionState(params: { } const sessionCtx: TemplateContext = { - ...ctx, + ...sessionCtxForState, // Keep BodyStripped aligned with Body (best default for agent prompts). // RawBody is reserved for command/directive parsing and may omit context. BodyStripped: normalizeInboundTextNewlines( bodyStripped ?? - ctx.BodyForAgent ?? - ctx.Body ?? - ctx.CommandBody ?? - ctx.RawBody ?? - ctx.BodyForCommands ?? + sessionCtxForState.BodyForAgent ?? + sessionCtxForState.Body ?? + sessionCtxForState.CommandBody ?? + sessionCtxForState.RawBody ?? + sessionCtxForState.BodyForCommands ?? "", ), SessionId: sessionId,