Skip to content

Commit c3d3c77

Browse files
1052326311steipete
authored andcommitted
fix(agents): update session store with rotated session id after compaction
When truncateAfterCompaction rotates the transcript to a new session file, rotateTranscriptAfterCompaction creates a new session ID. The run records this correctly in result.meta.agentMeta.sessionId, but updateSessionStoreAfterAgentRun was called with the original (pre-rotation) sessionId, leaving sessions.json pointing at the old file. Use result.meta.agentMeta.sessionId when available so the session store reflects the rotated file path. Closes #88040
1 parent 07870df commit c3d3c77

5 files changed

Lines changed: 455 additions & 10 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import { loadSessionStore, type SessionEntry } from "../config/sessions.js";
6+
import type { OpenClawConfig } from "../config/types.openclaw.js";
7+
8+
const state = vi.hoisted(() => ({
9+
cfg: undefined as OpenClawConfig | undefined,
10+
workspaceDir: undefined as string | undefined,
11+
agentDir: undefined as string | undefined,
12+
runAgentAttemptMock: vi.fn(),
13+
deliveryFreshEntries: [] as Array<SessionEntry | undefined>,
14+
}));
15+
16+
vi.mock("../config/io.js", () => ({
17+
getRuntimeConfig: () => state.cfg,
18+
readConfigFileSnapshotForWrite: async () => ({ snapshot: { valid: false } }),
19+
}));
20+
21+
vi.mock("./agent-runtime-config.js", () => ({
22+
resolveAgentRuntimeConfig: async () => ({
23+
loadedRaw: state.cfg,
24+
sourceConfig: state.cfg,
25+
cfg: state.cfg,
26+
}),
27+
}));
28+
29+
vi.mock("./agent-scope.js", async () => {
30+
const actual = await vi.importActual<typeof import("./agent-scope.js")>("./agent-scope.js");
31+
return {
32+
...actual,
33+
clearAutoFallbackPrimaryProbeSelection: vi.fn(),
34+
entryMatchesAutoFallbackPrimaryProbe: () => false,
35+
hasSessionAutoModelFallbackProvenance: () => false,
36+
listAgentIds: () => ["main"],
37+
markAutoFallbackPrimaryProbe: vi.fn(),
38+
resolveAutoFallbackPrimaryProbe: () => undefined,
39+
resolveAgentConfig: () => undefined,
40+
resolveAgentDir: () => state.agentDir ?? "/tmp/openclaw-agent",
41+
resolveDefaultAgentId: () => "main",
42+
resolveEffectiveModelFallbacks: () => undefined,
43+
resolveSessionAgentId: () => "main",
44+
resolveAgentWorkspaceDir: () => state.workspaceDir ?? "/tmp/openclaw-workspace",
45+
};
46+
});
47+
48+
vi.mock("../plugins/manifest-contract-eligibility.js", () => ({
49+
loadManifestMetadataSnapshot: () => ({ plugins: [] }),
50+
}));
51+
52+
vi.mock("./model-catalog.js", () => ({
53+
loadManifestModelCatalog: () => [],
54+
}));
55+
56+
vi.mock("./harness/runtime-plugin.js", () => ({
57+
ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined),
58+
}));
59+
60+
vi.mock("./workspace.js", () => ({
61+
ensureAgentWorkspace: vi.fn(async () => undefined),
62+
}));
63+
64+
vi.mock("./auth-profiles/store.js", async () => {
65+
const actual = await vi.importActual<typeof import("./auth-profiles/store.js")>(
66+
"./auth-profiles/store.js",
67+
);
68+
return {
69+
...actual,
70+
ensureAuthProfileStore: () => ({ profiles: {} }),
71+
saveAuthProfileStore: vi.fn(),
72+
updateAuthProfileStoreWithLock: vi.fn(async () => ({ profiles: {} })),
73+
};
74+
});
75+
76+
vi.mock("../acp/control-plane/manager.js", () => ({
77+
getAcpSessionManager: () => ({
78+
resolveSession: () => null,
79+
}),
80+
}));
81+
82+
vi.mock("../skills/runtime/remote.js", () => ({
83+
getRemoteSkillEligibility: () => ({ enabled: false, reason: "test" }),
84+
}));
85+
86+
vi.mock("../skills/runtime/session-snapshot.js", () => ({
87+
resolveReusableWorkspaceSkillSnapshot: () => ({
88+
shouldRefresh: true,
89+
snapshot: {
90+
prompt: "",
91+
skills: [],
92+
resolvedSkills: [],
93+
version: 0,
94+
},
95+
}),
96+
}));
97+
98+
vi.mock("./exec-defaults.js", () => ({
99+
canExecRequestNode: () => false,
100+
}));
101+
102+
vi.mock("./model-fallback.js", () => ({
103+
runWithModelFallback: async (params: {
104+
provider: string;
105+
model: string;
106+
run: (provider: string, model: string) => Promise<unknown>;
107+
}) => ({
108+
result: await params.run(params.provider, params.model),
109+
provider: params.provider,
110+
model: params.model,
111+
attempts: [],
112+
}),
113+
}));
114+
115+
vi.mock("./command/attempt-execution.runtime.js", async () => {
116+
const actual = await vi.importActual<typeof import("./command/attempt-execution.runtime.js")>(
117+
"./command/attempt-execution.runtime.js",
118+
);
119+
return {
120+
...actual,
121+
runAgentAttempt: (...args: unknown[]) => state.runAgentAttemptMock(...args),
122+
};
123+
});
124+
125+
vi.mock("./command/cli-compaction.js", () => ({
126+
runCliTurnCompactionLifecycle: async (params: { sessionEntry?: SessionEntry }) =>
127+
params.sessionEntry,
128+
}));
129+
130+
vi.mock("./command/delivery.runtime.js", () => ({
131+
deliverAgentCommandResult: async (params: {
132+
resolveFreshSessionEntryForDelivery?: () => Promise<SessionEntry | undefined>;
133+
}) => {
134+
state.deliveryFreshEntries.push(await params.resolveFreshSessionEntryForDelivery?.());
135+
return { deliverySucceeded: true };
136+
},
137+
}));
138+
139+
let agentCommand: typeof import("./agent-command.js").agentCommand;
140+
141+
beforeEach(async () => {
142+
vi.clearAllMocks();
143+
state.deliveryFreshEntries = [];
144+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rotation-e2e-"));
145+
state.workspaceDir = path.join(tmpDir, "workspace");
146+
state.agentDir = path.join(tmpDir, "agent");
147+
await fs.mkdir(state.workspaceDir, { recursive: true });
148+
await fs.mkdir(state.agentDir, { recursive: true });
149+
state.cfg = {
150+
session: {
151+
store: path.join(tmpDir, "sessions.json"),
152+
},
153+
agents: {
154+
defaults: {
155+
models: {
156+
"openai/gpt-5.5": {},
157+
},
158+
},
159+
},
160+
} as OpenClawConfig;
161+
agentCommand ??= (await import("./agent-command.js")).agentCommand;
162+
});
163+
164+
afterEach(async () => {
165+
const storePath = state.cfg?.session?.store;
166+
state.cfg = undefined;
167+
state.workspaceDir = undefined;
168+
state.agentDir = undefined;
169+
if (storePath) {
170+
await fs.rm(path.dirname(storePath), { recursive: true, force: true });
171+
}
172+
});
173+
174+
function makeResult(params: {
175+
sessionId: string;
176+
sessionFile?: string;
177+
text: string;
178+
compactionCount?: number;
179+
}) {
180+
return {
181+
payloads: [{ text: params.text }],
182+
meta: {
183+
durationMs: 1,
184+
stopReason: "end_turn",
185+
executionTrace: {
186+
runner: "embedded",
187+
fallbackUsed: false,
188+
winnerProvider: "openai",
189+
winnerModel: "gpt-5.5",
190+
},
191+
finalAssistantVisibleText: params.text,
192+
agentMeta: {
193+
sessionId: params.sessionId,
194+
...(params.sessionFile ? { sessionFile: params.sessionFile } : {}),
195+
provider: "openai",
196+
model: "gpt-5.5",
197+
...(params.compactionCount ? { compactionCount: params.compactionCount } : {}),
198+
},
199+
},
200+
};
201+
}
202+
203+
async function readSessionMessages(sessionFile: string) {
204+
const raw = await fs.readFile(sessionFile, "utf-8");
205+
return raw
206+
.split(/\r?\n/)
207+
.filter(Boolean)
208+
.map((line) => JSON.parse(line) as { type?: string; message?: { role?: string } })
209+
.filter((entry) => entry.type === "message")
210+
.map((entry) => entry.message);
211+
}
212+
213+
describe("agentCommand compaction transcript rotation", () => {
214+
it("keeps sessions.json on the rotated successor and resumes the next turn from it", async () => {
215+
const storePath = state.cfg?.session?.store;
216+
if (!storePath) {
217+
throw new Error("missing test session store path");
218+
}
219+
const sessionsDir = await fs.realpath(path.dirname(storePath));
220+
const rotatedSessionFile = path.join(sessionsDir, "rotated-session.jsonl");
221+
state.runAgentAttemptMock
222+
.mockResolvedValueOnce(
223+
makeResult({
224+
sessionId: "rotated-session",
225+
sessionFile: rotatedSessionFile,
226+
text: "first answer after rotation",
227+
compactionCount: 1,
228+
}),
229+
)
230+
.mockResolvedValueOnce(
231+
makeResult({
232+
sessionId: "rotated-session",
233+
text: "second answer",
234+
}),
235+
);
236+
237+
await agentCommand({
238+
message: "first prompt",
239+
sessionId: "old-session",
240+
cwd: state.workspaceDir,
241+
});
242+
243+
const storeAfterRotation = loadSessionStore(storePath, { skipCache: true });
244+
const entriesAfterRotation = Object.entries(storeAfterRotation);
245+
expect(entriesAfterRotation).toHaveLength(1);
246+
const [sessionKey, rotatedEntry] = entriesAfterRotation[0] ?? [];
247+
expect(sessionKey).toBe("agent:main:explicit:old-session");
248+
expect(rotatedEntry).toMatchObject({
249+
sessionId: "rotated-session",
250+
sessionFile: rotatedSessionFile,
251+
usageFamilyKey: "agent:main:explicit:old-session",
252+
usageFamilySessionIds: ["old-session", "rotated-session"],
253+
compactionCount: 1,
254+
});
255+
await expect(readSessionMessages(rotatedSessionFile)).resolves.toEqual([
256+
expect.objectContaining({ role: "assistant" }),
257+
]);
258+
259+
await agentCommand({
260+
message: "second prompt",
261+
sessionId: "rotated-session",
262+
cwd: state.workspaceDir,
263+
});
264+
265+
const secondAttempt = state.runAgentAttemptMock.mock.calls[1]?.[0] as
266+
| { sessionId?: string; sessionFile?: string; sessionKey?: string }
267+
| undefined;
268+
expect(secondAttempt).toMatchObject({
269+
sessionId: "rotated-session",
270+
sessionKey,
271+
sessionFile: rotatedSessionFile,
272+
});
273+
expect(state.deliveryFreshEntries.at(-1)).toMatchObject({
274+
sessionId: "rotated-session",
275+
sessionFile: rotatedSessionFile,
276+
});
277+
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey ?? ""]).toMatchObject({
278+
sessionId: "rotated-session",
279+
sessionFile: rotatedSessionFile,
280+
});
281+
});
282+
});

0 commit comments

Comments
 (0)