diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 9a7d21cae21..d99e337c6f7 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -1,5 +1,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; +import { + clearInternalHooks, + registerInternalHook, + type InternalHookEvent, +} from "../hooks/internal-hooks.js"; import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; import { buildTaskStatusSnapshot } from "../tasks/task-status.js"; @@ -432,6 +437,7 @@ function getSessionStatusTool( describe("session_status tool", () => { beforeEach(() => { buildStatusMessageMock.mockClear(); + clearInternalHooks(); }); it("returns a status card for the current session", async () => { @@ -897,6 +903,41 @@ describe("session_status tool", () => { expect(saved.sessionId).toMatch(UUID_RE); }); + it("fires session:patch when session_status changes the persisted session model", async () => { + const events: InternalHookEvent[] = []; + registerInternalHook("session:patch", async (event) => { + events.push(event); + }); + resetSessionStore({ + main: { + sessionId: "s1", + updatedAt: 10, + }, + }); + + const tool = getSessionStatusTool(); + + await tool.execute("call-session-status-model-hook", { + model: "anthropic/claude-sonnet-4-6", + }); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + const event = events[0]; + expect(event.type).toBe("session"); + expect(event.action).toBe("patch"); + expect(event.sessionKey).toBe("main"); + const context = event.context; + expect(context.patch).toMatchObject({ + key: "main", + model: "anthropic/claude-sonnet-4-6", + }); + expect(context.sessionEntry).toMatchObject({ + providerOverride: "anthropic", + modelOverride: "claude-sonnet-4-6", + liveModelSwitchPending: true, + }); + }); + it("materializes a valid persisted session entry when the default implicit current fallback mutates model state", async () => { resetSessionStore({}); diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 8540ae21cb2..0ce85a16f77 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -14,6 +14,7 @@ import { updateSessionStore, } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; import { resolveSessionModelIdentityRef } from "../../gateway/session-utils.js"; import { buildAgentMainSessionKey, @@ -630,6 +631,15 @@ export function createSessionStatusTool(opts?: { nextStore[resolved.key] = persistedEntry; }); resolved.entry = persistedEntry; + triggerSessionPatchHook({ + cfg, + sessionEntry: persistedEntry, + sessionKey: resolved.key, + patch: { + key: resolved.key, + model: selection.kind === "reset" ? null : `${selection.provider}/${selection.model}`, + }, + }); changedModel = true; } } diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index b0a1287cd65..af41f436fba 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -4,6 +4,7 @@ import { resolveExecDefaults } from "../../agents/exec-defaults.js"; import { resolveFastModeState } from "../../agents/fast-mode.js"; import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; import { updateSessionStore } from "../../config/sessions.js"; +import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; @@ -475,6 +476,16 @@ export async function handleDirectiveOnly( }); } if (modelSelection && modelSelectionUpdated && sessionKey) { + triggerSessionPatchHook({ + cfg: params.cfg, + sessionEntry, + sessionKey, + patch: { + key: sessionKey, + model: + directives.rawModelDirective ?? `${modelSelection.provider}/${modelSelection.model}`, + }, + }); // `/model` should retarget queued/future work without interrupting the // active run. Refresh queued followups so they pick up the persisted // selection once the current turn finishes. diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 2eb15f23e45..0dc2da40bb2 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -113,6 +113,11 @@ import { import type { ModelAliasIndex } from "../../agents/model-selection.js"; import type { ModelDefinitionConfig, OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; +import { + clearInternalHooks, + registerInternalHook, + type InternalHookEvent, +} from "../../hooks/internal-hooks.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -243,11 +248,13 @@ beforeEach(() => { vi.mocked(enqueueSystemEvent).mockClear(); liveModelSwitchMocks.requestLiveSessionModelSwitch.mockReset().mockReturnValue(false); queueMocks.refreshQueuedFollowupSession.mockReset(); + clearInternalHooks(); }); afterEach(() => { setDirectiveTestProviders([]); clearRuntimeAuthProfileStoreSnapshots(); + clearInternalHooks(); }); function setAuthProfiles(profiles: Record) { @@ -1267,6 +1274,34 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { }); }); + it("fires session:patch when /model changes the persisted session model", async () => { + const events: InternalHookEvent[] = []; + registerInternalHook("session:patch", async (event) => { + events.push(event); + }); + const sessionEntry = createSessionEntry(); + + await handleDirectiveOnly( + createHandleParams({ + directives: parseInlineDirectives("/model openai/gpt-4o"), + sessionEntry, + }), + ); + + await vi.waitFor(() => expect(events).toHaveLength(1)); + const event = events[0]; + expect(event.type).toBe("session"); + expect(event.action).toBe("patch"); + expect(event.sessionKey).toBe(sessionKey); + const context = event.context; + expect(context.patch).toMatchObject({ key: sessionKey, model: "openai/gpt-4o" }); + expect(context.sessionEntry).toMatchObject({ + providerOverride: "openai", + modelOverride: "gpt-4o", + liveModelSwitchPending: true, + }); + }); + it("keeps xhigh when switching to OpenCode Claude Opus 4.7", async () => { const sessionEntry = createSessionEntry({ thinkingLevel: "xhigh" }); const sessionStore = { [sessionKey]: sessionEntry }; diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts index 9d340612423..6e34c83ab56 100644 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ b/src/auto-reply/reply/directive-handling.persist.ts @@ -11,6 +11,7 @@ import { resolveContextConfigProviderForRuntime } from "../../agents/openai-code import { updateSessionStore } from "../../config/sessions/store.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; @@ -235,6 +236,7 @@ export async function persistInlineDirectives(params: { directives.hasModelDirective && params.effectiveModelDirective ? params.effectiveModelDirective : undefined; + let modelUpdated = false; if (modelDirective) { const modelResolution = resolveModelSelectionFromDirective({ directives: { @@ -252,7 +254,7 @@ export async function persistInlineDirectives(params: { provider, }); if (modelResolution.modelSelection) { - const { updated: modelUpdated } = applyModelOverrideToSessionEntry({ + const appliedModelOverride = applyModelOverrideToSessionEntry({ entry: sessionEntry, selection: modelResolution.modelSelection, profileOverride: modelResolution.profileOverride, @@ -292,6 +294,7 @@ export async function persistInlineDirectives(params: { }, ); } + modelUpdated = appliedModelOverride.updated; provider = modelResolution.modelSelection.provider; model = modelResolution.modelSelection.model; const currentThinkingLevel = sessionEntry.thinkingLevel as ThinkLevel | undefined; @@ -351,6 +354,14 @@ export async function persistInlineDirectives(params: { store[sessionKey] = sessionEntry; }); } + if (modelDirective && modelUpdated) { + triggerSessionPatchHook({ + cfg, + sessionEntry, + sessionKey, + patch: { key: sessionKey, model: modelDirective }, + }); + } enqueueModeSwitchEvents({ enqueueSystemEvent, sessionEntry, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 4b4d5cffe9d..10e06b2304a 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -32,8 +32,6 @@ import { createInternalHookEvent, hasInternalHookListeners, triggerInternalHook, - type SessionPatchHookContext, - type SessionPatchHookEvent, } from "../../hooks/internal-hooks.js"; import { measureDiagnosticsTimelineSpan, @@ -84,6 +82,7 @@ import { getSessionCompactionCheckpoint, listSessionCompactionCheckpoints, } from "../session-compaction-checkpoints.js"; +import { triggerSessionPatchHook } from "../session-patch-hooks.js"; import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js"; import { archiveFileOnDisk, @@ -1785,22 +1784,12 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } - if (hasInternalHookListeners("session", "patch")) { - const hookContext: SessionPatchHookContext = structuredClone({ - sessionEntry: applied.entry, - patch: p, - cfg, - }); - const hookEvent: SessionPatchHookEvent = { - type: "session", - action: "patch", - sessionKey: target.canonicalKey ?? key, - context: hookContext, - timestamp: new Date(), - messages: [], - }; - void triggerInternalHook(hookEvent); - } + triggerSessionPatchHook({ + cfg, + sessionEntry: applied.entry, + sessionKey: target.canonicalKey ?? key, + patch: p, + }); const parsed = parseAgentSessionKey(target.canonicalKey ?? key); const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg)); diff --git a/src/gateway/session-patch-hooks.ts b/src/gateway/session-patch-hooks.ts new file mode 100644 index 00000000000..9f6250470c4 --- /dev/null +++ b/src/gateway/session-patch-hooks.ts @@ -0,0 +1,35 @@ +import type { SessionEntry } from "../config/sessions.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + hasInternalHookListeners, + triggerInternalHook, + type SessionPatchHookContext, + type SessionPatchHookEvent, +} from "../hooks/internal-hooks.js"; +import type { SessionsPatchParams } from "./protocol/index.js"; + +export function triggerSessionPatchHook(params: { + cfg: OpenClawConfig; + sessionEntry: SessionEntry; + sessionKey: string; + patch: SessionsPatchParams; +}): void { + if (!hasInternalHookListeners("session", "patch")) { + return; + } + + const hookContext: SessionPatchHookContext = structuredClone({ + sessionEntry: params.sessionEntry, + patch: params.patch, + cfg: params.cfg, + }); + const hookEvent: SessionPatchHookEvent = { + type: "session", + action: "patch", + sessionKey: params.sessionKey, + context: hookContext, + timestamp: new Date(), + messages: [], + }; + void triggerInternalHook(hookEvent); +}