mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 17:54:47 +00:00
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:
@@ -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({});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
35
src/gateway/session-patch-hooks.ts
Normal file
35
src/gateway/session-patch-hooks.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user