Skip to content

Commit abc9c16

Browse files
committed
fix(sessions): route async session-message-count through the same archive fallback as readers
Codex/clawsweeper review on PR #76134 found that `readRecentSessionMessagesWithStatsAsync` still derived `totalMessages` from `readSessionMessageCountAsync`, which kept the active-only `findExistingTranscriptPath` resolution. After the new async reader fallback, an archive-only transcript longer than the bounded tail window therefore returned the newest messages with low `seq` values and `hasMore = false`. The sessions-history HTTP first page passes `boundedSnapshot.totalMessages` into `buildSessionHistorySnapshot`, so a `nextCursor` follow-up could skip the middle archive page. This commit routes the async count and visit helpers (`readSessionMessageCountAsync` and `visitSessionMessagesAsync`) through the same `findActiveOrLatestResetArchiveAsync` resolution that the message readers use, so an archive-only transcript reports a consistent total, gives messages the right tail-end `seq` values, and exposes the archive's earlier pages via `nextCursor`. Behavior boundaries (kept narrow, same as the original PR): - Active wins when present (no active+archive merging). - Only the single newest archive is returned (no chain aggregation). - Sync paths, `findExistingTranscriptPath`, the protocol schema, the `includeArchived` opt-in, sessions_history schema, the session-memory hook, and the session-logs skill are still untouched. Tests: 4 new cases in `session-utils.fs.test.ts` cover the count and stats helpers (active-only, archive-only with archive longer than the tail window, both-exist active-priority, both-missing) and assert the returned `seq` metadata matches the archive's tail position. One new case in `sessions-history-http.test.ts` exercises the archive-only HTTP pagination path: archive a 3-message transcript, request the first `limit=2` page, follow `nextCursor`, and assert the older archive message is visible without skipping middle pages. Refs #56131 #43929 #45003 #60409 #73883
1 parent fa7b0dc commit abc9c16

3 files changed

Lines changed: 158 additions & 2 deletions

File tree

src/gateway/session-utils.fs.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,6 +2188,96 @@ describe("async archive fallback (missing-primary auto-fallback)", () => {
21882188
const out = await readSessionTitleFieldsFromTranscriptAsync(sessionId, storePath);
21892189
expect(out).toEqual({ firstUserMessage: null, lastMessagePreview: null });
21902190
});
2191+
2192+
test("readSessionMessageCountAsync returns archive count when primary is missing", async () => {
2193+
const sessionId = "fallback-count-archive-only";
2194+
const primaryPath = path.join(tmpDir, `${sessionId}.jsonl`);
2195+
writeJsonlLines(archivePath(primaryPath, Date.parse("2026-04-01T00:00:00.000Z")), [
2196+
{ type: "session", version: 1, id: sessionId },
2197+
{ message: { role: "user", content: "u1" } },
2198+
{ message: { role: "assistant", content: "a1" } },
2199+
{ message: { role: "user", content: "u2" } },
2200+
{ message: { role: "assistant", content: "a2" } },
2201+
{ message: { role: "user", content: "u3" } },
2202+
]);
2203+
2204+
expect(await readSessionMessageCountAsync(sessionId, storePath)).toBe(5);
2205+
});
2206+
2207+
test("readSessionMessageCountAsync prefers active count when both exist", async () => {
2208+
const sessionId = "fallback-count-active-priority";
2209+
const primaryPath = path.join(tmpDir, `${sessionId}.jsonl`);
2210+
writeJsonlLines(primaryPath, [
2211+
{ type: "session", version: 1, id: sessionId },
2212+
{ message: { role: "user", content: "active 1" } },
2213+
{ message: { role: "assistant", content: "active 2" } },
2214+
]);
2215+
writeJsonlLines(archivePath(primaryPath, Date.parse("2026-04-01T00:00:00.000Z")), [
2216+
{ type: "session", version: 1, id: sessionId },
2217+
{ message: { role: "user", content: "archived 1" } },
2218+
{ message: { role: "assistant", content: "archived 2" } },
2219+
{ message: { role: "user", content: "archived 3" } },
2220+
{ message: { role: "assistant", content: "archived 4" } },
2221+
]);
2222+
2223+
expect(await readSessionMessageCountAsync(sessionId, storePath)).toBe(2);
2224+
});
2225+
2226+
test("readRecentSessionMessagesWithStatsAsync reports archive totals and consistent seq for archive-only transcripts", async () => {
2227+
const sessionId = "fallback-stats-archive-only";
2228+
const primaryPath = path.join(tmpDir, `${sessionId}.jsonl`);
2229+
writeJsonlLines(archivePath(primaryPath, Date.parse("2026-04-01T00:00:00.000Z")), [
2230+
{ type: "session", version: 1, id: sessionId },
2231+
{ message: { role: "user", content: "u1" } },
2232+
{ message: { role: "assistant", content: "a1" } },
2233+
{ message: { role: "user", content: "u2" } },
2234+
{ message: { role: "assistant", content: "a2" } },
2235+
{ message: { role: "user", content: "u3" } },
2236+
]);
2237+
2238+
const result = await readRecentSessionMessagesWithStatsAsync(sessionId, storePath, undefined, {
2239+
maxMessages: 2,
2240+
});
2241+
2242+
expect(result.totalMessages).toBe(5);
2243+
expect(result.messages).toHaveLength(2);
2244+
expect(result.messages.map((m) => (m as { content?: unknown }).content)).toEqual(["a2", "u3"]);
2245+
expect(
2246+
result.messages.map((m) => (m as { __openclaw?: { seq?: number } }).__openclaw?.seq),
2247+
).toEqual([4, 5]);
2248+
});
2249+
2250+
test("readRecentSessionMessagesWithStatsAsync prefers active totals + seq when active exists", async () => {
2251+
const sessionId = "fallback-stats-active-priority";
2252+
const primaryPath = path.join(tmpDir, `${sessionId}.jsonl`);
2253+
writeJsonlLines(primaryPath, [
2254+
{ type: "session", version: 1, id: sessionId },
2255+
{ message: { role: "user", content: "active u1" } },
2256+
{ message: { role: "assistant", content: "active a1" } },
2257+
{ message: { role: "user", content: "active u2" } },
2258+
]);
2259+
writeJsonlLines(archivePath(primaryPath, Date.parse("2026-04-01T00:00:00.000Z")), [
2260+
{ type: "session", version: 1, id: sessionId },
2261+
{ message: { role: "user", content: "archived u1" } },
2262+
{ message: { role: "assistant", content: "archived a1" } },
2263+
{ message: { role: "user", content: "archived u2" } },
2264+
{ message: { role: "assistant", content: "archived a2" } },
2265+
{ message: { role: "user", content: "archived u3" } },
2266+
]);
2267+
2268+
const result = await readRecentSessionMessagesWithStatsAsync(sessionId, storePath, undefined, {
2269+
maxMessages: 2,
2270+
});
2271+
2272+
expect(result.totalMessages).toBe(3);
2273+
expect(result.messages.map((m) => (m as { content?: unknown }).content)).toEqual([
2274+
"active a1",
2275+
"active u2",
2276+
]);
2277+
expect(
2278+
result.messages.map((m) => (m as { __openclaw?: { seq?: number } }).__openclaw?.seq),
2279+
).toEqual([2, 3]);
2280+
});
21912281
});
21922282

21932283
describe("archiveSessionTranscripts", () => {

src/gateway/session-utils.fs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ export async function visitSessionMessagesAsync(
573573
visit: (message: unknown, seq: number) => void,
574574
_opts: { mode: "full"; reason: string },
575575
): Promise<number> {
576-
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile);
576+
const filePath = await findActiveOrLatestResetArchiveAsync(sessionId, storePath, sessionFile);
577577
if (!filePath) {
578578
return 0;
579579
}
@@ -595,7 +595,7 @@ export async function readSessionMessageCountAsync(
595595
storePath: string | undefined,
596596
sessionFile?: string,
597597
): Promise<number> {
598-
const filePath = findExistingTranscriptPath(sessionId, storePath, sessionFile);
598+
const filePath = await findActiveOrLatestResetArchiveAsync(sessionId, storePath, sessionFile);
599599
if (!filePath) {
600600
return 0;
601601
}

src/gateway/sessions-history-http.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
appendExactAssistantMessageToSessionTranscript,
99
} from "../config/sessions/transcript.js";
1010
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
11+
import { archiveSessionTranscripts } from "./session-utils.js";
1112
import { testState } from "./test-helpers.runtime-state.js";
1213
import {
1314
connectReq,
@@ -452,6 +453,71 @@ describe("session history HTTP endpoints", () => {
452453
});
453454
});
454455

456+
test("paginates archive-only transcripts without skipping middle pages on nextCursor", async () => {
457+
const { storePath } = await seedSession({ text: "first message" });
458+
const second = await appendAssistantMessageToSessionTranscript({
459+
sessionKey: "agent:main:main",
460+
text: "second message",
461+
storePath,
462+
});
463+
expect(second.ok).toBe(true);
464+
const third = await appendAssistantMessageToSessionTranscript({
465+
sessionKey: "agent:main:main",
466+
text: "third message",
467+
storePath,
468+
});
469+
expect(third.ok).toBe(true);
470+
471+
// Reset rotates the active <id>.jsonl into a sibling <id>.jsonl.reset.<ts>
472+
// archive and leaves no active file. Without the missing-active async
473+
// fallback the bounded first page would compute totalMessages from the
474+
// missing active and assign cursor sequence numbers that skip the middle
475+
// archive page when the client follows nextCursor.
476+
const archived = archiveSessionTranscripts({
477+
sessionId: "sess-main",
478+
storePath,
479+
reason: "reset",
480+
});
481+
expect(archived).toHaveLength(1);
482+
483+
await withGatewayHarness(async (harness) => {
484+
const firstPage = await fetchSessionHistory(harness.port, "agent:main:main", {
485+
query: "?limit=2",
486+
});
487+
expect(firstPage.status).toBe(200);
488+
const firstBody = (await firstPage.json()) as {
489+
items?: Array<{ content?: Array<{ text?: string }>; __openclaw?: { seq?: number } }>;
490+
messages?: Array<{ __openclaw?: { seq?: number } }>;
491+
nextCursor?: string;
492+
hasMore?: boolean;
493+
};
494+
expect(firstBody.items?.map((message) => message.content?.[0]?.text)).toEqual([
495+
"second message",
496+
"third message",
497+
]);
498+
expect(firstBody.messages?.map((message) => message.__openclaw?.seq)).toEqual([2, 3]);
499+
expect(firstBody.hasMore).toBe(true);
500+
expect(firstBody.nextCursor).toBe("2");
501+
502+
const secondPage = await fetchSessionHistory(harness.port, "agent:main:main", {
503+
query: `?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor ?? "")}`,
504+
});
505+
expect(secondPage.status).toBe(200);
506+
const secondBody = (await secondPage.json()) as {
507+
items?: Array<{ content?: Array<{ text?: string }>; __openclaw?: { seq?: number } }>;
508+
messages?: Array<{ __openclaw?: { seq?: number } }>;
509+
nextCursor?: string;
510+
hasMore?: boolean;
511+
};
512+
expect(secondBody.items?.map((message) => message.content?.[0]?.text)).toEqual([
513+
"first message",
514+
]);
515+
expect(secondBody.messages?.map((message) => message.__openclaw?.seq)).toEqual([1]);
516+
expect(secondBody.hasMore).toBe(false);
517+
expect(secondBody.nextCursor).toBeUndefined();
518+
});
519+
});
520+
455521
test("streams bounded history windows over SSE", async () => {
456522
const { storePath } = await seedSession({ text: "first message" });
457523

0 commit comments

Comments
 (0)