fix(gateway): fire session:patch hooks for model changes (#82257)

* Fire session patch hooks for model changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: refresh CI after main repairs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Gio Della-Libera
2026-05-15 22:44:01 -07:00
committed by GitHub
parent 8c9ec0724e
commit dccf5f6842
7 changed files with 151 additions and 19 deletions

View File

@@ -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({});

View File

@@ -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;
}
}

View File

@@ -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.

View File

@@ -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<string, AuthProfileForTest>) {
@@ -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 };

View File

@@ -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,

View File

@@ -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));

View File

@@ -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);
}