Skip to content
Merged
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
34 changes: 31 additions & 3 deletions lib/omp/session-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,11 +400,12 @@ export const MAX_SESSION_LOAD_BYTES = 1024 * 1024 * 1024;
* Read a file line by line over a byte buffer. Unlike readFileSync(path,"utf8")
* this never materializes the whole file as a single JS string, so sessions
* past Node's ~512 MiB string cap still open. Lines exclude the newline; the
* decoder carries multi-byte characters across chunk boundaries.
* decoder carries multi-byte characters across chunk boundaries. Returning
* false from onLine stops the read.
*/
export function forEachFileLineSync(
filePath: string,
onLine: (line: string, offset: number, length: number) => void,
onLine: (line: string, offset: number, length: number) => boolean | void,
): void {
const fd = openSync(filePath, "r");
try {
Expand All @@ -425,7 +426,7 @@ export function forEachFileLineSync(
length += end - start;
if (end < bytesRead) {
fragments.push(decoder.end());
onLine(fragments.join(""), offset, length);
if (onLine(fragments.join(""), offset, length) === false) return;
fragments.length = 0;
offset += length + 1;
length = 0;
Expand Down Expand Up @@ -724,6 +725,28 @@ function extractFirstDisplayMessageFromPrefix(content: string): string | undefin
return fallback;
}

// Injected context (rules, skills, memory recall) can push the first user
// message past the 4 KiB prefix; stream lines until one turns up.
function scanFirstUserMessage(filePath: string): string {
let text = "";
try {
forEachFileLineSync(filePath, (line) => {
if (!line.includes('"user"')) return;
try {
const entry = JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } };
if (entry.type !== "message" || entry.message?.role !== "user") return;
text = extractTextFromContent(entry.message.content);
return !text;
} catch {
// Torn or hand-edited line: skip it, as parseJsonlLenient does.
}
});
} catch {
// A line past the JS string cap: keep listing the session without a first message.
}
return text;
}

function extractTextFromContent(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
Expand Down Expand Up @@ -913,6 +936,11 @@ export function scanSessionInfo(filePath: string, withStatus = true): OmpSession
}
}

// Cap at the prefix size: the list ships firstMessage for every session.
// Files past the load ceiling cannot be opened anyway, so skip reading them.
if (!firstMessage && size > SESSION_LIST_PREFIX_BYTES && size <= MAX_SESSION_LOAD_BYTES) {
firstMessage = scanFirstUserMessage(filePath).slice(0, SESSION_LIST_PREFIX_BYTES);
}
firstMessage ||= extractFirstDisplayMessageFromPrefix(content) ?? "";
const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
return {
Expand Down
38 changes: 38 additions & 0 deletions lib/session-reader.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,44 @@ test("links forked children whose parentSession is a bare session id", async ()
});
});

// Rules/skills/memory injections land before the first user message and can
// exceed the 4 KiB list-scan prefix; the sidebar and auto-name need that text.
test("lists the first user message even when injected context precedes it beyond the scan prefix", async () => {
await withAgentDir(async (dir) => {
const injected = { type: "custom_message", customType: "rules", content: "r".repeat(20_000), display: false };
writeSessionFile(dir, "2026-01-01_injected.jsonl", {
id: "injected",
cwd: join(tmpdir(), "omp-web-missing-project"),
timestamp: "2026-01-01T00:00:00.000Z",
}, [injected, userEntry("u1", null, "Integrate and deploy"), assistantEntry("a1", "u1", "ok")]);
// A torn line mentioning "user" must be skipped, not end the scan.
const tornPath = writeSessionFile(dir, "2026-01-02_torn.jsonl", {
id: "torn",
cwd: join(tmpdir(), "omp-web-missing-project"),
timestamp: "2026-01-02T00:00:00.000Z",
}, [injected]);
appendFileSync(tornPath, `{"type":"message","message":{"role":"user" torn\n${JSON.stringify(userEntry("u1", null, "after torn"))}\n`);
writeSessionFile(dir, "2026-01-03_huge.jsonl", {
id: "huge",
cwd: join(tmpdir(), "omp-web-missing-project"),
timestamp: "2026-01-03T00:00:00.000Z",
}, [injected, userEntry("u1", null, "x".repeat(300_000))]);
// No fixed read window: the scan reaches a user entry behind 300 KB of context.
writeSessionFile(dir, "2026-01-04_deep.jsonl", {
id: "deep",
cwd: join(tmpdir(), "omp-web-missing-project"),
timestamp: "2026-01-04T00:00:00.000Z",
}, [...Array(15).fill(injected), userEntry("u1", null, "deep prompt")]);

const byId = new Map((await listAllSessions()).map((s) => [s.id, s]));
assert.equal(byId.get("injected")?.firstMessage, "Integrate and deploy");
assert.equal(byId.get("torn")?.firstMessage, "after torn");
// The list ships firstMessage for every session, so it stays bounded.
assert.equal(byId.get("huge")?.firstMessage, "x".repeat(4096));
assert.equal(byId.get("deep")?.firstMessage, "deep prompt");
});
});

test("stops resolving a session path once the file is deleted", async () => {
await withAgentDir(async (dir) => {
const filePath = writeSessionFile(dir, "2026-01-01_doomed.jsonl", {
Expand Down
Loading