agent-yes 1.287.0 → 1.288.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{SUPPORTED_CLIS-DKdFELZL.js → SUPPORTED_CLIS-BHowMgMY.js} +2 -2
- package/dist/{SUPPORTED_CLIS-5YPYQ8Iw.js → SUPPORTED_CLIS-ChhJyq4h.js} +3 -3
- package/dist/{agentShare-B525ly6v.js → agentShare-T3RskvDT.js} +2 -2
- package/dist/{callback-C-awM4qn.js → callback-CuohbCJA.js} +3 -3
- package/dist/{callback-BLTW4Mgp.js → callback-DeaETuHt.js} +2 -2
- package/dist/cli.js +5 -5
- package/dist/{cmdPs-BRtmnpsM.js → cmdPs-Bm4JG7hi.js} +2 -2
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-DshQvO-4.js → notifyDaemon-BORqPeAl.js} +2 -2
- package/dist/{parentPingLoop-Bjw6hKoZ.js → parentPingLoop-COePRYr9.js} +2 -2
- package/dist/{rustBinary-C57KWEtX.js → rustBinary-BmXzqj5z.js} +2 -2
- package/dist/{rustBinary-BXhp8yBp.js → rustBinary-D12k7iOD.js} +2 -2
- package/dist/{schedule-Boa0nE0V.js → schedule-DLoUmyJh.js} +4 -4
- package/dist/{serve-7mcHebep.js → serve-DaDlOtss.js} +17 -17
- package/dist/{setup-DXRL-Mdo.js → setup-Cqy8nRrq.js} +2 -2
- package/dist/{subcommands-BftthBK7.js → subcommands-BfiOyfz7.js} +15 -15
- package/dist/{subcommands-BQJ-Uqtl.js → subcommands-DKTa3E6O.js} +1 -1
- package/dist/{terminal-DrYf3HP0.js → terminal-C0tnGnrZ.js} +2 -2
- package/dist/trayApp-BrAMlCY7.js +5 -0
- package/dist/{trayApp-DG2fQnhx.js → trayApp-DvL9qW6V.js} +2 -2
- package/dist/{ts-LDnoqCV2.js → ts-DJLp2_bv.js} +3 -3
- package/dist/{versionChecker-CGmyt2qE.js → versionChecker-CkO7wYyV.js} +2 -2
- package/dist/{widget-B2SluyG1.js → widget-CJqpScCG.js} +3 -3
- package/dist/{ws-DWA90-uE.js → ws-DGENGNrr.js} +2 -2
- package/dist/{ws-CTOnVXg8.js → ws-DzCKvzWD.js} +2 -2
- package/lab/ui/index.html +95 -67
- package/package.json +2 -1
- package/ts/JsonlStore.bun.spec.ts +204 -0
- package/ts/ReadyManager.bun.spec.ts +72 -0
- package/ts/agentNice.bun.spec.ts +24 -0
- package/ts/agentPermissions.bun.spec.ts +117 -0
- package/ts/agentShare.bun.spec.ts +240 -0
- package/ts/agentTree.bun.spec.ts +92 -0
- package/ts/agentYesHome.bun.spec.ts +25 -0
- package/ts/askApi.bun.spec.ts +358 -0
- package/ts/askCli.bun.spec.ts +304 -0
- package/ts/autoRetry.bun.spec.ts +123 -0
- package/ts/badges.bun.spec.ts +187 -0
- package/ts/buildRustArgs.bun.spec.ts +351 -0
- package/ts/callbackCore.bun.spec.ts +139 -0
- package/ts/channels/hlc.bun.spec.ts +56 -0
- package/ts/channels/link.bun.spec.ts +72 -0
- package/ts/channels/op.bun.spec.ts +78 -0
- package/ts/channels/store.browser.bun.spec.ts +60 -0
- package/ts/channels/store.bun.spec.ts +145 -0
- package/ts/channels/store.node.bun.spec.ts +76 -0
- package/ts/channels/trust.bun.spec.ts +86 -0
- package/ts/channels.bun.spec.ts +212 -0
- package/ts/cli-idle.bun.spec.ts +20 -0
- package/ts/cmdDsh.bun.spec.ts +90 -0
- package/ts/configLoader.bun.spec.ts +273 -0
- package/ts/cwdConflictWarn.bun.spec.ts +60 -0
- package/ts/cwdPassthroughHint.bun.spec.ts +74 -0
- package/ts/e2e-crypto.bun.spec.ts +235 -0
- package/ts/forkNested.bun.spec.ts +240 -0
- package/ts/histStore.bun.spec.ts +355 -0
- package/ts/identity.bun.spec.ts +190 -0
- package/ts/idleWaiter.bun.spec.ts +91 -0
- package/ts/initMsg.bun.spec.ts +132 -0
- package/ts/invokedCli.bun.spec.ts +30 -0
- package/ts/ipcLock.bun.spec.ts +107 -0
- package/ts/logger.bun.spec.ts +27 -0
- package/ts/lsWatch.bun.spec.ts +61 -0
- package/ts/messageLog.bun.spec.ts +145 -0
- package/ts/needsInput.bun.spec.ts +157 -0
- package/ts/nodeRuntime.bun.spec.ts +99 -0
- package/ts/notifyDaemon.bun.spec.ts +297 -0
- package/ts/notifyInbox.bun.spec.ts +277 -0
- package/ts/notifyRouter.bun.spec.ts +351 -0
- package/ts/notifyStore.bun.spec.ts +403 -0
- package/ts/ownerHeartbeat.bun.spec.ts +175 -0
- package/ts/parentLink.bun.spec.ts +64 -0
- package/ts/parentPing.bun.spec.ts +176 -0
- package/ts/parentPingLoop.bun.spec.ts +79 -0
- package/ts/parentPingSend.bun.spec.ts +195 -0
- package/ts/parentWatching.bun.spec.ts +61 -0
- package/ts/pidStore.bun.spec.ts +372 -0
- package/ts/procStartTime.bun.spec.ts +117 -0
- package/ts/reaper.bun.spec.ts +86 -0
- package/ts/removeControlCharacters.bun.spec.ts +94 -0
- package/ts/resolveBinary.bun.spec.ts +133 -0
- package/ts/resultEnvelope.bun.spec.ts +46 -0
- package/ts/runningLock.bun.spec.ts +485 -0
- package/ts/rustBinary.bun.spec.ts +81 -0
- package/ts/schedule.bun.spec.ts +30 -0
- package/ts/serve.bun.spec.ts +166 -0
- package/ts/serveLock.bun.spec.ts +220 -0
- package/ts/session-integration.bun.spec.ts +93 -0
- package/ts/setup.bun.spec.ts +42 -0
- package/ts/share.bun.spec.ts +36 -0
- package/ts/sizeNego.bun.spec.ts +56 -0
- package/ts/statusText.bun.spec.ts +17 -0
- package/ts/strayServe.bun.spec.ts +50 -0
- package/ts/subcommandMirror.bun.spec.ts +76 -0
- package/ts/systemPathLink.bun.spec.ts +79 -0
- package/ts/termToken.bun.spec.ts +74 -0
- package/ts/terminal.bun.spec.ts +50 -0
- package/ts/terminalReply.bun.spec.ts +36 -0
- package/ts/titleScanner.bun.spec.ts +65 -0
- package/ts/todoAutomation.bun.spec.ts +253 -0
- package/ts/todoBlock.bun.spec.ts +73 -0
- package/ts/todoDigest.bun.spec.ts +184 -0
- package/ts/todoIdentity.bun.spec.ts +85 -0
- package/ts/todoLifecycle.bun.spec.ts +79 -0
- package/ts/todoParse.bun.spec.ts +98 -0
- package/ts/todoRoot.bun.spec.ts +117 -0
- package/ts/todoStore.bun.spec.ts +727 -0
- package/ts/tryCatch.bun.spec.ts +275 -0
- package/ts/webrtcLink.bun.spec.ts +45 -0
- package/ts/workspaceConfig.bun.spec.ts +319 -0
- package/dist/trayApp-1AQ6X2V9.js +0 -5
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import {
|
|
6
|
+
discoverTranscripts,
|
|
7
|
+
encodeProjectSlug,
|
|
8
|
+
histPage,
|
|
9
|
+
projectRecord,
|
|
10
|
+
readCodexCwd,
|
|
11
|
+
readLinesBackward,
|
|
12
|
+
tailTranscript,
|
|
13
|
+
type TranscriptFile,
|
|
14
|
+
} from "./histStore.ts";
|
|
15
|
+
|
|
16
|
+
// histStore takes `home` explicitly rather than calling homedir(), so these
|
|
17
|
+
// tests point it at a tempdir instead of mocking the os module.
|
|
18
|
+
let home: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
home = await mkdtemp(path.join(tmpdir(), "agent-yes-hist-"));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await rm(home, { recursive: true, force: true }).catch(() => null);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const CWD = "/code/snomiao/agent-yes/tree/main";
|
|
29
|
+
|
|
30
|
+
function claudeTurn(role: "user" | "assistant", text: string, ts: string, extra = {}) {
|
|
31
|
+
return JSON.stringify({
|
|
32
|
+
type: role,
|
|
33
|
+
timestamp: ts,
|
|
34
|
+
cwd: CWD,
|
|
35
|
+
message: { role, content: [{ type: "text", text }] },
|
|
36
|
+
...extra,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function codexEvent(kind: "user_message" | "agent_message", message: string, ts: string) {
|
|
41
|
+
return JSON.stringify({ timestamp: ts, type: "event_msg", payload: { type: kind, message } });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function writeClaudeSession(session: string, lines: string[], cwd = CWD) {
|
|
45
|
+
const dir = path.join(home, ".claude", "projects", encodeProjectSlug(cwd));
|
|
46
|
+
await mkdir(dir, { recursive: true });
|
|
47
|
+
const file = path.join(dir, `${session}.jsonl`);
|
|
48
|
+
await writeFile(file, lines.length ? lines.join("\n") + "\n" : "");
|
|
49
|
+
return file;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function writeCodexSession(session: string, lines: string[], cwd = CWD) {
|
|
53
|
+
const dir = path.join(home, ".codex", "sessions", "2026", "08", "03");
|
|
54
|
+
await mkdir(dir, { recursive: true });
|
|
55
|
+
const file = path.join(dir, `rollout-${session}.jsonl`);
|
|
56
|
+
const meta = JSON.stringify({
|
|
57
|
+
timestamp: "2026-08-03T00:00:00.000Z",
|
|
58
|
+
type: "session_meta",
|
|
59
|
+
payload: { session_id: session, cwd },
|
|
60
|
+
});
|
|
61
|
+
await writeFile(file, [meta, ...lines].join("\n") + "\n");
|
|
62
|
+
return file;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asFile(p: string, source: "claude" | "codex"): TranscriptFile {
|
|
66
|
+
return { path: p, source, sessionId: path.basename(p, ".jsonl"), mtimeMs: 0, size: 0 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("encodeProjectSlug", () => {
|
|
70
|
+
it("matches Claude's cwd-to-directory encoding", () => {
|
|
71
|
+
expect(encodeProjectSlug("/code/snomiao/agent-yes/tree/main")).toBe(
|
|
72
|
+
"-code-snomiao-agent-yes-tree-main",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("collapses dots, as Claude does for domain-shaped directories", () => {
|
|
77
|
+
expect(encodeProjectSlug("/code/snomiao/cv.snomiao.com/tree/main")).toBe(
|
|
78
|
+
"-code-snomiao-cv-snomiao-com-tree-main",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("readLinesBackward", () => {
|
|
84
|
+
it("yields lines newest-first with correct byte offsets", async () => {
|
|
85
|
+
const file = path.join(home, "a.jsonl");
|
|
86
|
+
await writeFile(file, "one\ntwo\nthree\n");
|
|
87
|
+
const got = [];
|
|
88
|
+
for await (const l of readLinesBackward(file)) got.push(l);
|
|
89
|
+
expect(got.map((g) => g.text)).toEqual(["three", "two", "one"]);
|
|
90
|
+
expect(got.map((g) => g.offset)).toEqual([8, 4, 0]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("reassembles lines that straddle chunk boundaries", async () => {
|
|
94
|
+
const file = path.join(home, "big.jsonl");
|
|
95
|
+
const lines = Array.from({ length: 200 }, (_, i) => `line-${i}-${"x".repeat(i % 37)}`);
|
|
96
|
+
await writeFile(file, lines.join("\n") + "\n");
|
|
97
|
+
const got = [];
|
|
98
|
+
// A chunk far smaller than the file forces the carry-over path repeatedly.
|
|
99
|
+
for await (const l of readLinesBackward(file, { chunkSize: 16 })) got.push(l.text);
|
|
100
|
+
expect(got).toEqual([...lines].reverse());
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("handles a file with no trailing newline", async () => {
|
|
104
|
+
const file = path.join(home, "b.jsonl");
|
|
105
|
+
await writeFile(file, "one\ntwo");
|
|
106
|
+
const got = [];
|
|
107
|
+
for await (const l of readLinesBackward(file)) got.push(l.text);
|
|
108
|
+
// "two" is unterminated, so it is treated as a partial write and skipped.
|
|
109
|
+
expect(got).toEqual(["one"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("drops a half-written trailing record from a live session", async () => {
|
|
113
|
+
const file = path.join(home, "live.jsonl");
|
|
114
|
+
await writeFile(file, `${claudeTurn("user", "done", "2026-08-01T00:00:00Z")}\n{"type":"assi`);
|
|
115
|
+
const got = [];
|
|
116
|
+
for await (const l of readLinesBackward(file)) got.push(l.text);
|
|
117
|
+
expect(got).toHaveLength(1);
|
|
118
|
+
expect(got[0]).toContain("done");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reads only what the consumer asks for", async () => {
|
|
122
|
+
const file = path.join(home, "huge.jsonl");
|
|
123
|
+
// 5MB of records; taking one must not depend on reading them all.
|
|
124
|
+
const lines = Array.from({ length: 500 }, (_, i) => `${i}:${"y".repeat(10_000)}`);
|
|
125
|
+
await writeFile(file, lines.join("\n") + "\n");
|
|
126
|
+
const it = readLinesBackward(file, { chunkSize: 4096 })[Symbol.asyncIterator]();
|
|
127
|
+
const first = await it.next();
|
|
128
|
+
expect(first.value?.text.startsWith("499:")).toBe(true);
|
|
129
|
+
await it.return?.(undefined);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns nothing for an empty file", async () => {
|
|
133
|
+
const file = path.join(home, "empty.jsonl");
|
|
134
|
+
await writeFile(file, "");
|
|
135
|
+
const got = [];
|
|
136
|
+
for await (const l of readLinesBackward(file)) got.push(l);
|
|
137
|
+
expect(got).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("projectRecord", () => {
|
|
142
|
+
it("extracts Claude text turns", () => {
|
|
143
|
+
const r = projectRecord(claudeTurn("assistant", "hello", "2026-08-01T00:00:00Z"), "claude");
|
|
144
|
+
expect(r).toEqual({ ts: "2026-08-01T00:00:00Z", role: "assistant", text: "hello" });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("skips Claude sidechains unless asked", () => {
|
|
148
|
+
const line = claudeTurn("assistant", "sub", "2026-08-01T00:00:00Z", { isSidechain: true });
|
|
149
|
+
expect(projectRecord(line, "claude")).toBeNull();
|
|
150
|
+
expect(projectRecord(line, "claude", { includeSidechains: true })?.text).toBe("sub");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("drops tool plumbing by default and marks it when requested", () => {
|
|
154
|
+
const line = JSON.stringify({
|
|
155
|
+
type: "assistant",
|
|
156
|
+
timestamp: "2026-08-01T00:00:00Z",
|
|
157
|
+
message: { role: "assistant", content: [{ type: "tool_use", name: "Bash", input: {} }] },
|
|
158
|
+
});
|
|
159
|
+
expect(projectRecord(line, "claude")).toBeNull();
|
|
160
|
+
expect(projectRecord(line, "claude", { includeTools: true })?.text).toBe("[tool: Bash]");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("accepts Claude's string-shaped content", () => {
|
|
164
|
+
const line = JSON.stringify({
|
|
165
|
+
type: "user",
|
|
166
|
+
timestamp: "2026-08-01T00:00:00Z",
|
|
167
|
+
message: { role: "user", content: "plain" },
|
|
168
|
+
});
|
|
169
|
+
expect(projectRecord(line, "claude")?.text).toBe("plain");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("reads Codex event messages and ignores duplicate response_items", () => {
|
|
173
|
+
expect(
|
|
174
|
+
projectRecord(codexEvent("agent_message", "hi", "2026-08-03T01:00:00Z"), "codex"),
|
|
175
|
+
).toEqual({ ts: "2026-08-03T01:00:00Z", role: "assistant", text: "hi" });
|
|
176
|
+
const dup = JSON.stringify({
|
|
177
|
+
timestamp: "2026-08-03T01:00:00Z",
|
|
178
|
+
type: "response_item",
|
|
179
|
+
payload: { type: "message", role: "assistant", content: [{ text: "hi" }] },
|
|
180
|
+
});
|
|
181
|
+
expect(projectRecord(dup, "codex")).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("ignores metadata and malformed lines rather than throwing", () => {
|
|
185
|
+
expect(projectRecord("{not json", "claude")).toBeNull();
|
|
186
|
+
expect(projectRecord(JSON.stringify({ type: "ai-title" }), "claude")).toBeNull();
|
|
187
|
+
expect(projectRecord(JSON.stringify({ type: "session_meta" }), "codex")).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("discoverTranscripts", () => {
|
|
192
|
+
it("finds both sources and filters Claude by cwd slug", async () => {
|
|
193
|
+
await writeClaudeSession("s1", [claudeTurn("user", "a", "2026-08-01T00:00:00Z")]);
|
|
194
|
+
await writeClaudeSession(
|
|
195
|
+
"s2",
|
|
196
|
+
[claudeTurn("user", "b", "2026-08-01T00:00:00Z")],
|
|
197
|
+
"/other/repo",
|
|
198
|
+
);
|
|
199
|
+
await writeCodexSession("c1", [codexEvent("user_message", "c", "2026-08-03T00:00:01Z")]);
|
|
200
|
+
|
|
201
|
+
const all = await discoverTranscripts({ home });
|
|
202
|
+
expect(all.map((f) => f.sessionId).sort()).toEqual(["rollout-c1", "s1", "s2"]);
|
|
203
|
+
|
|
204
|
+
const scoped = await discoverTranscripts({ home, cwd: CWD });
|
|
205
|
+
expect(scoped.map((f) => f.sessionId).sort()).toEqual(["rollout-c1", "s1"]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("filters Codex by the cwd inside session_meta", async () => {
|
|
209
|
+
await writeCodexSession("c1", [codexEvent("user_message", "c", "2026-08-03T00:00:01Z")]);
|
|
210
|
+
await writeCodexSession(
|
|
211
|
+
"c2",
|
|
212
|
+
[codexEvent("user_message", "d", "2026-08-03T00:00:02Z")],
|
|
213
|
+
"/elsewhere",
|
|
214
|
+
);
|
|
215
|
+
const scoped = await discoverTranscripts({ home, cwd: CWD });
|
|
216
|
+
expect(scoped.map((f) => f.sessionId)).toEqual(["rollout-c1"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("ignores codex history.jsonl and empty files", async () => {
|
|
220
|
+
const dir = path.join(home, ".codex", "sessions");
|
|
221
|
+
await mkdir(dir, { recursive: true });
|
|
222
|
+
await writeFile(path.join(dir, "history.jsonl"), '{"x":1}\n');
|
|
223
|
+
await writeClaudeSession("empty", []);
|
|
224
|
+
expect(await discoverTranscripts({ home })).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("returns empty when no agent transcripts exist at all", async () => {
|
|
228
|
+
expect(await discoverTranscripts({ home })).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("readCodexCwd", () => {
|
|
233
|
+
it("reads cwd without consuming the whole file", async () => {
|
|
234
|
+
const file = await writeCodexSession("c1", [
|
|
235
|
+
codexEvent("user_message", "x".repeat(200_000), "2026-08-03T00:00:01Z"),
|
|
236
|
+
]);
|
|
237
|
+
expect(await readCodexCwd(file)).toBe(CWD);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("tailTranscript", () => {
|
|
242
|
+
it("returns the newest N records oldest-first", async () => {
|
|
243
|
+
const file = await writeClaudeSession(
|
|
244
|
+
"s1",
|
|
245
|
+
Array.from({ length: 20 }, (_, i) =>
|
|
246
|
+
claudeTurn("user", `msg-${i}`, `2026-08-01T00:00:${String(i).padStart(2, "0")}Z`),
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
const got = await tailTranscript(asFile(file, "claude"), { limit: 3 });
|
|
250
|
+
expect(got.map((r) => r.text)).toEqual(["msg-17", "msg-18", "msg-19"]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("honours the before cursor and role filter", async () => {
|
|
254
|
+
const file = await writeClaudeSession("s1", [
|
|
255
|
+
claudeTurn("user", "q1", "2026-08-01T00:00:01Z"),
|
|
256
|
+
claudeTurn("assistant", "a1", "2026-08-01T00:00:02Z"),
|
|
257
|
+
claudeTurn("user", "q2", "2026-08-01T00:00:03Z"),
|
|
258
|
+
]);
|
|
259
|
+
const f = asFile(file, "claude");
|
|
260
|
+
expect(
|
|
261
|
+
(await tailTranscript(f, { limit: 10, before: "2026-08-01T00:00:03Z" })).map((r) => r.text),
|
|
262
|
+
).toEqual(["q1", "a1"]);
|
|
263
|
+
expect((await tailTranscript(f, { limit: 10, role: "user" })).map((r) => r.text)).toEqual([
|
|
264
|
+
"q1",
|
|
265
|
+
"q2",
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("histPage", () => {
|
|
271
|
+
it("merges sessions chronologically and caps to the limit", async () => {
|
|
272
|
+
await writeClaudeSession("s1", [
|
|
273
|
+
claudeTurn("user", "claude-early", "2026-08-01T00:00:01Z"),
|
|
274
|
+
claudeTurn("assistant", "claude-late", "2026-08-01T00:00:05Z"),
|
|
275
|
+
]);
|
|
276
|
+
await writeCodexSession("c1", [
|
|
277
|
+
codexEvent("user_message", "codex-mid", "2026-08-01T00:00:03Z"),
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
const page = await histPage({ home, cwd: CWD, limit: 2 });
|
|
281
|
+
expect(page.records.map((r) => r.text)).toEqual(["codex-mid", "claude-late"]);
|
|
282
|
+
expect(page.scanned).toBe(2);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("returns a cursor that pages backwards without overlap", async () => {
|
|
286
|
+
await writeClaudeSession(
|
|
287
|
+
"s1",
|
|
288
|
+
Array.from({ length: 10 }, (_, i) =>
|
|
289
|
+
claudeTurn("user", `m${i}`, `2026-08-01T00:00:${String(i).padStart(2, "0")}Z`),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
const first = await histPage({ home, cwd: CWD, limit: 3 });
|
|
293
|
+
expect(first.records.map((r) => r.text)).toEqual(["m7", "m8", "m9"]);
|
|
294
|
+
expect(first.cursor).toBeTruthy();
|
|
295
|
+
|
|
296
|
+
const second = await histPage({ home, cwd: CWD, limit: 3, before: first.cursor! });
|
|
297
|
+
expect(second.records.map((r) => r.text)).toEqual(["m4", "m5", "m6"]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("pages through records that share one timestamp without losing any", async () => {
|
|
301
|
+
// Transcripts routinely record several turns inside the same millisecond.
|
|
302
|
+
// A bare-timestamp cursor drops every record tying with the page boundary.
|
|
303
|
+
const ts = "2026-08-01T00:00:00.000Z";
|
|
304
|
+
await writeClaudeSession(
|
|
305
|
+
"s1",
|
|
306
|
+
["a", "b", "c", "d"].map((t) => claudeTurn("user", t, ts)),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const first = await histPage({ home, cwd: CWD, limit: 2 });
|
|
310
|
+
expect(first.records.map((r) => r.text)).toEqual(["c", "d"]);
|
|
311
|
+
const second = await histPage({ home, cwd: CWD, limit: 2, before: first.cursor! });
|
|
312
|
+
expect(second.records.map((r) => r.text)).toEqual(["a", "b"]);
|
|
313
|
+
expect(second.cursor).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("walks the entire history in pages without gaps or repeats", async () => {
|
|
317
|
+
await writeClaudeSession(
|
|
318
|
+
"s1",
|
|
319
|
+
Array.from({ length: 7 }, (_, i) => claudeTurn("user", `x${i}`, "2026-08-01T00:00:00.000Z")),
|
|
320
|
+
);
|
|
321
|
+
await writeCodexSession(
|
|
322
|
+
"c1",
|
|
323
|
+
Array.from({ length: 5 }, (_, i) =>
|
|
324
|
+
codexEvent("user_message", `y${i}`, "2026-08-01T00:00:00.000Z"),
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const seen: string[] = [];
|
|
329
|
+
let before: string | undefined;
|
|
330
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
331
|
+
const page = await histPage({ home, cwd: CWD, limit: 3, before });
|
|
332
|
+
seen.unshift(...page.records.map((r) => r.text));
|
|
333
|
+
if (!page.cursor) break;
|
|
334
|
+
before = page.cursor;
|
|
335
|
+
}
|
|
336
|
+
expect(seen).toHaveLength(12);
|
|
337
|
+
expect(new Set(seen).size).toBe(12);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("still accepts a hand-typed ISO timestamp for --before", async () => {
|
|
341
|
+
await writeClaudeSession("s1", [
|
|
342
|
+
claudeTurn("user", "old", "2026-08-01T00:00:01Z"),
|
|
343
|
+
claudeTurn("user", "new", "2026-08-01T00:00:09Z"),
|
|
344
|
+
]);
|
|
345
|
+
const page = await histPage({ home, cwd: CWD, limit: 5, before: "2026-08-01T00:00:05Z" });
|
|
346
|
+
expect(page.records.map((r) => r.text)).toEqual(["old"]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("reports no cursor once the history is exhausted", async () => {
|
|
350
|
+
await writeClaudeSession("s1", [claudeTurn("user", "only", "2026-08-01T00:00:01Z")]);
|
|
351
|
+
const page = await histPage({ home, cwd: CWD, limit: 6 });
|
|
352
|
+
expect(page.records).toHaveLength(1);
|
|
353
|
+
expect(page.cursor).toBeNull();
|
|
354
|
+
});
|
|
355
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import { homedir, tmpdir } from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import {
|
|
7
|
+
formatIdentity,
|
|
8
|
+
localHost,
|
|
9
|
+
localUser,
|
|
10
|
+
readGitBranch,
|
|
11
|
+
safeBranch,
|
|
12
|
+
safeToken,
|
|
13
|
+
tildify,
|
|
14
|
+
} from "./identity.ts";
|
|
15
|
+
|
|
16
|
+
let root: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
root = await mkdtemp(path.join(tmpdir(), "ay-ident-"));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await rm(root, { recursive: true, force: true }).catch(() => null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
async function gitFixture(rel: string, head: string): Promise<string> {
|
|
27
|
+
const repo = path.join(root, rel);
|
|
28
|
+
await mkdir(path.join(repo, ".git"), { recursive: true });
|
|
29
|
+
await writeFile(path.join(repo, ".git", "HEAD"), head);
|
|
30
|
+
return repo;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("readGitBranch", () => {
|
|
34
|
+
it("reads the branch from a normal checkout", async () => {
|
|
35
|
+
const repo = await gitFixture("repo", "ref: refs/heads/main\n");
|
|
36
|
+
expect(readGitBranch(repo)).toBe("main");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("walks up from a subdirectory", async () => {
|
|
40
|
+
const repo = await gitFixture("repo2", "ref: refs/heads/feat/x-1.2\n");
|
|
41
|
+
const sub = path.join(repo, "a", "b");
|
|
42
|
+
await mkdir(sub, { recursive: true });
|
|
43
|
+
expect(readGitBranch(sub)).toBe("feat/x-1.2");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("follows a worktree's gitdir indirection file", async () => {
|
|
47
|
+
const gitdir = path.join(root, "main-repo", ".git", "worktrees", "wt1");
|
|
48
|
+
await mkdir(gitdir, { recursive: true });
|
|
49
|
+
await writeFile(path.join(gitdir, "HEAD"), "ref: refs/heads/crm-yamamoto-wifi\n");
|
|
50
|
+
const wt = path.join(root, "wt-checkout");
|
|
51
|
+
await mkdir(wt, { recursive: true });
|
|
52
|
+
await writeFile(path.join(wt, ".git"), `gitdir: ${gitdir}\n`);
|
|
53
|
+
expect(readGitBranch(wt)).toBe("crm-yamamoto-wifi");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("resolves a RELATIVE gitdir against the checkout", async () => {
|
|
57
|
+
const gitdir = path.join(root, "gd");
|
|
58
|
+
await mkdir(gitdir, { recursive: true });
|
|
59
|
+
await writeFile(path.join(gitdir, "HEAD"), "ref: refs/heads/rel\n");
|
|
60
|
+
const wt = path.join(root, "rel-checkout");
|
|
61
|
+
await mkdir(wt, { recursive: true });
|
|
62
|
+
await writeFile(path.join(wt, ".git"), "gitdir: ../gd\n");
|
|
63
|
+
expect(readGitBranch(wt)).toBe("rel");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns a short commit id for a detached HEAD", async () => {
|
|
67
|
+
const sha = "0123456789abcdef0123456789abcdef01234567";
|
|
68
|
+
const repo = await gitFixture("det", sha + "\n");
|
|
69
|
+
expect(readGitBranch(repo)).toBe(sha.slice(0, 12));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns null outside any git checkout", async () => {
|
|
73
|
+
const plain = path.join(root, "plain");
|
|
74
|
+
await mkdir(plain, { recursive: true });
|
|
75
|
+
expect(readGitBranch(plain)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns null on an unreadable/garbled HEAD", async () => {
|
|
79
|
+
const repo = await gitFixture("bad", "something else entirely\n");
|
|
80
|
+
expect(readGitBranch(repo)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns null when .git exists but HEAD cannot be read at all", async () => {
|
|
84
|
+
// A HEAD that is a DIRECTORY makes readFileSync throw rather than return
|
|
85
|
+
// junk — a different path from the garbled-content case above. Identity is
|
|
86
|
+
// never worth failing a spawn over, so this degrades to "no branch".
|
|
87
|
+
const repo = path.join(root, "headless");
|
|
88
|
+
await mkdir(path.join(repo, ".git", "HEAD"), { recursive: true });
|
|
89
|
+
expect(readGitBranch(repo)).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("formatIdentity", () => {
|
|
94
|
+
it("renders user@host:path:branch#pid", async () => {
|
|
95
|
+
const repo = await gitFixture("fmt", "ref: refs/heads/main\n");
|
|
96
|
+
const id = formatIdentity({ user: "sno", host: "Mac", cwd: repo, pid: 30402 });
|
|
97
|
+
expect(id).toBe(`sno@Mac:${repo}:main#30402`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("omits the branch segment outside a repo", async () => {
|
|
101
|
+
const plain = path.join(root, "nofmt");
|
|
102
|
+
await mkdir(plain, { recursive: true });
|
|
103
|
+
const id = formatIdentity({ user: "sno", host: "Mac", cwd: plain, pid: 7 });
|
|
104
|
+
expect(id).toBe(`sno@Mac:${plain}#7`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("accepts an explicit branch (and null to suppress detection)", () => {
|
|
108
|
+
expect(formatIdentity({ user: "u", host: "h", cwd: "/x", branch: "b", pid: 1 })).toBe(
|
|
109
|
+
"u@h:/x:b#1",
|
|
110
|
+
);
|
|
111
|
+
expect(formatIdentity({ user: "u", host: "h", cwd: "/x", branch: null, pid: 1 })).toBe(
|
|
112
|
+
"u@h:/x#1",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("defaults user/host to header-safe local values", () => {
|
|
117
|
+
const id = formatIdentity({ cwd: "/x", branch: null, pid: 1 });
|
|
118
|
+
expect(id).toMatch(/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:\/x#1$/);
|
|
119
|
+
expect(id).toContain(`${localUser()}@${localHost()}`);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("tildifies the home directory like ay ls does", () => {
|
|
123
|
+
expect(tildify(path.join(homedir(), "ws"))).toBe(`~${path.sep}ws`);
|
|
124
|
+
expect(tildify("/opt/x")).toBe("/opt/x");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The SAME table rs/src/identity.rs asserts
|
|
130
|
+
* (`identity::tests::matches_the_shared_case_table`).
|
|
131
|
+
*
|
|
132
|
+
* rs/src/identity.rs is a hand port of this module, and both build the
|
|
133
|
+
* `<ay-init-msg>` header that tests/fixtures/ay-init-msg.golden.txt pins byte
|
|
134
|
+
* for byte. That fixture holds a LITERAL identity string, so it cannot catch
|
|
135
|
+
* the two implementations drifting apart — this table can.
|
|
136
|
+
*/
|
|
137
|
+
describe("identity — shared cross-runtime case table", () => {
|
|
138
|
+
const cases = JSON.parse(
|
|
139
|
+
readFileSync(
|
|
140
|
+
path.resolve(import.meta.dirname, "../tests/fixtures/identity-cases.json"),
|
|
141
|
+
"utf8",
|
|
142
|
+
),
|
|
143
|
+
) as {
|
|
144
|
+
identity: {
|
|
145
|
+
name: string;
|
|
146
|
+
user: string;
|
|
147
|
+
host: string;
|
|
148
|
+
cwd: string;
|
|
149
|
+
home: string | null;
|
|
150
|
+
branch: string | null;
|
|
151
|
+
pid: number;
|
|
152
|
+
want: string;
|
|
153
|
+
}[];
|
|
154
|
+
safeToken: { name: string; raw: string; max: number; want: string }[];
|
|
155
|
+
safeBranch: { name: string; raw: string; want: string }[];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
it("renders every identity case exactly as the Rust port does", () => {
|
|
159
|
+
expect(cases.identity.length).toBeGreaterThan(0);
|
|
160
|
+
for (const c of cases.identity) {
|
|
161
|
+
expect(
|
|
162
|
+
formatIdentity({
|
|
163
|
+
user: c.user,
|
|
164
|
+
host: c.host,
|
|
165
|
+
cwd: c.cwd,
|
|
166
|
+
// JSON null means "omit the segment" — never "auto-detect", which
|
|
167
|
+
// would read the filesystem and stop being cross-runtime comparable.
|
|
168
|
+
branch: c.branch,
|
|
169
|
+
pid: c.pid,
|
|
170
|
+
home: c.home ?? undefined,
|
|
171
|
+
}),
|
|
172
|
+
`identity case: ${c.name}`,
|
|
173
|
+
).toBe(c.want);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("clamps every safeToken case exactly as the Rust port does", () => {
|
|
178
|
+
expect(cases.safeToken.length).toBeGreaterThan(0);
|
|
179
|
+
for (const c of cases.safeToken) {
|
|
180
|
+
expect(safeToken(c.raw, c.max), `safeToken case: ${c.name}`).toBe(c.want);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("clamps every safeBranch case exactly as the Rust port does", () => {
|
|
185
|
+
expect(cases.safeBranch.length).toBeGreaterThan(0);
|
|
186
|
+
for (const c of cases.safeBranch) {
|
|
187
|
+
expect(safeBranch(c.raw), `safeBranch case: ${c.name}`).toBe(c.want);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { IdleWaiter } from "./idleWaiter";
|
|
3
|
+
|
|
4
|
+
describe("IdleWaiter", () => {
|
|
5
|
+
it("should initialize with current time", () => {
|
|
6
|
+
// Bracket the constructor instead of a magic toBeCloseTo tolerance: the
|
|
7
|
+
// timestamp must fall within [before, after], which can't flake on a slow CI
|
|
8
|
+
// runner (a GC pause would just widen the bracket, never break it).
|
|
9
|
+
const before = Date.now();
|
|
10
|
+
const waiter = new IdleWaiter();
|
|
11
|
+
const after = Date.now();
|
|
12
|
+
expect(waiter.lastActivityTime).toBeGreaterThanOrEqual(before);
|
|
13
|
+
expect(waiter.lastActivityTime).toBeLessThanOrEqual(after);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should update lastActivityTime when ping is called", () => {
|
|
17
|
+
const waiter = new IdleWaiter();
|
|
18
|
+
const initialTime = waiter.lastActivityTime;
|
|
19
|
+
|
|
20
|
+
// Wait a small amount
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
while (Date.now() - start < 10) {
|
|
23
|
+
// busy wait
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
waiter.ping();
|
|
27
|
+
expect(waiter.lastActivityTime).toBeGreaterThan(initialTime);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("should return this when ping is called for chaining", () => {
|
|
31
|
+
const waiter = new IdleWaiter();
|
|
32
|
+
expect(waiter.ping()).toBe(waiter);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should resolve wait immediately when already idle", async () => {
|
|
36
|
+
const waiter = new IdleWaiter();
|
|
37
|
+
|
|
38
|
+
// Wait enough time to be considered idle
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
while (Date.now() - start < 50) {
|
|
41
|
+
// busy wait
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// This should resolve quickly since enough time has passed
|
|
45
|
+
const waitPromise = waiter.wait(10);
|
|
46
|
+
await expect(waitPromise).resolves.toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should respect custom check interval", () => {
|
|
50
|
+
const waiter = new IdleWaiter();
|
|
51
|
+
waiter.checkInterval = 200;
|
|
52
|
+
|
|
53
|
+
expect(waiter.checkInterval).toBe(200);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should have ping method that chains", () => {
|
|
57
|
+
const waiter = new IdleWaiter();
|
|
58
|
+
const result = waiter.ping().ping().ping();
|
|
59
|
+
expect(result).toBe(waiter);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("idleTimeMs reflects elapsed time since the last ping", async () => {
|
|
63
|
+
const waiter = new IdleWaiter();
|
|
64
|
+
waiter.ping();
|
|
65
|
+
expect(waiter.idleTimeMs()).toBeLessThan(20);
|
|
66
|
+
|
|
67
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
68
|
+
// 45, not 50: setTimeout(50) can fire with the monotonic clock reading
|
|
69
|
+
// ~49ms on CI (timer coarseness), which flaked this exact assertion.
|
|
70
|
+
expect(waiter.idleTimeMs()).toBeGreaterThanOrEqual(45);
|
|
71
|
+
|
|
72
|
+
waiter.ping();
|
|
73
|
+
expect(waiter.idleTimeMs()).toBeLessThan(20);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should wait until idle period has passed", async () => {
|
|
77
|
+
const waiter = new IdleWaiter();
|
|
78
|
+
waiter.checkInterval = 10;
|
|
79
|
+
|
|
80
|
+
// Ping right now so it's not idle
|
|
81
|
+
waiter.ping();
|
|
82
|
+
|
|
83
|
+
const start = Date.now();
|
|
84
|
+
// Wait for 50ms idle period — the wait loop should actually iterate
|
|
85
|
+
await waiter.wait(50);
|
|
86
|
+
const elapsed = Date.now() - start;
|
|
87
|
+
|
|
88
|
+
// Should have waited at least ~50ms
|
|
89
|
+
expect(elapsed).toBeGreaterThanOrEqual(40);
|
|
90
|
+
});
|
|
91
|
+
});
|