Skip to content

Commit e61837e

Browse files
committed
refactor: route session status through session accessors
1 parent acc2a0e commit e61837e

8 files changed

Lines changed: 473 additions & 165 deletions

scripts/check-session-accessor-boundary.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export const migratedSessionAccessorFiles = new Set([
117117
"src/gateway/session-reset-service.ts",
118118
"src/infra/outbound/message-action-tts.ts",
119119
"src/agents/tools/embedded-gateway-stub.ts",
120+
"src/agents/tools/session-status-tool.ts",
120121
"src/agents/tools/sessions-list-tool.ts",
121122
"src/plugins/host-hook-state.ts",
122123
"src/status/status-message.ts",
@@ -142,6 +143,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
142143
"src/auto-reply/reply/abort.ts",
143144
"src/agents/subagent-control.ts",
144145
"src/agents/subagent-registry-helpers.ts",
146+
"src/agents/tools/session-status-tool.ts",
145147
"src/auto-reply/reply/abort-cutoff.runtime.ts",
146148
"src/auto-reply/reply/agent-runner-cli-dispatch.ts",
147149
"src/auto-reply/reply/agent-runner-execution.ts",

src/agents/openclaw-tools.session-status.test.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,77 @@ function installScopedSessionStores(syncUpdates = false) {
9999
async function createSessionsModuleMock() {
100100
const actual =
101101
await vi.importActual<typeof import("../config/sessions.js")>("../config/sessions.js");
102+
const resolveMockStorePath = (_store: string | undefined, opts?: { agentId?: string }) =>
103+
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json";
104+
const cloneEntry = (entry: SessionEntry): SessionEntry => structuredClone(entry);
102105
return {
103106
...actual,
104107
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
108+
patchSessionEntryWithKey: async (
109+
scope: { agentId?: string; sessionKey: string; storePath?: string },
110+
update: (
111+
entry: SessionEntry,
112+
context: { existingEntry?: SessionEntry },
113+
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null,
114+
options?: { fallbackEntry?: SessionEntry; replaceEntry?: boolean },
115+
) => {
116+
const storePath =
117+
scope.storePath ?? resolveMockStorePath(undefined, { agentId: scope.agentId });
118+
const store = loadSessionStoreMock(storePath) as Record<string, SessionEntry>;
119+
const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey });
120+
const existing = resolved.existing ?? options?.fallbackEntry;
121+
if (!existing) {
122+
return null;
123+
}
124+
const patch = await update(cloneEntry(existing), {
125+
existingEntry: resolved.existing ? cloneEntry(resolved.existing) : undefined,
126+
});
127+
if (!patch) {
128+
return { sessionKey: resolved.normalizedKey, entry: cloneEntry(existing) };
129+
}
130+
const next = options?.replaceEntry
131+
? cloneEntry(patch as SessionEntry)
132+
: actual.mergeSessionEntry(existing, patch);
133+
store[resolved.normalizedKey] = next;
134+
updateSessionStoreMock(storePath, store);
135+
return { sessionKey: resolved.normalizedKey, entry: cloneEntry(next) };
136+
},
137+
resolveSessionEntryCandidateTarget: (scope: {
138+
agentId: string;
139+
candidateKeys: readonly string[];
140+
cfg: { session?: { store?: string } };
141+
fallback?: { sessionKey: string; entry: SessionEntry };
142+
}) => {
143+
const storePath = resolveMockStorePath(scope.cfg.session?.store, { agentId: scope.agentId });
144+
const store = loadSessionStoreMock(storePath) as Record<string, SessionEntry>;
145+
const candidates = [...new Set(scope.candidateKeys.map((key) => key.trim()))];
146+
for (const candidateKey of candidates) {
147+
if (!candidateKey) {
148+
continue;
149+
}
150+
const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: candidateKey });
151+
if (!resolved.existing) {
152+
continue;
153+
}
154+
return {
155+
agentId: scope.agentId,
156+
candidateKey,
157+
entry: cloneEntry(resolved.existing),
158+
persisted: true,
159+
sessionKey: resolved.normalizedKey,
160+
};
161+
}
162+
const fallbackKey = scope.fallback?.sessionKey.trim();
163+
return fallbackKey && scope.fallback
164+
? {
165+
agentId: scope.agentId,
166+
candidateKey: fallbackKey,
167+
entry: cloneEntry(scope.fallback.entry),
168+
persisted: false,
169+
sessionKey: fallbackKey,
170+
}
171+
: null;
172+
},
105173
updateSessionStore: async (
106174
storePath: string,
107175
mutator: (store: Record<string, unknown>) => Promise<void> | void,
@@ -111,8 +179,7 @@ async function createSessionsModuleMock() {
111179
updateSessionStoreMock(storePath, store);
112180
return store;
113181
},
114-
resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) =>
115-
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json",
182+
resolveStorePath: resolveMockStorePath,
116183
};
117184
}
118185

@@ -1191,6 +1258,41 @@ describe("session_status tool", () => {
11911258
expect(saved.sessionId).toMatch(UUID_RE);
11921259
});
11931260

1261+
it("preserves an existing legacy main row when implicit fallback mutates model state", async () => {
1262+
resetSessionStore({
1263+
main: {
1264+
sessionId: "legacy-main-session",
1265+
updatedAt: 10,
1266+
label: "Legacy Main",
1267+
lastChannel: "telegram",
1268+
},
1269+
});
1270+
1271+
const tool = getSessionStatusTool("agent:main:main");
1272+
1273+
const result = await tool.execute("call-legacy-main-fallback-model", {
1274+
model: "anthropic/claude-sonnet-4-6",
1275+
});
1276+
const details = result.details as {
1277+
ok?: boolean;
1278+
sessionKey?: string;
1279+
modelOverride?: string | null;
1280+
};
1281+
expect(details.ok).toBe(true);
1282+
expect(details.sessionKey).toBe("main");
1283+
expect(details.modelOverride).toBe("anthropic/claude-sonnet-4-6");
1284+
expect(updateSessionStoreMock).toHaveBeenCalledTimes(1);
1285+
const savedStore = latestMockCallArg(updateSessionStoreMock, 1) as Record<string, SessionEntry>;
1286+
expect(savedStore.main).toMatchObject({
1287+
sessionId: "legacy-main-session",
1288+
label: "Legacy Main",
1289+
lastChannel: "telegram",
1290+
providerOverride: "anthropic",
1291+
modelOverride: "claude-sonnet-4-6",
1292+
liveModelSwitchPending: true,
1293+
});
1294+
});
1295+
11941296
it("fires session:patch when session_status changes the persisted session model", async () => {
11951297
const events: InternalHookEvent[] = [];
11961298
registerInternalHook("session:patch", async (event) => {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Status-tool session resolution helpers keep storage lookup out of the tool body.
2+
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
3+
import { resolveSessionEntryCandidateTarget, type SessionEntry } from "../../config/sessions.js";
4+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
5+
import {
6+
buildAgentMainSessionKey,
7+
DEFAULT_AGENT_ID,
8+
parseAgentSessionKey,
9+
} from "../../routing/session-key.js";
10+
import { resolveInternalSessionKey } from "./sessions-helpers.js";
11+
12+
export type ResolvedStatusSessionEntry = {
13+
entry: SessionEntry;
14+
key: string;
15+
persisted: boolean;
16+
};
17+
18+
/** Resolves one status lookup against ordered tool-local session key candidates. */
19+
export function resolveSessionStatusEntry(params: {
20+
agentId: string;
21+
alias: string;
22+
cfg: OpenClawConfig;
23+
includeAliasFallback?: boolean;
24+
keyRaw: string;
25+
mainKey: string;
26+
requesterInternalKey?: string;
27+
}): ResolvedStatusSessionEntry | null {
28+
const keyRaw = params.keyRaw.trim();
29+
if (!keyRaw) {
30+
return null;
31+
}
32+
const includeAliasFallback = params.includeAliasFallback ?? true;
33+
const internal = resolveInternalSessionKey({
34+
key: keyRaw,
35+
alias: params.alias,
36+
mainKey: params.mainKey,
37+
requesterInternalKey: params.requesterInternalKey,
38+
});
39+
40+
const candidates: string[] = [keyRaw];
41+
if (!keyRaw.startsWith("agent:")) {
42+
candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
43+
}
44+
if (includeAliasFallback && internal !== keyRaw) {
45+
candidates.push(internal);
46+
}
47+
if (includeAliasFallback && !keyRaw.startsWith("agent:")) {
48+
const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`;
49+
const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`;
50+
if (agentInternal !== agentRaw) {
51+
candidates.push(agentInternal);
52+
}
53+
}
54+
if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) {
55+
const defaultMainKey = buildAgentMainSessionKey({
56+
agentId: DEFAULT_AGENT_ID,
57+
mainKey: params.mainKey,
58+
});
59+
if (!candidates.includes(defaultMainKey)) {
60+
candidates.push(defaultMainKey);
61+
}
62+
}
63+
64+
const resolved = resolveSessionEntryCandidateTarget({
65+
agentId: params.agentId,
66+
candidateKeys: candidates,
67+
cfg: params.cfg,
68+
});
69+
return resolved
70+
? {
71+
entry: resolved.entry,
72+
key: resolved.sessionKey,
73+
persisted: resolved.persisted,
74+
}
75+
: null;
76+
}
77+
78+
/** Maps requester keys into the currently selected agent store's legacy main key shape. */
79+
export function resolveStoreScopedRequesterKey(params: {
80+
agentId: string;
81+
mainKey: string;
82+
requesterKey: string;
83+
}) {
84+
const parsed = parseAgentSessionKey(params.requesterKey);
85+
if (!parsed || parsed.agentId !== params.agentId) {
86+
return params.requesterKey;
87+
}
88+
return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey;
89+
}
90+
91+
function synthesizeImplicitCurrentSessionEntry(): SessionEntry {
92+
return {
93+
sessionId: "",
94+
updatedAt: Date.now(),
95+
};
96+
}
97+
98+
/** Returns a synthesized current-session entry without writing it to storage. */
99+
export function resolveImplicitCurrentSessionFallback(params: {
100+
agentId: string;
101+
allowFallback: boolean;
102+
cfg: OpenClawConfig;
103+
fallbackKey: string;
104+
}): ResolvedStatusSessionEntry | null {
105+
const fallbackKey = params.fallbackKey.trim();
106+
if (!params.allowFallback || !fallbackKey) {
107+
return null;
108+
}
109+
const resolved = resolveSessionEntryCandidateTarget({
110+
agentId: params.agentId,
111+
candidateKeys: [],
112+
cfg: params.cfg,
113+
fallback: {
114+
sessionKey: fallbackKey,
115+
entry: synthesizeImplicitCurrentSessionEntry(),
116+
},
117+
});
118+
return resolved
119+
? {
120+
entry: resolved.entry,
121+
key: resolved.sessionKey,
122+
persisted: resolved.persisted,
123+
}
124+
: null;
125+
}
126+
127+
/** Lists policy-key fallbacks for implicit default-account direct status lookups. */
128+
export function listImplicitDefaultDirectFallbackKeys(params: {
129+
keyRaw: string;
130+
mainKey: string;
131+
}): string[] {
132+
const parsed = parseAgentSessionKey(params.keyRaw.trim());
133+
if (!parsed) {
134+
return [];
135+
}
136+
const parts = parsed.rest.split(":");
137+
if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") {
138+
return [];
139+
}
140+
const channel = parts[0];
141+
const peerParts = parts.slice(3);
142+
if (!channel || peerParts.length === 0) {
143+
return [];
144+
}
145+
const candidates = [
146+
`agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`,
147+
buildAgentMainSessionKey({
148+
agentId: parsed.agentId,
149+
mainKey: params.mainKey,
150+
}),
151+
params.mainKey,
152+
];
153+
return uniqueStrings(candidates);
154+
}

0 commit comments

Comments
 (0)