Skip to content

Commit f7f1dd8

Browse files
committed
Auto-merge upstream openclaw/openclaw
2 parents 8cd4086 + 6159b17 commit f7f1dd8

4 files changed

Lines changed: 252 additions & 12 deletions

File tree

src/agents/tools/sessions-spawn-tool.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
33
const hoisted = vi.hoisted(() => {
44
const spawnSubagentDirectMock = vi.fn();
55
const spawnAcpDirectMock = vi.fn();
6+
const registerSubagentRunMock = vi.fn();
67
return {
78
spawnSubagentDirectMock,
89
spawnAcpDirectMock,
10+
registerSubagentRunMock,
911
};
1012
});
1113

@@ -21,6 +23,10 @@ vi.mock("../acp-spawn.js", () => ({
2123
spawnAcpDirect: (...args: unknown[]) => hoisted.spawnAcpDirectMock(...args),
2224
}));
2325

26+
vi.mock("../subagent-registry.js", () => ({
27+
registerSubagentRun: (...args: unknown[]) => hoisted.registerSubagentRunMock(...args),
28+
}));
29+
2430
let createSessionsSpawnTool: typeof import("./sessions-spawn-tool.js").createSessionsSpawnTool;
2531

2632
describe("sessions_spawn tool", () => {
@@ -39,6 +45,7 @@ describe("sessions_spawn tool", () => {
3945
childSessionKey: "agent:codex:acp:1",
4046
runId: "run-acp",
4147
});
48+
hoisted.registerSubagentRunMock.mockReset();
4249
});
4350

4451
it("uses subagent runtime by default", async () => {
@@ -237,6 +244,7 @@ describe("sessions_spawn tool", () => {
237244
}),
238245
);
239246
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
247+
expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled();
240248
});
241249

242250
it("adds requested role to forwarded ACP failures", async () => {
@@ -286,6 +294,16 @@ describe("sessions_spawn tool", () => {
286294
sandboxed: true,
287295
}),
288296
);
297+
expect(hoisted.registerSubagentRunMock).toHaveBeenCalledWith(
298+
expect.objectContaining({
299+
runId: "run-acp",
300+
childSessionKey: "agent:codex:acp:1",
301+
requesterSessionKey: "agent:main:subagent:parent",
302+
task: "investigate",
303+
cleanup: "keep",
304+
spawnMode: "run",
305+
}),
306+
);
289307
});
290308

291309
it("passes resumeSessionId through to ACP spawns", async () => {

src/gateway/session-utils.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,105 @@ describe("gateway session utils", () => {
331331
}
332332
});
333333

334+
test("loadSessionEntry preserves a listed deleted main session over the live default main", async () => {
335+
resetConfigRuntimeState();
336+
try {
337+
await withStateDirEnv("session-utils-load-deleted-main-entry-", async ({ stateDir }) => {
338+
const storeTemplate = path.join(
339+
stateDir,
340+
"agents",
341+
"{agentId}",
342+
"sessions",
343+
"sessions.json",
344+
);
345+
const liveSessionsDir = path.join(stateDir, "agents", "ops", "sessions");
346+
const deletedSessionsDir = path.join(stateDir, "agents", "main", "sessions");
347+
fs.mkdirSync(liveSessionsDir, { recursive: true });
348+
fs.mkdirSync(deletedSessionsDir, { recursive: true });
349+
const liveStorePath = path.join(liveSessionsDir, "sessions.json");
350+
const deletedStorePath = path.join(deletedSessionsDir, "sessions.json");
351+
fs.writeFileSync(
352+
liveStorePath,
353+
JSON.stringify({
354+
"agent:ops:main": { sessionId: "sess-live-default", updatedAt: 10 },
355+
}),
356+
"utf8",
357+
);
358+
fs.writeFileSync(
359+
deletedStorePath,
360+
JSON.stringify({
361+
"agent:main:main": { sessionId: "sess-deleted-main", updatedAt: 20 },
362+
}),
363+
"utf8",
364+
);
365+
const cfg = {
366+
session: { mainKey: "main", store: storeTemplate },
367+
agents: { list: [{ id: "ops", default: true }] },
368+
} as OpenClawConfig;
369+
setRuntimeConfigSnapshot(cfg, cfg);
370+
371+
const target = resolveGatewaySessionStoreTarget({ cfg, key: "agent:main:main" });
372+
const loaded = loadSessionEntry("agent:main:main");
373+
374+
expect(target.canonicalKey).toBe("agent:main:main");
375+
expect(target.agentId).toBe("main");
376+
expect(target.storePath).toBe(resolveSyncRealpath(deletedStorePath));
377+
expect(loaded.canonicalKey).toBe("agent:main:main");
378+
expect(loaded.storePath).toBe(resolveSyncRealpath(deletedStorePath));
379+
expect(loaded.entry?.sessionId).toBe("sess-deleted-main");
380+
});
381+
} finally {
382+
resetConfigRuntimeState();
383+
}
384+
});
385+
386+
test("loadSessionEntry resolves deleted main aliases when mainKey is customized", async () => {
387+
resetConfigRuntimeState();
388+
try {
389+
await withStateDirEnv("session-utils-load-deleted-main-alias-", async ({ stateDir }) => {
390+
const storeTemplate = path.join(
391+
stateDir,
392+
"agents",
393+
"{agentId}",
394+
"sessions",
395+
"sessions.json",
396+
);
397+
const liveSessionsDir = path.join(stateDir, "agents", "ops", "sessions");
398+
const deletedSessionsDir = path.join(stateDir, "agents", "main", "sessions");
399+
fs.mkdirSync(liveSessionsDir, { recursive: true });
400+
fs.mkdirSync(deletedSessionsDir, { recursive: true });
401+
fs.writeFileSync(
402+
path.join(liveSessionsDir, "sessions.json"),
403+
JSON.stringify({
404+
"agent:ops:work": { sessionId: "sess-live-default", updatedAt: 10 },
405+
}),
406+
"utf8",
407+
);
408+
const deletedStorePath = path.join(deletedSessionsDir, "sessions.json");
409+
fs.writeFileSync(
410+
deletedStorePath,
411+
JSON.stringify({
412+
"agent:main:main": { sessionId: "sess-deleted-main", updatedAt: 20 },
413+
}),
414+
"utf8",
415+
);
416+
const cfg = {
417+
session: { mainKey: "work", store: storeTemplate },
418+
agents: { list: [{ id: "ops", default: true }] },
419+
} as OpenClawConfig;
420+
setRuntimeConfigSnapshot(cfg, cfg);
421+
422+
const loaded = loadSessionEntry("agent:main:work");
423+
424+
expect(loaded.canonicalKey).toBe("agent:main:work");
425+
expect(loaded.storePath).toBe(resolveSyncRealpath(deletedStorePath));
426+
expect(loaded.entry?.sessionId).toBe("sess-deleted-main");
427+
});
428+
} finally {
429+
resetConfigRuntimeState();
430+
}
431+
});
432+
334433
test("loadSessionEntry prefers the freshest duplicate row for a logical key", async () => {
335434
resetConfigRuntimeState();
336435
try {

src/gateway/session-utils.ts

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
import type { OpenClawConfig } from "../config/types.openclaw.js";
4343
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
4444
import {
45+
DEFAULT_AGENT_ID,
4546
normalizeAgentId,
4647
normalizeMainKey,
4748
parseAgentSessionKey,
@@ -414,22 +415,23 @@ export function resolveDeletedAgentIdFromSessionKey(
414415

415416
export function loadSessionEntry(sessionKey: string) {
416417
const cfg = loadConfig();
417-
const canonicalKey = resolveSessionStoreKey({ cfg, sessionKey });
418-
const agentId = resolveSessionStoreAgentId(cfg, canonicalKey);
419-
const { storePath, store } = resolveGatewaySessionStoreLookup({
420-
cfg,
421-
key: normalizeOptionalString(sessionKey) ?? "",
422-
canonicalKey,
423-
agentId,
424-
});
418+
const key = normalizeOptionalString(sessionKey) ?? "";
425419
const target = resolveGatewaySessionStoreTarget({
426420
cfg,
427-
key: normalizeOptionalString(sessionKey) ?? "",
428-
store,
421+
key,
429422
});
423+
const storePath = target.storePath;
424+
const store = loadSessionStore(storePath);
430425
const freshestMatch = resolveFreshestSessionStoreMatchFromStoreKeys(store, target.storeKeys);
431-
const legacyKey = freshestMatch?.key !== canonicalKey ? freshestMatch?.key : undefined;
432-
return { cfg, storePath, store, entry: freshestMatch?.entry, canonicalKey, legacyKey };
426+
const legacyKey = freshestMatch?.key !== target.canonicalKey ? freshestMatch?.key : undefined;
427+
return {
428+
cfg,
429+
storePath,
430+
store,
431+
entry: freshestMatch?.entry,
432+
canonicalKey: target.canonicalKey,
433+
legacyKey,
434+
};
433435
}
434436

435437
export function resolveFreshestSessionStoreMatchFromStoreKeys(
@@ -828,6 +830,83 @@ function resolveGatewaySessionStoreLookup(params: {
828830
};
829831
}
830832

833+
function resolveExplicitDeletedLegacyMainStoreTarget(params: {
834+
cfg: OpenClawConfig;
835+
key: string;
836+
scanLegacyKeys?: boolean;
837+
}): {
838+
agentId: string;
839+
storePath: string;
840+
canonicalKey: string;
841+
storeKeys: string[];
842+
} | null {
843+
const parsed = parseAgentSessionKey(params.key);
844+
const legacyAgentId = normalizeAgentId(parsed?.agentId);
845+
if (
846+
!parsed ||
847+
legacyAgentId !== DEFAULT_AGENT_ID ||
848+
listAgentIds(params.cfg).includes(legacyAgentId)
849+
) {
850+
return null;
851+
}
852+
853+
// Only preserve agent:main:* when it is backed by a discovered deleted-main store.
854+
// Shared-store legacy aliases should continue remapping to the configured default agent.
855+
const canonicalKey = resolveStoredSessionKeyForAgentStore({
856+
cfg: params.cfg,
857+
agentId: legacyAgentId,
858+
sessionKey: params.key,
859+
});
860+
const agentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: legacyAgentId });
861+
const legacyAgentMainKey = `agent:${legacyAgentId}:main`;
862+
const lookupSeeds = Array.from(
863+
new Set([params.key, canonicalKey, agentMainKey, legacyAgentMainKey]),
864+
);
865+
let best:
866+
| {
867+
storePath: string;
868+
store: Record<string, SessionEntry>;
869+
match: { entry: SessionEntry; key: string };
870+
}
871+
| undefined;
872+
for (const target of resolveAllAgentSessionStoreTargetsSync(params.cfg)) {
873+
if (target.agentId !== legacyAgentId) {
874+
continue;
875+
}
876+
const store = loadSessionStore(target.storePath);
877+
const match = findFreshestStoreMatch(store, ...lookupSeeds);
878+
if (!match) {
879+
continue;
880+
}
881+
if (!best || (match.entry.updatedAt ?? 0) >= (best.match.entry.updatedAt ?? 0)) {
882+
best = { storePath: target.storePath, store, match };
883+
}
884+
}
885+
if (!best) {
886+
return null;
887+
}
888+
889+
const storeKeys = new Set<string>([canonicalKey]);
890+
if (params.key !== canonicalKey) {
891+
storeKeys.add(params.key);
892+
}
893+
storeKeys.add(best.match.key);
894+
if (params.scanLegacyKeys !== false) {
895+
for (const seed of lookupSeeds) {
896+
storeKeys.add(seed);
897+
for (const legacyKey of findStoreKeysIgnoreCase(best.store, seed)) {
898+
storeKeys.add(legacyKey);
899+
}
900+
}
901+
}
902+
return {
903+
agentId: legacyAgentId,
904+
storePath: best.storePath,
905+
canonicalKey,
906+
storeKeys: Array.from(storeKeys),
907+
};
908+
}
909+
831910
export function resolveGatewaySessionStoreTarget(params: {
832911
cfg: OpenClawConfig;
833912
key: string;
@@ -840,6 +919,15 @@ export function resolveGatewaySessionStoreTarget(params: {
840919
storeKeys: string[];
841920
} {
842921
const key = normalizeOptionalString(params.key) ?? "";
922+
const explicitDeletedMainTarget = resolveExplicitDeletedLegacyMainStoreTarget({
923+
cfg: params.cfg,
924+
key,
925+
scanLegacyKeys: params.scanLegacyKeys,
926+
});
927+
if (explicitDeletedMainTarget) {
928+
return explicitDeletedMainTarget;
929+
}
930+
843931
const canonicalKey = resolveSessionStoreKey({
844932
cfg: params.cfg,
845933
sessionKey: key,

src/gateway/sessions-resolve-store.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,39 @@ describe("resolveSessionKeyFromResolveParams store canonicalization", () => {
109109
});
110110
});
111111
});
112+
113+
it("rejects an explicit listed deleted main key instead of remapping to the live default main", async () => {
114+
await withStateDirEnv("openclaw-sessions-resolve-key-deleted-main-", async () => {
115+
const cfg: OpenClawConfig = {
116+
agents: { list: [{ id: "ops", default: true }] },
117+
};
118+
const liveDefaultStorePath = resolveStorePath(cfg.session?.store, { agentId: "ops" });
119+
await saveSessionStore(liveDefaultStorePath, {
120+
"agent:ops:main": {
121+
sessionId: "sess-live-default",
122+
updatedAt: 10,
123+
},
124+
});
125+
const staleMainStorePath = resolveStorePath(cfg.session?.store, { agentId: "main" });
126+
await saveSessionStore(staleMainStorePath, {
127+
"agent:main:main": {
128+
sessionId: "sess-deleted-main",
129+
updatedAt: 20,
130+
},
131+
});
132+
133+
await expect(
134+
resolveSessionKeyFromResolveParams({
135+
cfg,
136+
p: { key: "agent:main:main" },
137+
}),
138+
).resolves.toEqual({
139+
ok: false,
140+
error: {
141+
code: ErrorCodes.INVALID_REQUEST,
142+
message: 'Agent "main" no longer exists in configuration',
143+
},
144+
});
145+
});
146+
});
112147
});

0 commit comments

Comments
 (0)