fix: prefer freshest duplicate session rows in reads

This commit is contained in:
Tak Hoffman
2026-03-24 22:26:15 -05:00
parent 93656da672
commit 40f820ff7f
5 changed files with 137 additions and 3 deletions

View File

@@ -55,6 +55,7 @@ import {
loadSessionEntry,
migrateAndPruneGatewaySessionStoreKey,
readSessionPreviewItemsFromTranscript,
resolveFreshestSessionEntryFromStoreKeys,
resolveGatewaySessionStoreTarget,
resolveSessionModelRef,
resolveSessionTranscriptCandidates,
@@ -583,7 +584,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
key,
store,
});
const entry = target.storeKeys.map((candidate) => store[candidate]).find(Boolean);
const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys);
if (!entry?.sessionId) {
previews.push({ key, status: "missing", items: [] });
continue;
@@ -1027,7 +1028,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const { target, storePath } = resolveGatewaySessionTargetFromKey(key);
const store = loadSessionStore(storePath);
const entry = target.storeKeys.map((k) => store[k]).find(Boolean);
const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys);
if (!entry?.sessionId) {
respond(true, { messages: [] }, undefined);
return;

View File

@@ -1014,6 +1014,55 @@ describe("gateway server sessions", () => {
ws.close();
});
test("sessions.preview prefers the freshest duplicate row for a legacy mixed-case main alias", async () => {
const { dir, storePath } = await createSessionStoreDir();
testState.agentsConfig = { list: [{ id: "ops", default: true }] };
testState.sessionConfig = { mainKey: "work" };
const staleTranscriptPath = path.join(dir, "sess-stale-main.jsonl");
const freshTranscriptPath = path.join(dir, "sess-fresh-main.jsonl");
await fs.writeFile(
staleTranscriptPath,
[
JSON.stringify({ type: "session", version: 1, id: "sess-stale-main" }),
JSON.stringify({ message: { role: "assistant", content: "stale preview" } }),
].join("\n"),
"utf-8",
);
await fs.writeFile(
freshTranscriptPath,
[
JSON.stringify({ type: "session", version: 1, id: "sess-fresh-main" }),
JSON.stringify({ message: { role: "assistant", content: "fresh preview" } }),
].join("\n"),
"utf-8",
);
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:ops:work": {
sessionId: "sess-stale-main",
updatedAt: 1,
},
"agent:ops:WORK": {
sessionId: "sess-fresh-main",
updatedAt: 2,
},
},
null,
2,
),
"utf-8",
);
const { ws } = await openClient();
const entry = await getMainPreviewEntry(ws);
expect(entry?.items[0]?.text).toContain("fresh preview");
ws.close();
});
test("sessions.resolve and mutators clean legacy main-alias ghost keys", async () => {
const { dir, storePath } = await createSessionStoreDir();
testState.agentsConfig = { list: [{ id: "ops", default: true }] };

View File

@@ -367,6 +367,22 @@ export function loadSessionEntry(sessionKey: string) {
return { cfg, storePath, store, entry: match?.entry, canonicalKey, legacyKey };
}
export function resolveFreshestSessionEntryFromStoreKeys(
store: Record<string, SessionEntry>,
storeKeys: string[],
): SessionEntry | undefined {
const matches = storeKeys
.map((key) => store[key])
.filter((entry): entry is SessionEntry => Boolean(entry));
if (matches.length === 0) {
return undefined;
}
if (matches.length === 1) {
return matches[0];
}
return [...matches].toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
}
/**
* Find a session entry by exact or case-insensitive key match.
* Returns both the entry and the actual store key it was found under,

View File

@@ -144,6 +144,73 @@ describe("session history HTTP endpoints", () => {
}
});
test("prefers the freshest duplicate row for direct history reads", async () => {
const storePath = await createSessionStoreFile();
const dir = path.dirname(storePath);
const staleTranscriptPath = path.join(dir, "sess-stale-main.jsonl");
const freshTranscriptPath = path.join(dir, "sess-fresh-main.jsonl");
await fs.writeFile(
staleTranscriptPath,
[
JSON.stringify({ type: "session", version: 1, id: "sess-stale-main" }),
JSON.stringify({
message: { role: "assistant", content: [{ type: "text", text: "stale history" }] },
}),
].join("\n"),
"utf-8",
);
await fs.writeFile(
freshTranscriptPath,
[
JSON.stringify({ type: "session", version: 1, id: "sess-fresh-main" }),
JSON.stringify({
message: { role: "assistant", content: [{ type: "text", text: "fresh history" }] },
}),
].join("\n"),
"utf-8",
);
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:main": {
sessionId: "sess-stale-main",
sessionFile: staleTranscriptPath,
updatedAt: 1,
},
"agent:main:MAIN": {
sessionId: "sess-fresh-main",
sessionFile: freshTranscriptPath,
updatedAt: 2,
},
},
null,
2,
),
"utf-8",
);
const harness = await createGatewaySuiteHarness();
try {
const res = await fetch(
`http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history`,
{
headers: AUTH_HEADER,
},
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
sessionKey?: string;
messages?: Array<{ content?: Array<{ text?: string }> }>;
};
expect(body.sessionKey).toBe("agent:main:main");
expect(body.messages?.[0]?.content?.[0]?.text).toBe("fresh history");
} finally {
await harness.close();
}
});
test("supports cursor pagination over direct REST while preserving the messages field", async () => {
const { storePath } = await seedSession({ text: "first message" });
const second = await appendAssistantMessageToSessionTranscript({

View File

@@ -17,6 +17,7 @@ import { getHeader } from "./http-utils.js";
import {
attachOpenClawTranscriptMeta,
readSessionMessages,
resolveFreshestSessionEntryFromStoreKeys,
resolveGatewaySessionStoreTarget,
resolveSessionTranscriptCandidates,
} from "./session-utils.js";
@@ -168,7 +169,7 @@ export async function handleSessionHistoryHttpRequest(
const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey });
const store = loadSessionStore(target.storePath);
const entry = target.storeKeys.map((key) => store[key]).find(Boolean);
const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys);
if (!entry?.sessionId) {
sendJson(res, 404, {
ok: false,