Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions apps/controller/src/runtime/sessions-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,46 @@ function sessionMetadataPath(filePath: string): string {
return filePath.replace(/\.jsonl$/, ".meta.json");
}

function encodeSessionId(botId: string, sessionKey: string): string {
return `session:${Buffer.from(
JSON.stringify({ botId, sessionKey }),
"utf8",
).toString("base64url")}`;
}

function decodeSessionId(
id: string,
): { botId: string; sessionKey: string } | null {
if (!id.startsWith("session:")) {
return null;
}

try {
const raw = Buffer.from(id.slice("session:".length), "base64url").toString(
"utf8",
);
const parsed = JSON.parse(raw) as {
botId?: unknown;
sessionKey?: unknown;
};
if (
typeof parsed.botId !== "string" ||
parsed.botId.length === 0 ||
typeof parsed.sessionKey !== "string" ||
parsed.sessionKey.length === 0
) {
return null;
}

return {
botId: parsed.botId,
sessionKey: parsed.sessionKey,
};
} catch {
return null;
}
}

function abbreviateOpaqueId(value: string): string {
return value.slice(0, 8).toUpperCase();
}
Expand Down Expand Up @@ -309,7 +349,7 @@ export class SessionsRuntime {
const lastMsg = messages.at(-1);

sessions.push({
id: file.name,
id: encodeSessionId(agentEntry.name, sessionKey),
botId: agentEntry.name,
sessionKey,
channelType: channelType ?? null,
Expand Down Expand Up @@ -904,19 +944,30 @@ export class SessionsRuntime {
}

async getSession(id: string): Promise<SessionResponse | null> {
const decoded = decodeSessionId(id);
if (decoded) {
return this.getSessionByKey(decoded.botId, decoded.sessionKey);
}

const sessions = await this.listSessions();
const legacyMatches = sessions.filter(
(session) => `${session.sessionKey}.jsonl` === id,
);
if (legacyMatches.length === 1) {
return legacyMatches[0] ?? null;
}
return sessions.find((session) => session.id === id) ?? null;
}

private async getSessionByKey(
botId: string,
sessionKey: string,
): Promise<SessionResponse | null> {
const id = `${sessionKey}.jsonl`;
const sessions = await this.listSessions();
return (
sessions.find(
(session) => session.id === id && session.botId === botId,
(session) =>
session.botId === botId && session.sessionKey === sessionKey,
) ?? null
);
}
Expand Down
96 changes: 96 additions & 0 deletions apps/controller/tests/sessions-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,102 @@ describe("SessionsRuntime", () => {
}
});

it("returns unique ids and correct history for duplicate session filenames across bots", async () => {
rootDir = await mkdtemp(path.join(tmpdir(), "nexu-sessions-runtime-"));
const runtime = new SessionsRuntime(
createEnv({
openclawStateDir: rootDir,
openclawConfigPath: path.join(rootDir, "openclaw.json"),
openclawSkillsDir: path.join(rootDir, "skills"),
openclawWorkspaceTemplatesDir: path.join(
rootDir,
"workspace-templates",
),
}),
);

const webSessionsDir = path.join(rootDir, "agents", "bot-web", "sessions");
const wechatSessionsDir = path.join(
rootDir,
"agents",
"bot-wechat",
"sessions",
);
await mkdir(webSessionsDir, { recursive: true });
await mkdir(wechatSessionsDir, { recursive: true });

await writeFile(
path.join(webSessionsDir, "shared.jsonl"),
`${JSON.stringify({
type: "message",
id: "web-msg-1",
timestamp: "2026-04-10T10:00:00.000Z",
message: {
role: "user",
timestamp: Date.parse("2026-04-10T10:00:00.000Z"),
content: "web only",
},
})}
`,
"utf8",
);
await writeFile(
path.join(webSessionsDir, "shared.meta.json"),
JSON.stringify({
title: "Web thread",
channelType: "web",
}),
"utf8",
);

await writeFile(
path.join(wechatSessionsDir, "shared.jsonl"),
`${JSON.stringify({
type: "message",
id: "wechat-msg-1",
timestamp: "2026-04-10T10:01:00.000Z",
message: {
role: "user",
timestamp: Date.parse("2026-04-10T10:01:00.000Z"),
content: "wechat only",
},
})}
`,
"utf8",
);
await writeFile(
path.join(wechatSessionsDir, "shared.meta.json"),
JSON.stringify({
title: "WeChat thread",
channelType: "openclaw-weixin",
}),
"utf8",
);

const sessions = await runtime.listSessions();
const webSession = sessions.find((session) => session.botId === "bot-web");
const wechatSession = sessions.find(
(session) => session.botId === "bot-wechat",
);

expect(webSession).toBeDefined();
expect(wechatSession).toBeDefined();

if (!webSession || !wechatSession) {
throw new Error("expected both repro sessions to exist");
}

expect(webSession.id).not.toBe(wechatSession.id);

const wechatHistory = await runtime.getChatHistory(wechatSession.id);
const webHistory = await runtime.getChatHistory(webSession.id);

expect(wechatHistory.messages).toHaveLength(1);
expect(wechatHistory.messages[0]?.content).toBe("wechat only");
expect(webHistory.messages).toHaveLength(1);
expect(webHistory.messages[0]?.content).toBe("web only");
});

it("merges filesystem metadata into session responses", async () => {
rootDir = await mkdtemp(path.join(tmpdir(), "nexu-sessions-runtime-"));
const runtime = new SessionsRuntime(
Expand Down
Loading
Loading