


























@@ -0,0 +1,176 @@
1+import fs from "node:fs";
2+import os from "node:os";
3+import path from "node:path";
4+import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+import {
6+readSessionTranscriptTailLines,
7+streamSessionTranscriptLines,
8+} from "./transcript-stream.js";
9+10+// Regression coverage for #54296: the transcript readers must stay correct and
11+// memory-bounded as session files grow into the multi-MB / 100s of MB range.
12+// The previous implementations called `fs.readFile` and split on newlines,
13+// which made memory usage scale with file size. These tests exercise the
14+// shared streaming helpers that replace those whole-file reads.
15+16+let tempDir = "";
17+let transcriptPath = "";
18+19+beforeEach(() => {
20+tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-stream-"));
21+transcriptPath = path.join(tempDir, "session.jsonl");
22+});
23+24+afterEach(() => {
25+fs.rmSync(tempDir, { recursive: true, force: true });
26+});
27+28+async function collect(iter: AsyncGenerator<string>): Promise<string[]> {
29+const out: string[] = [];
30+for await (const value of iter) {
31+out.push(value);
32+}
33+return out;
34+}
35+36+describe("streamSessionTranscriptLines", () => {
37+it("yields trimmed non-empty lines in file order", async () => {
38+fs.writeFileSync(transcriptPath, " alpha \n\nbeta\n \r\ngamma\n", "utf-8");
39+40+const lines = await collect(streamSessionTranscriptLines(transcriptPath));
41+42+expect(lines).toEqual(["alpha", "beta", "gamma"]);
43+});
44+45+it("returns an empty iterator when the file does not exist", async () => {
46+const lines = await collect(streamSessionTranscriptLines(path.join(tempDir, "missing.jsonl")));
47+48+expect(lines).toEqual([]);
49+});
50+51+it("returns an empty iterator for an empty file", async () => {
52+fs.writeFileSync(transcriptPath, "", "utf-8");
53+54+const lines = await collect(streamSessionTranscriptLines(transcriptPath));
55+56+expect(lines).toEqual([]);
57+});
58+59+it("forwards malformed JSON lines as raw text so callers can choose to skip them", async () => {
60+fs.writeFileSync(
61+transcriptPath,
62+`${JSON.stringify({ id: "a" })}\nnot-json\n${JSON.stringify({ id: "b" })}\n`,
63+"utf-8",
64+);
65+66+const lines = await collect(streamSessionTranscriptLines(transcriptPath));
67+68+expect(lines).toEqual([JSON.stringify({ id: "a" }), "not-json", JSON.stringify({ id: "b" })]);
69+});
70+71+it("honours an abort signal between lines", async () => {
72+fs.writeFileSync(transcriptPath, "one\ntwo\nthree\n", "utf-8");
73+const controller = new AbortController();
74+75+const out: string[] = [];
76+for await (const line of streamSessionTranscriptLines(transcriptPath, {
77+signal: controller.signal,
78+})) {
79+out.push(line);
80+if (line === "one") {
81+controller.abort();
82+}
83+}
84+85+expect(out).toEqual(["one"]);
86+});
87+88+it("preserves long lines without truncation", async () => {
89+const longLine = "x".repeat(64 * 1024 + 7);
90+fs.writeFileSync(transcriptPath, `${longLine}\nshort\n`, "utf-8");
91+92+const lines = await collect(streamSessionTranscriptLines(transcriptPath));
93+94+expect(lines).toEqual([longLine, "short"]);
95+});
96+});
97+98+describe("readSessionTranscriptTailLines", () => {
99+it("returns trimmed non-empty lines in reverse order for short files", async () => {
100+fs.writeFileSync(transcriptPath, "first\nsecond\nthird\n", "utf-8");
101+102+const lines = await readSessionTranscriptTailLines(transcriptPath);
103+104+expect(lines).toEqual(["third", "second", "first"]);
105+});
106+107+it("returns undefined when the file cannot be opened", async () => {
108+const lines = await readSessionTranscriptTailLines(path.join(tempDir, "missing.jsonl"));
109+110+expect(lines).toBeUndefined();
111+});
112+113+it("returns an empty array for an empty file", async () => {
114+fs.writeFileSync(transcriptPath, "", "utf-8");
115+116+const lines = await readSessionTranscriptTailLines(transcriptPath);
117+118+expect(lines).toEqual([]);
119+});
120+121+it("drops the leading partial line when the window does not start at byte zero", async () => {
122+// Build a file longer than the requested tail window. The first line of
123+// the slice we end up reading should be a suffix of an earlier line; the
124+// helper must discard it so callers do not see corrupt JSON.
125+const longPrefix = "x".repeat(2048);
126+const content = `${longPrefix}\nbeta\ngamma\n`;
127+fs.writeFileSync(transcriptPath, content, "utf-8");
128+129+const lines = await readSessionTranscriptTailLines(transcriptPath, {
130+maxBytes: 16,
131+});
132+133+expect(lines).not.toContain(longPrefix);
134+expect(lines).toEqual(["gamma", "beta"]);
135+});
136+137+it("does not drop the first line when the window covers the entire file", async () => {
138+fs.writeFileSync(transcriptPath, "alpha\nbeta\ngamma\n", "utf-8");
139+140+const lines = await readSessionTranscriptTailLines(transcriptPath, {
141+maxBytes: 64 * 1024,
142+});
143+144+expect(lines).toEqual(["gamma", "beta", "alpha"]);
145+});
146+147+it("clamps a sub-minimum maxBytes to the floor instead of returning nothing", async () => {
148+fs.writeFileSync(transcriptPath, "alpha\nbeta\ngamma\n", "utf-8");
149+150+const lines = await readSessionTranscriptTailLines(transcriptPath, {
151+maxBytes: 16,
152+});
153+154+// The full file is smaller than the 1 KiB floor, so we still read the
155+// whole file and return all three lines in reverse order.
156+expect(lines).toEqual(["gamma", "beta", "alpha"]);
157+});
158+159+it("preserves JSONL line ordering so reverse scans hit the newest match first", async () => {
160+fs.writeFileSync(
161+transcriptPath,
162+[
163+JSON.stringify({ id: "first", role: "user" }),
164+JSON.stringify({ id: "second", role: "assistant", text: "hi" }),
165+JSON.stringify({ id: "third", role: "assistant", text: "bye" }),
166+].join("\n") + "\n",
167+"utf-8",
168+);
169+170+const lines = await readSessionTranscriptTailLines(transcriptPath);
171+172+expect(lines).toBeDefined();
173+const parsed = lines!.map((line) => JSON.parse(line) as { id: string });
174+expect(parsed.map((entry) => entry.id)).toEqual(["third", "second", "first"]);
175+});
176+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。