Skip to content

Commit 9915699

Browse files
committed
fix: preserve session-file tail fallback
1 parent 393ff33 commit 9915699

3 files changed

Lines changed: 231 additions & 32 deletions

File tree

src/commands/sessions-tail.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import os from "node:os";
44
import path from "node:path";
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import type { RuntimeEnv } from "../runtime.js";
7-
import { resolveTrajectoryPointerFilePath } from "../trajectory/paths.js";
7+
import {
8+
resolveTrajectoryFilePath,
9+
resolveTrajectoryPointerFilePath,
10+
} from "../trajectory/paths.js";
811
import type { TrajectoryEvent } from "../trajectory/types.js";
912
import { sessionsTailCommand, setSessionsTailFollowIntervalMsForTests } from "./sessions-tail.js";
1013

@@ -77,12 +80,15 @@ describe("sessionsTailCommand", () => {
7780
let storePath: string;
7881
let trajectoryPath: string;
7982
let previousStateDir: string | undefined;
83+
let previousTrajectoryDir: string | undefined;
8084

8185
beforeEach(() => {
8286
setSessionsTailFollowIntervalMsForTests(10);
8387
previousStateDir = process.env.OPENCLAW_STATE_DIR;
88+
previousTrajectoryDir = process.env.OPENCLAW_TRAJECTORY_DIR;
8489
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sessions-tail-"));
8590
process.env.OPENCLAW_STATE_DIR = path.join(tmpDir, "state");
91+
delete process.env.OPENCLAW_TRAJECTORY_DIR;
8692
mocks.getRuntimeConfig.mockReturnValue({
8793
agents: {
8894
list: [{ id: "main" }, { id: "ops" }],
@@ -110,6 +116,11 @@ describe("sessionsTailCommand", () => {
110116
} else {
111117
process.env.OPENCLAW_STATE_DIR = previousStateDir;
112118
}
119+
if (previousTrajectoryDir === undefined) {
120+
delete process.env.OPENCLAW_TRAJECTORY_DIR;
121+
} else {
122+
process.env.OPENCLAW_TRAJECTORY_DIR = previousTrajectoryDir;
123+
}
113124
fs.rmSync(tmpDir, { recursive: true, force: true });
114125
});
115126

@@ -350,4 +361,123 @@ describe("sessionsTailCommand", () => {
350361
expect(output).toContain("bash ok");
351362
expect(output).not.toContain("No sessions found");
352363
});
364+
365+
it("tails entries pinned by sessionFile when sessionId was normalized away", async () => {
366+
const runtime = makeRuntime();
367+
const legacySessionFile = path.join(tmpDir, "legacy-session.jsonl");
368+
const legacyTrajectoryPath = path.join(tmpDir, "legacy-session.trajectory.jsonl");
369+
fs.writeFileSync(legacySessionFile, "");
370+
fs.writeFileSync(
371+
storePath,
372+
`${JSON.stringify({
373+
[sessionKey]: {
374+
sessionFile: legacySessionFile,
375+
updatedAt: 2,
376+
status: "running",
377+
},
378+
})}\n`,
379+
);
380+
writeJsonl(legacyTrajectoryPath, [
381+
makeEvent({
382+
type: "tool.result",
383+
ts: "2026-05-18T12:04:21.000Z",
384+
data: { name: "legacy", success: true },
385+
}),
386+
]);
387+
388+
await sessionsTailCommand({ store: storePath, sessionKey }, runtime);
389+
390+
const output = runtimeOutput(runtime);
391+
expect(output).toContain("tool.result");
392+
expect(output).toContain("legacy ok");
393+
expect(output).not.toContain("No sessions found");
394+
});
395+
396+
it("uses trajectory pointers for sessionFile-only entries with original runtime ids", async () => {
397+
const runtime = makeRuntime();
398+
const legacySessionFile = path.join(tmpDir, "legacy-session.jsonl");
399+
const pointerPath = resolveTrajectoryPointerFilePath(legacySessionFile);
400+
const runtimeTrajectoryPath = path.join(tmpDir, "runtime-original.jsonl");
401+
fs.writeFileSync(legacySessionFile, "");
402+
fs.writeFileSync(
403+
storePath,
404+
`${JSON.stringify({
405+
[sessionKey]: {
406+
sessionFile: legacySessionFile,
407+
updatedAt: 2,
408+
status: "running",
409+
},
410+
})}\n`,
411+
);
412+
fs.writeFileSync(
413+
pointerPath,
414+
`${JSON.stringify({
415+
sessionId: "runtime-original",
416+
runtimeFile: runtimeTrajectoryPath,
417+
})}\n`,
418+
);
419+
writeJsonl(runtimeTrajectoryPath, [
420+
makeEvent({
421+
sessionId: "runtime-original",
422+
type: "tool.result",
423+
ts: "2026-05-18T12:04:21.000Z",
424+
data: { name: "pointer", success: true },
425+
}),
426+
]);
427+
428+
await sessionsTailCommand({ store: storePath, sessionKey }, runtime);
429+
430+
const output = runtimeOutput(runtime);
431+
expect(output).toContain("tool.result");
432+
expect(output).toContain("pointer ok");
433+
expect(output).not.toContain("No sessions found");
434+
});
435+
436+
it("keeps trajectory directory fallback for pointer session ids", async () => {
437+
const runtime = makeRuntime();
438+
const legacySessionFile = path.join(tmpDir, "legacy-session.jsonl");
439+
const pointerPath = resolveTrajectoryPointerFilePath(legacySessionFile);
440+
const trajectoryDir = path.join(tmpDir, "trajectories");
441+
process.env.OPENCLAW_TRAJECTORY_DIR = trajectoryDir;
442+
fs.mkdirSync(trajectoryDir, { recursive: true });
443+
fs.writeFileSync(legacySessionFile, "");
444+
fs.writeFileSync(
445+
storePath,
446+
`${JSON.stringify({
447+
[sessionKey]: {
448+
sessionFile: legacySessionFile,
449+
updatedAt: 2,
450+
status: "running",
451+
},
452+
})}\n`,
453+
);
454+
fs.writeFileSync(
455+
pointerPath,
456+
`${JSON.stringify({
457+
sessionId: "runtime-original",
458+
runtimeFile: path.join(tmpDir, "missing-pointer-runtime.jsonl"),
459+
})}\n`,
460+
);
461+
writeJsonl(
462+
resolveTrajectoryFilePath({
463+
sessionFile: legacySessionFile,
464+
sessionId: "runtime-original",
465+
}),
466+
[
467+
makeEvent({
468+
sessionId: "runtime-original",
469+
type: "tool.result",
470+
ts: "2026-05-18T12:04:21.000Z",
471+
data: { name: "trajectory-dir", success: true },
472+
}),
473+
],
474+
);
475+
476+
await sessionsTailCommand({ store: storePath, sessionKey }, runtime);
477+
478+
const output = runtimeOutput(runtime);
479+
expect(output).toContain("tool.result");
480+
expect(output).toContain("trajectory-dir ok");
481+
expect(output).not.toContain("No sessions found");
482+
});
353483
});

src/commands/sessions-tail.ts

Lines changed: 87 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,14 @@ import { resolveStoredSessionKeyForAgentStore } from "../gateway/session-store-k
1515
import { formatErrorMessage } from "../infra/errors.js";
1616
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
1717
import type { RuntimeEnv } from "../runtime.js";
18-
import { resolveTrajectoryFilePath } from "../trajectory/paths.js";
19-
import { resolveTrajectoryRuntimeFile } from "../trajectory/runtime-file.js";
18+
import {
19+
resolveTrajectoryFilePath,
20+
resolveTrajectoryPointerFilePath,
21+
} from "../trajectory/paths.js";
22+
import {
23+
isRegularNonSymlinkFile,
24+
resolveTrajectoryRuntimeFile,
25+
} from "../trajectory/runtime-file.js";
2026
import type { TrajectoryEvent } from "../trajectory/types.js";
2127
import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js";
2228
import { shortenText } from "./text-format.js";
@@ -346,30 +352,96 @@ function compareSelectionsByUpdatedAt(a: TailSelection, b: TailSelection): numbe
346352
return (b.entry.updatedAt ?? 0) - (a.entry.updatedAt ?? 0);
347353
}
348354

355+
function deriveSessionFileFallbackId(entry: SessionEntry): string | undefined {
356+
const sessionId = entry.sessionId?.trim();
357+
if (sessionId) {
358+
return sessionId;
359+
}
360+
const sessionFile = entry.sessionFile?.trim();
361+
if (!sessionFile) {
362+
return undefined;
363+
}
364+
return "session";
365+
}
366+
367+
async function readTrajectoryPointerSessionId(sessionFile: string): Promise<string | undefined> {
368+
const pointerPath = resolveTrajectoryPointerFilePath(sessionFile);
369+
if (!(await isRegularNonSymlinkFile(pointerPath))) {
370+
return undefined;
371+
}
372+
try {
373+
const parsed = JSON.parse(fs.readFileSync(pointerPath, "utf8")) as unknown;
374+
if (!isRecord(parsed) || typeof parsed.sessionId !== "string") {
375+
return undefined;
376+
}
377+
const sessionId = parsed.sessionId.trim();
378+
return sessionId || undefined;
379+
} catch {
380+
return undefined;
381+
}
382+
}
383+
384+
async function resolveTailTrajectoryPath(params: {
385+
sessionFile: string;
386+
sessionId?: string;
387+
}): Promise<string> {
388+
if (params.sessionId) {
389+
return (
390+
(await resolveTrajectoryRuntimeFile({
391+
sessionFile: params.sessionFile,
392+
sessionId: params.sessionId,
393+
})) ??
394+
resolveTrajectoryFilePath({
395+
sessionFile: params.sessionFile,
396+
sessionId: params.sessionId,
397+
})
398+
);
399+
}
400+
401+
const pointerSessionId = await readTrajectoryPointerSessionId(params.sessionFile);
402+
if (pointerSessionId) {
403+
const pointerRuntimePath = await resolveTrajectoryRuntimeFile({
404+
sessionFile: params.sessionFile,
405+
sessionId: pointerSessionId,
406+
});
407+
if (pointerRuntimePath) {
408+
return pointerRuntimePath;
409+
}
410+
return resolveTrajectoryFilePath({
411+
sessionFile: params.sessionFile,
412+
sessionId: pointerSessionId,
413+
});
414+
}
415+
416+
return resolveTrajectoryFilePath({
417+
env: {},
418+
sessionFile: params.sessionFile,
419+
sessionId: "session",
420+
});
421+
}
422+
349423
async function buildTailSelection(params: {
350424
agentId: string;
351425
entry: SessionEntry;
352426
key: string;
353427
storePath: string;
354428
}): Promise<TailSelection | null> {
355429
const sessionId = params.entry.sessionId?.trim();
356-
if (!sessionId) {
430+
const fallbackSessionId = deriveSessionFileFallbackId(params.entry);
431+
if (!fallbackSessionId) {
357432
return null;
358433
}
359434
const sessionsDir = path.dirname(params.storePath);
360-
const sessionFile = resolveSessionFilePath(sessionId, params.entry, {
361-
agentId: params.agentId,
362-
sessionsDir,
363-
});
364-
const trajectoryPath =
365-
(await resolveTrajectoryRuntimeFile({
366-
sessionFile,
367-
sessionId,
368-
})) ??
369-
resolveTrajectoryFilePath({
370-
sessionFile,
371-
sessionId,
435+
let sessionFile: string;
436+
try {
437+
sessionFile = resolveSessionFilePath(fallbackSessionId, params.entry, {
438+
agentId: params.agentId,
439+
sessionsDir,
372440
});
441+
} catch {
442+
return null;
443+
}
444+
const trajectoryPath = await resolveTailTrajectoryPath({ sessionFile, sessionId });
373445
return {
374446
agentId: params.agentId,
375447
entry: params.entry,

src/cron/isolated-agent/delivery-target.test.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
44
import type { OpenClawConfig } from "../../config/config.js";
5+
import type { SessionEntry } from "../../config/sessions/types.js";
56
import {
67
forumMessagingForTest,
78
parseTelegramTargetForTest,
@@ -28,9 +29,8 @@ vi.mock("../../config/sessions/paths.js", () => ({
2829
resolveStorePath: vi.fn().mockReturnValue("/tmp/test-store.json"),
2930
}));
3031

31-
vi.mock("../../config/sessions/store-load.js", () => ({
32-
loadSessionStore: vi.fn().mockReturnValue({}),
33-
readSessionEntry: vi.fn(),
32+
vi.mock("../../config/sessions/session-accessor.js", () => ({
33+
loadSessionEntry: vi.fn(),
3434
}));
3535

3636
vi.mock("../../infra/outbound/channel-selection.runtime.js", () => ({
@@ -50,13 +50,13 @@ const mockedModuleIds = [
5050
"../../config/sessions/main-session.js",
5151
"../../config/sessions/delivery-info.js",
5252
"../../config/sessions/paths.js",
53-
"../../config/sessions/store-load.js",
53+
"../../config/sessions/session-accessor.js",
5454
"../../infra/outbound/channel-selection.runtime.js",
5555
"../../infra/outbound/targets.runtime.js",
5656
"../../infra/outbound/target-id-resolution.js",
5757
];
5858

59-
import { loadSessionStore, readSessionEntry } from "../../config/sessions/store-load.js";
59+
import { loadSessionEntry } from "../../config/sessions/session-accessor.js";
6060
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.runtime.js";
6161
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-id-resolution.js";
6262
import { resolveOutboundTarget } from "../../infra/outbound/targets.runtime.js";
@@ -114,8 +114,7 @@ beforeEach(() => {
114114
extractDeliveryInfoMock.mockReturnValue({ deliveryContext: undefined, threadId: undefined });
115115
normalizeTelegramTargetForDeliveryTest.mockClear();
116116
vi.mocked(resolveOutboundTarget).mockReset();
117-
vi.mocked(loadSessionStore).mockReset().mockReturnValue({});
118-
vi.mocked(readSessionEntry).mockReset().mockReturnValue(undefined);
117+
vi.mocked(loadSessionEntry).mockReset().mockReturnValue(undefined);
119118
setActivePluginRegistry(
120119
createTestRegistry([
121120
{
@@ -222,11 +221,10 @@ const DEFAULT_TARGET = {
222221
to: "room:default",
223222
};
224223

225-
type SessionStore = ReturnType<typeof loadSessionStore>;
224+
type SessionStore = Record<string, SessionEntry>;
226225

227226
function setSessionStore(store: SessionStore) {
228-
vi.mocked(loadSessionStore).mockReturnValue(store);
229-
vi.mocked(readSessionEntry).mockImplementation((_storePath, sessionKey) => store[sessionKey]);
227+
vi.mocked(loadSessionEntry).mockImplementation(({ sessionKey }) => store[sessionKey]);
230228
}
231229

232230
function setMainSessionEntry(entry?: SessionStore[string]) {
@@ -282,12 +280,11 @@ describe("resolveDeliveryTarget", () => {
282280

283281
expect(result.channel).toBe("alpha");
284282
expect(result.to).toBe("room-allowed");
285-
expect(readSessionEntry).toHaveBeenCalledWith(
286-
"/tmp/test-store.json",
287-
"agent:test:main",
288-
expect.any(Object),
289-
);
290-
expect(loadSessionStore).not.toHaveBeenCalled();
283+
expect(loadSessionEntry).toHaveBeenCalledWith({
284+
agentId: AGENT_ID,
285+
sessionKey: "agent:test:main",
286+
storePath: "/tmp/test-store.json",
287+
});
291288
});
292289

293290
it("reroutes implicit delivery to an authorized allowFrom recipient", async () => {

0 commit comments

Comments
 (0)