diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 2a6e5c08526..fb7d2c2c0d5 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -590,6 +590,76 @@ describe("session_status tool", () => { expect(details.statusText).toContain("🧠 Model:"); }); + it("resolves the default session_status lookup for a channel-plugin requester via implicit fallback", async () => { + resetSessionStore({}); + + const tool = getSessionStatusTool("agent:main:scope:scopy:direct:scopy"); + + const result = await tool.execute("call-current-channel-plugin-default", {}); + const details = result.details as { ok?: boolean; sessionKey?: string; statusText?: string }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("agent:main:scope:scopy:direct:scopy"); + expect(details.statusText).toContain("OpenClaw"); + expect(details.statusText).toContain("🧠 Model:"); + }); + + it("materializes a valid persisted session entry when implicit current fallback mutates model state", async () => { + resetSessionStore({}); + + const tool = getSessionStatusTool("agent:main:scope:scopy:direct:scopy"); + + const result = await tool.execute("call-current-channel-plugin-model", { + sessionKey: "current", + model: "anthropic/claude-sonnet-4-6", + }); + const details = result.details as { ok?: boolean; sessionKey?: string }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("agent:main:scope:scopy:direct:scopy"); + expect(updateSessionStoreMock).toHaveBeenCalled(); + const [, savedStore] = updateSessionStoreMock.mock.calls.at(-1) as [ + string, + Record, + ]; + const saved = savedStore["agent:main:scope:scopy:direct:scopy"]; + expect(saved).toEqual( + expect.objectContaining({ + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4-6", + liveModelSwitchPending: true, + }), + ); + expect(saved.sessionId).toEqual(expect.any(String)); + expect(saved.sessionId.trim().length).toBeGreaterThan(0); + }); + + it("materializes a valid persisted session entry when the default implicit current fallback mutates model state", async () => { + resetSessionStore({}); + + const tool = getSessionStatusTool("agent:main:scope:scopy:direct:scopy"); + + const result = await tool.execute("call-current-channel-plugin-default-model", { + model: "anthropic/claude-sonnet-4-6", + }); + const details = result.details as { ok?: boolean; sessionKey?: string }; + expect(details.ok).toBe(true); + expect(details.sessionKey).toBe("agent:main:scope:scopy:direct:scopy"); + expect(updateSessionStoreMock).toHaveBeenCalled(); + const [, savedStore] = updateSessionStoreMock.mock.calls.at(-1) as [ + string, + Record, + ]; + const saved = savedStore["agent:main:scope:scopy:direct:scopy"]; + expect(saved).toEqual( + expect.objectContaining({ + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4-6", + liveModelSwitchPending: true, + }), + ); + expect(saved.sessionId).toEqual(expect.any(String)); + expect(saved.sessionId.trim().length).toBeGreaterThan(0); + }); + it("does not synthesize a current fallback for unknown non-literal session keys", async () => { resetSessionStore({}); diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 0163b17a130..495f6a0e582 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -8,6 +8,7 @@ import type { import { getRuntimeConfig } from "../../config/config.js"; import { loadSessionStore, + mergeSessionEntry, resolveStorePath, type SessionEntry, updateSessionStore, @@ -145,11 +146,11 @@ function synthesizeImplicitCurrentSessionEntry(): SessionEntry { } function resolveImplicitCurrentSessionFallback(params: { - requestedKeyRaw: string; + allowFallback: boolean; storeScopedRequesterKey: string; }): { key: string; entry: SessionEntry } | null { const requesterKey = params.storeScopedRequesterKey.trim(); - if (params.requestedKeyRaw !== "current" || !requesterKey) { + if (!params.allowFallback || !requesterKey) { return null; } return { @@ -484,7 +485,7 @@ export function createSessionStatusTool(opts?: { if (!resolved) { const fallback = resolveImplicitCurrentSessionFallback({ - requestedKeyRaw, + allowFallback: requestedKeyRaw === "current" || requestedKeyParam === undefined, storeScopedRequesterKey, }); if (fallback) { @@ -539,11 +540,22 @@ export function createSessionStatusTool(opts?: { markLiveSwitchPending: true, }); if (applied.updated) { - store[resolved.key] = nextEntry; + const persistedEntry = nextEntry.sessionId.trim() + ? nextEntry + : (() => { + const persistedEntryPatch: Partial = { ...nextEntry }; + delete persistedEntryPatch.sessionId; + const existingEntry = store[resolved.key]; + const existingWithValidSessionId = existingEntry?.sessionId?.trim() + ? existingEntry + : undefined; + return mergeSessionEntry(existingWithValidSessionId, persistedEntryPatch); + })(); + store[resolved.key] = persistedEntry; await updateSessionStore(storePath, (nextStore) => { - nextStore[resolved.key] = nextEntry; + nextStore[resolved.key] = persistedEntry; }); - resolved.entry = nextEntry; + resolved.entry = persistedEntry; changedModel = true; } }