@vellumai/assistant 0.12.2-staging.6 → 0.12.2-staging.7
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/docs/architecture/memory.md +11 -2
- package/docs/desktop-browser-cli.md +4 -2
- package/node_modules/@vellumai/environments/src/shell.test.ts +21 -0
- package/node_modules/@vellumai/environments/src/shell.ts +24 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +8 -2
- package/openapi.yaml +6 -2
- package/package.json +2 -2
- package/scripts/postinstall.ts +33 -0
- package/scripts/smoke-desktop-browser-cli.ts +1 -0
- package/src/__tests__/agent-loop.test.ts +124 -0
- package/src/__tests__/approval-interception-trust-gates.test.ts +40 -0
- package/src/__tests__/channel-approval.test.ts +9 -14
- package/src/__tests__/conversation-agent-loop.test.ts +25 -0
- package/src/__tests__/db-conversation-tool-surface.test.ts +144 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +0 -1
- package/src/__tests__/run-conversation-turn-persistence.test.ts +138 -1
- package/src/__tests__/script-proxy-certs.test.ts +1 -1
- package/src/__tests__/subagent-tool-gate-mode.test.ts +169 -0
- package/src/__tests__/terminal-tools.test.ts +8 -0
- package/src/__tests__/unicode.test.ts +36 -0
- package/src/agent/loop.ts +19 -0
- package/src/api/events/desktop-activity-changed.ts +10 -0
- package/src/api/index.ts +6 -0
- package/src/approvals/approval-primitive.ts +5 -2
- package/src/approvals/scoped-approval-grants.ts +6 -2
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +43 -0
- package/src/daemon/conversation-agent-loop.ts +2 -0
- package/src/daemon/conversation-tool-setup.ts +57 -1
- package/src/daemon/conversation.ts +17 -0
- package/src/daemon/daemon-control.ts +2 -6
- package/src/daemon/orphan-reaper.ts +4 -3
- package/src/daemon/tool-setup-types.ts +6 -0
- package/src/daemon/wake-conversation-ops.ts +38 -15
- package/src/desktop/desktop-automation-lease.test.ts +143 -0
- package/src/desktop/desktop-automation-lease.ts +39 -3
- package/src/messaging/provider-message-metadata.ts +3 -3
- package/src/notifications/__tests__/copy-composer.test.ts +70 -0
- package/src/notifications/copy-composer.ts +11 -3
- package/src/persistence/conversation-plugin-facade.ts +13 -0
- package/src/persistence/conversation-tool-surface.ts +86 -0
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.test.ts +78 -0
- package/src/persistence/migrations/378-create-conversation-tool-surfaces.ts +29 -0
- package/src/persistence/schema/conversation-tool-surfaces.ts +22 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/steps.ts +2 -0
- package/src/plugin-api/conversation-turn.ts +31 -7
- package/src/plugin-api/index.ts +9 -1
- package/src/plugin-api/plugin-channel-turn-trust.test.ts +133 -0
- package/src/plugin-api/plugin-channel-turn-trust.ts +71 -0
- package/src/plugins/defaults/memory/AGENTS.md +14 -2
- package/src/plugins/defaults/memory/__tests__/buffer-file.test.ts +320 -0
- package/src/plugins/defaults/memory/__tests__/buffer-format.test.ts +43 -0
- package/src/plugins/defaults/memory/__tests__/fixtures/buffer-appender.ts +17 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +46 -0
- package/src/plugins/defaults/memory/__tests__/memory-run-evidence.test.ts +161 -0
- package/src/plugins/defaults/memory/buffer-file.ts +354 -0
- package/src/plugins/defaults/memory/buffer-format.ts +40 -0
- package/src/plugins/defaults/memory/context-search/agent-runner.ts +1 -2
- package/src/plugins/defaults/memory/context-search/format.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/memory-v2.ts +2 -1
- package/src/plugins/defaults/memory/context-search/sources/workspace.ts +2 -1
- package/src/plugins/defaults/memory/graph/capability-seed.ts +1 -2
- package/src/plugins/defaults/memory/graph/tool-handlers.ts +1 -42
- package/src/plugins/defaults/memory/host-utils.ts +0 -10
- package/src/plugins/defaults/memory/injectors.ts +4 -3
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +55 -181
- package/src/plugins/defaults/memory/memory-run-evidence.ts +213 -0
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-job.test.ts +407 -99
- package/src/plugins/defaults/memory/substrate/__tests__/consolidation-prompt-flag-gating-guard.test.ts +10 -0
- package/src/plugins/defaults/memory/substrate/__tests__/prompts-consolidation.test.ts +107 -7
- package/src/plugins/defaults/memory/substrate/consolidation-job.ts +307 -86
- package/src/plugins/defaults/memory/substrate/consolidation-tool-surface.ts +34 -0
- package/src/plugins/defaults/memory/substrate/page-index.ts +2 -1
- package/src/plugins/defaults/memory/substrate/prompts/consolidation.ts +89 -49
- package/src/plugins/defaults/memory/substrate/sweep-job.ts +1 -1
- package/src/plugins/defaults/memory/tools.ts +1 -1
- package/src/plugins/defaults/memory/v1/graph/consolidation.ts +2 -2
- package/src/plugins/defaults/memory/v1/graph/extraction.ts +2 -1
- package/src/plugins/defaults/memory/v1/graph/retriever.ts +1 -1
- package/src/plugins/defaults/memory/v2/__tests__/migration.test.ts +5 -0
- package/src/plugins/defaults/memory/v2/__tests__/reranker.test.ts +5 -2
- package/src/plugins/defaults/memory/v2/reranker.ts +2 -1
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +81 -1
- package/src/plugins/defaults/memory/v3/__tests__/orchestrate.test.ts +87 -0
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +21 -0
- package/src/plugins/defaults/memory/v3/card.ts +2 -1
- package/src/plugins/defaults/memory/v3/injector.ts +212 -178
- package/src/plugins/defaults/memory/v3/orchestrate.ts +86 -22
- package/src/plugins/defaults/memory/v3/pool-select.ts +10 -7
- package/src/plugins/defaults/memory/v3/sections.ts +2 -1
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +10 -1
- package/src/plugins/defaults/tool-result-truncate/terminal.ts +1 -46
- package/src/runtime/AGENTS.md +1 -1
- package/src/runtime/__tests__/agent-wake.test.ts +86 -1
- package/src/runtime/agent-wake.ts +20 -4
- package/src/runtime/guardian-action-service.ts +2 -17
- package/src/runtime/guardian-reply-router.ts +1 -8
- package/src/runtime/routes/channel-route-shared.ts +1 -9
- package/src/runtime/routes/desktop-setup-routes.test.ts +2 -2
- package/src/runtime/routes/desktop-setup-routes.ts +7 -3
- package/src/runtime/routes/guardian-approval-interception.ts +24 -0
- package/src/runtime/routes/inbound-message-handler.ts +2 -3
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +1 -1
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +7 -4
- package/src/schedule/run-script.ts +2 -2
- package/src/tools/host-terminal/host-shell.ts +12 -6
- package/src/tools/shared/filesystem/file-ops-service.ts +1 -31
- package/src/tools/shared/shell-output.test.ts +10 -0
- package/src/tools/shared/shell-output.ts +14 -2
- package/src/tools/skills/sandbox-runner.ts +13 -2
- package/src/tools/skills/scaffold-managed.ts +2 -1
- package/src/tools/terminal/__tests__/safe-env.test.ts +29 -0
- package/src/tools/terminal/safe-env.ts +30 -1
- package/src/tools/terminal/sanitized-bash.ts +15 -2
- package/src/tools/terminal/shell-launch.test.ts +162 -0
- package/src/tools/terminal/shell.test.ts +29 -0
- package/src/tools/terminal/shell.ts +13 -7
- package/src/util/host-process.test.ts +17 -1
- package/src/util/host-process.ts +24 -0
- package/src/util/unicode.ts +29 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `buffer-file.ts`: the two writers of `memory/buffer.md`.
|
|
3
|
+
*
|
|
4
|
+
* The consume cases pin the invariant the module exists for: an entry saved
|
|
5
|
+
* at any point while a consolidation run is in flight is either the run's
|
|
6
|
+
* own material or still in the buffer afterwards, never destroyed. The
|
|
7
|
+
* cross-process case is the reason to believe it: a child process appends
|
|
8
|
+
* as fast as it can while this process consumes in a loop, and every tagged
|
|
9
|
+
* entry must end up consumed or present. The same harness run against a
|
|
10
|
+
* naive read-modify-write consume loses entries, which is what the
|
|
11
|
+
* mechanism is measured against.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
appendFileSync,
|
|
16
|
+
chmodSync,
|
|
17
|
+
closeSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdtempSync,
|
|
20
|
+
openSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import {
|
|
29
|
+
afterAll,
|
|
30
|
+
beforeAll,
|
|
31
|
+
beforeEach,
|
|
32
|
+
describe,
|
|
33
|
+
expect,
|
|
34
|
+
test,
|
|
35
|
+
} from "bun:test";
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
appendBufferAndArchive,
|
|
39
|
+
consumeBufferEntries,
|
|
40
|
+
} from "../buffer-file.js";
|
|
41
|
+
import {
|
|
42
|
+
type BufferEntryLines,
|
|
43
|
+
bufferEntryText,
|
|
44
|
+
formatRememberEntry,
|
|
45
|
+
joinBufferEntries,
|
|
46
|
+
splitBufferContent,
|
|
47
|
+
} from "../buffer-format.js";
|
|
48
|
+
|
|
49
|
+
let tmp: string;
|
|
50
|
+
beforeAll(() => {
|
|
51
|
+
tmp = mkdtempSync(join(tmpdir(), "memory-buffer-file-test-"));
|
|
52
|
+
});
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let memoryDir: string;
|
|
58
|
+
let bufferPath: string;
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
memoryDir = mkdtempSync(join(tmp, "memory-"));
|
|
61
|
+
bufferPath = join(memoryDir, "buffer.md");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const A = "- [Apr 27, 9:00 AM] Alice prefers VS Code.";
|
|
65
|
+
const B = "- [Apr 27, 9:01 AM] Bob shared a snippet:\n line two\n line three";
|
|
66
|
+
const C = "- [Apr 27, 9:02 AM] Carol loves jazz.";
|
|
67
|
+
const D = "- [Apr 27, 9:03 AM] Dave runs marathons.";
|
|
68
|
+
|
|
69
|
+
function file(...entries: string[]): string {
|
|
70
|
+
return entries.map((e) => `${e}\n`).join("");
|
|
71
|
+
}
|
|
72
|
+
function entries(content: string): BufferEntryLines[] {
|
|
73
|
+
return splitBufferContent(content);
|
|
74
|
+
}
|
|
75
|
+
function texts(): string[] {
|
|
76
|
+
return entries(readFileSync(bufferPath, "utf-8")).map(bufferEntryText);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("appendBufferAndArchive", () => {
|
|
80
|
+
test("appends to the buffer and the dated archive, seeding the archive header once", () => {
|
|
81
|
+
const now = new Date(2026, 3, 27, 9, 0);
|
|
82
|
+
const entry = formatRememberEntry("Alice prefers VS Code.", now);
|
|
83
|
+
const first = appendBufferAndArchive({ rootDir: memoryDir, entry, now });
|
|
84
|
+
appendBufferAndArchive({ rootDir: memoryDir, entry, now });
|
|
85
|
+
|
|
86
|
+
expect(first.bufferPath).toBe(bufferPath);
|
|
87
|
+
expect(readFileSync(bufferPath, "utf-8")).toBe(entry + entry);
|
|
88
|
+
expect(first.archivePath).toBe(join(memoryDir, "archive", "2026-04-27.md"));
|
|
89
|
+
expect(readFileSync(first.archivePath, "utf-8")).toBe(
|
|
90
|
+
`# Apr 27, 2026\n\n${entry}${entry}`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("consumeBufferEntries", () => {
|
|
96
|
+
test("removes exactly the consumed entries and keeps the rest verbatim and in order", async () => {
|
|
97
|
+
writeFileSync(bufferPath, file(A, B, C, D));
|
|
98
|
+
const pass = entries(file(A, B));
|
|
99
|
+
|
|
100
|
+
const result = await consumeBufferEntries(bufferPath, pass, {
|
|
101
|
+
lateAppendGraceMs: 0,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual({
|
|
105
|
+
removed: 2,
|
|
106
|
+
alreadyAbsent: 0,
|
|
107
|
+
lateAppendBytesRecovered: 0,
|
|
108
|
+
unrecoveredLateAppendBytes: 0,
|
|
109
|
+
lateAppendDrainFailed: false,
|
|
110
|
+
});
|
|
111
|
+
expect(readFileSync(bufferPath, "utf-8")).toBe(file(C, D));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("entries appended after the snapshot survive, including one that duplicates a consumed entry", async () => {
|
|
115
|
+
// The snapshot held one copy of A; the same fact was remembered again
|
|
116
|
+
// during the run. Multiset semantics: one copy goes, one stays.
|
|
117
|
+
writeFileSync(bufferPath, file(A, B));
|
|
118
|
+
const pass = entries(file(A, B));
|
|
119
|
+
appendFileSync(bufferPath, file(C, A));
|
|
120
|
+
|
|
121
|
+
const result = await consumeBufferEntries(bufferPath, pass, {
|
|
122
|
+
lateAppendGraceMs: 0,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result.removed).toBe(2);
|
|
126
|
+
expect(texts()).toEqual([C, A]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a consumed entry missing from the live file is counted, not searched for", async () => {
|
|
130
|
+
writeFileSync(bufferPath, file(B, C));
|
|
131
|
+
const pass = entries(file(A, B));
|
|
132
|
+
|
|
133
|
+
const result = await consumeBufferEntries(bufferPath, pass, {
|
|
134
|
+
lateAppendGraceMs: 0,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(result).toMatchObject({ removed: 1, alreadyAbsent: 1 });
|
|
138
|
+
expect(texts()).toEqual([C]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a missing buffer file consumes nothing and creates nothing", async () => {
|
|
142
|
+
const result = await consumeBufferEntries(bufferPath, entries(file(A)), {
|
|
143
|
+
lateAppendGraceMs: 0,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result).toEqual({
|
|
147
|
+
removed: 0,
|
|
148
|
+
alreadyAbsent: 1,
|
|
149
|
+
lateAppendBytesRecovered: 0,
|
|
150
|
+
unrecoveredLateAppendBytes: 0,
|
|
151
|
+
lateAppendDrainFailed: false,
|
|
152
|
+
});
|
|
153
|
+
expect(existsSync(bufferPath)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("consuming everything leaves an empty file and no temp file behind", async () => {
|
|
157
|
+
writeFileSync(bufferPath, file(A, B));
|
|
158
|
+
|
|
159
|
+
await consumeBufferEntries(bufferPath, entries(file(A, B)), {
|
|
160
|
+
lateAppendGraceMs: 0,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(readFileSync(bufferPath, "utf-8")).toBe("");
|
|
164
|
+
expect(readdirSync(memoryDir)).toEqual(["buffer.md"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("an append that landed on the replaced inode is drained back into the buffer", async () => {
|
|
168
|
+
// An appender that opened the file before the rename writes to the old
|
|
169
|
+
// inode. The consumer holds that inode open and copies the late bytes
|
|
170
|
+
// over. The test plays the appender: open before, write after the
|
|
171
|
+
// synchronous phase, before the grace-window drain.
|
|
172
|
+
writeFileSync(bufferPath, file(A, B));
|
|
173
|
+
const lateFd = openSync(bufferPath, "a");
|
|
174
|
+
const consuming = consumeBufferEntries(bufferPath, entries(file(A)), {
|
|
175
|
+
lateAppendGraceMs: 50,
|
|
176
|
+
});
|
|
177
|
+
// The synchronous phase (read, rewrite, rename) ran before the first
|
|
178
|
+
// await inside consume, so this write lands on the orphaned inode.
|
|
179
|
+
appendFileSync(lateFd, file(C));
|
|
180
|
+
closeSync(lateFd);
|
|
181
|
+
|
|
182
|
+
const result = await consuming;
|
|
183
|
+
|
|
184
|
+
expect(result.removed).toBe(1);
|
|
185
|
+
expect(result.lateAppendBytesRecovered).toBe(file(C).length);
|
|
186
|
+
expect(result.unrecoveredLateAppendBytes).toBe(0);
|
|
187
|
+
expect(texts()).toEqual([B, C]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a late append the drain cannot copy back is reported, not thrown, and the pass stays consumed", async () => {
|
|
191
|
+
// The rename has committed when the drain runs, so a failing copy must
|
|
192
|
+
// not surface as "nothing happened". The test makes the rewritten
|
|
193
|
+
// buffer read-only before the late bytes land, so every copy attempt
|
|
194
|
+
// fails.
|
|
195
|
+
writeFileSync(bufferPath, file(A, B));
|
|
196
|
+
const lateFd = openSync(bufferPath, "a");
|
|
197
|
+
const consuming = consumeBufferEntries(bufferPath, entries(file(A)), {
|
|
198
|
+
lateAppendGraceMs: 50,
|
|
199
|
+
});
|
|
200
|
+
chmodSync(bufferPath, 0o444);
|
|
201
|
+
appendFileSync(lateFd, file(C));
|
|
202
|
+
closeSync(lateFd);
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const result = await consuming;
|
|
206
|
+
expect(result.removed).toBe(1);
|
|
207
|
+
expect(result.lateAppendBytesRecovered).toBe(0);
|
|
208
|
+
expect(result.unrecoveredLateAppendBytes).toBe(file(C).length);
|
|
209
|
+
expect(result.lateAppendDrainFailed).toBe(false);
|
|
210
|
+
expect(texts()).toEqual([B]);
|
|
211
|
+
} finally {
|
|
212
|
+
chmodSync(bufferPath, 0o644);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The cross-process invariant. A child appends `count` tagged entries as
|
|
219
|
+
* fast as it can through `appendFileSync`; this process consumes the leading
|
|
220
|
+
* entries (up to `passSize` at a time) in a loop through `consume`. Returns
|
|
221
|
+
* the tags that were neither consumed by a pass nor left in the buffer: the
|
|
222
|
+
* entries the consume destroyed.
|
|
223
|
+
*/
|
|
224
|
+
async function lostUnder(
|
|
225
|
+
consume: (path: string, pass: BufferEntryLines[]) => Promise<unknown>,
|
|
226
|
+
count: number,
|
|
227
|
+
passSize: number,
|
|
228
|
+
): Promise<{ lost: string[]; duplicated: string[]; passes: number }> {
|
|
229
|
+
const tag = `t${Math.random().toString(36).slice(2, 8)}`;
|
|
230
|
+
writeFileSync(bufferPath, "");
|
|
231
|
+
const child = Bun.spawn(
|
|
232
|
+
[
|
|
233
|
+
process.execPath,
|
|
234
|
+
join(import.meta.dir, "fixtures", "buffer-appender.ts"),
|
|
235
|
+
bufferPath,
|
|
236
|
+
String(count),
|
|
237
|
+
tag,
|
|
238
|
+
],
|
|
239
|
+
{ stdout: "ignore", stderr: "inherit" },
|
|
240
|
+
);
|
|
241
|
+
const consumed: string[] = [];
|
|
242
|
+
let passes = 0;
|
|
243
|
+
let childDone = false;
|
|
244
|
+
void child.exited.then(() => {
|
|
245
|
+
childDone = true;
|
|
246
|
+
});
|
|
247
|
+
while (!childDone) {
|
|
248
|
+
const snapshot = entries(readFileSync(bufferPath, "utf-8"));
|
|
249
|
+
const pass = snapshot.slice(0, passSize);
|
|
250
|
+
if (pass.length === 0) {
|
|
251
|
+
await Bun.sleep(1);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
await consume(bufferPath, pass);
|
|
255
|
+
consumed.push(...pass.map(bufferEntryText));
|
|
256
|
+
passes += 1;
|
|
257
|
+
}
|
|
258
|
+
await child.exited;
|
|
259
|
+
const seen = new Map<string, number>();
|
|
260
|
+
for (const text of [...consumed, ...texts()]) {
|
|
261
|
+
seen.set(text, (seen.get(text) ?? 0) + 1);
|
|
262
|
+
}
|
|
263
|
+
const lost: string[] = [];
|
|
264
|
+
const duplicated: string[] = [];
|
|
265
|
+
for (let i = 0; i < count; i++) {
|
|
266
|
+
const text = `- [Apr 27, 9:00 AM] ${tag}-${i}`;
|
|
267
|
+
const n = seen.get(text) ?? 0;
|
|
268
|
+
if (n === 0) {
|
|
269
|
+
lost.push(text);
|
|
270
|
+
} else if (n > 1) {
|
|
271
|
+
duplicated.push(text);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { lost, duplicated, passes };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The read-modify-write the production consume replaces. */
|
|
278
|
+
async function naiveConsume(
|
|
279
|
+
path: string,
|
|
280
|
+
pass: BufferEntryLines[],
|
|
281
|
+
): Promise<void> {
|
|
282
|
+
const pending = pass.map(bufferEntryText);
|
|
283
|
+
const remaining = entries(readFileSync(path, "utf-8")).filter((entry) => {
|
|
284
|
+
const index = pending.indexOf(bufferEntryText(entry));
|
|
285
|
+
if (index === -1) {
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
pending.splice(index, 1);
|
|
289
|
+
return false;
|
|
290
|
+
});
|
|
291
|
+
writeFileSync(path, joinBufferEntries(remaining), "utf-8");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
describe("consumeBufferEntries under a concurrent appender in another process", () => {
|
|
295
|
+
const APPENDS = 4000;
|
|
296
|
+
|
|
297
|
+
test("no entry appended during consumption is lost or duplicated", async () => {
|
|
298
|
+
// Runs the consume exactly as production does, grace window included:
|
|
299
|
+
// the window is what makes the invariant hold, and a loaded machine
|
|
300
|
+
// can deschedule the appender between its open and its write for
|
|
301
|
+
// longer than a token grace would cover. Larger passes keep the run
|
|
302
|
+
// short with the full grace paid per pass.
|
|
303
|
+
const outcome = await lostUnder(
|
|
304
|
+
(path, pass) => consumeBufferEntries(path, pass),
|
|
305
|
+
APPENDS,
|
|
306
|
+
500,
|
|
307
|
+
);
|
|
308
|
+
expect(outcome.passes).toBeGreaterThan(0);
|
|
309
|
+
expect(outcome.lost).toEqual([]);
|
|
310
|
+
expect(outcome.duplicated).toEqual([]);
|
|
311
|
+
}, 60_000);
|
|
312
|
+
|
|
313
|
+
test("the naive read-modify-write consume loses entries under the same load", async () => {
|
|
314
|
+
// Sensitivity check for the harness: the mechanism above is only
|
|
315
|
+
// evidence if this shape, run identically, fails.
|
|
316
|
+
const outcome = await lostUnder(naiveConsume, APPENDS, 500);
|
|
317
|
+
expect(outcome.passes).toBeGreaterThan(0);
|
|
318
|
+
expect(outcome.lost.length).toBeGreaterThan(0);
|
|
319
|
+
}, 60_000);
|
|
320
|
+
});
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
import { describe, expect, test } from "bun:test";
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
+
bufferEntryText,
|
|
13
14
|
formatBufferTimestamp,
|
|
14
15
|
formatRememberEntry,
|
|
15
16
|
isBufferEntryStart,
|
|
17
|
+
joinBufferEntries,
|
|
16
18
|
matchBufferEntryStart,
|
|
19
|
+
splitBufferContent,
|
|
17
20
|
splitBufferEntries,
|
|
18
21
|
} from "../buffer-format.js";
|
|
19
22
|
|
|
@@ -202,3 +205,43 @@ describe("splitBufferEntries", () => {
|
|
|
202
205
|
expect(splitBufferEntries([])).toEqual([]);
|
|
203
206
|
});
|
|
204
207
|
});
|
|
208
|
+
|
|
209
|
+
describe("splitBufferContent / joinBufferEntries", () => {
|
|
210
|
+
const content =
|
|
211
|
+
formatRememberEntry("Alice prefers dark mode", new Date(2026, 0, 1, 9, 0)) +
|
|
212
|
+
formatRememberEntry(
|
|
213
|
+
"Bob's plan:\n- [ ] step one\n\nend",
|
|
214
|
+
new Date(2026, 0, 1, 9, 1),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
test("round-trips newline-terminated content byte for byte", () => {
|
|
218
|
+
expect(joinBufferEntries(splitBufferContent(content))).toBe(content);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("an entry's text is the same whether or not it is last in the file", () => {
|
|
222
|
+
const [first] = splitBufferContent(content);
|
|
223
|
+
const [alone] = splitBufferContent(
|
|
224
|
+
formatRememberEntry(
|
|
225
|
+
"Alice prefers dark mode",
|
|
226
|
+
new Date(2026, 0, 1, 9, 0),
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
expect(bufferEntryText(alone!)).toBe(bufferEntryText(first!));
|
|
230
|
+
expect(bufferEntryText(alone!)).toBe(
|
|
231
|
+
"- [Jan 1, 9:00 AM] Alice prefers dark mode",
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("empty content has no entries and joins back to empty", () => {
|
|
236
|
+
expect(splitBufferContent("")).toEqual([]);
|
|
237
|
+
expect(joinBufferEntries([])).toBe("");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("content without a terminating newline keeps its last line intact", () => {
|
|
241
|
+
const entries = splitBufferContent("- [Jan 1, 9:00 AM] partial appe");
|
|
242
|
+
expect(entries).toHaveLength(1);
|
|
243
|
+
expect(bufferEntryText(entries[0]!)).toBe(
|
|
244
|
+
"- [Jan 1, 9:00 AM] partial appe",
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Child process for the buffer-file cross-process test: appends `count`
|
|
2
|
+
// uniquely tagged entries to `bufferPath` as fast as it can, through the same
|
|
3
|
+
// `appendFileSync` call the production appender uses. Hand-formats the entry
|
|
4
|
+
// line so the fixture imports nothing from `src/`.
|
|
5
|
+
//
|
|
6
|
+
// usage: bun buffer-appender.ts <bufferPath> <count> <tag>
|
|
7
|
+
|
|
8
|
+
import { appendFileSync } from "node:fs";
|
|
9
|
+
|
|
10
|
+
const [bufferPath, countArg, tag] = process.argv.slice(2);
|
|
11
|
+
if (!bufferPath || !countArg || !tag) {
|
|
12
|
+
throw new Error("usage: buffer-appender <bufferPath> <count> <tag>");
|
|
13
|
+
}
|
|
14
|
+
const count = Number.parseInt(countArg, 10);
|
|
15
|
+
for (let i = 0; i < count; i++) {
|
|
16
|
+
appendFileSync(bufferPath, `- [Apr 27, 9:00 AM] ${tag}-${i}\n`, "utf-8");
|
|
17
|
+
}
|
|
@@ -117,6 +117,17 @@ let loadedConversations: Record<string, { processing: boolean }> = {};
|
|
|
117
117
|
// the stale-flag override's age check. Absent ids read as null (stampless).
|
|
118
118
|
let processingStartedAtById: Record<string, number | null> = {};
|
|
119
119
|
|
|
120
|
+
// The source conversation's recorded wire tool surface (what its last live
|
|
121
|
+
// turn sent to the provider). `null` = no live turn has recorded one yet.
|
|
122
|
+
let mockSourceToolSurface: Array<Record<string, unknown>> | null = null;
|
|
123
|
+
let toolSurfaceReads: string[] = [];
|
|
124
|
+
mock.module("../../../../persistence/conversation-tool-surface.js", () => ({
|
|
125
|
+
getConversationToolSurface: (conversationId: string) => {
|
|
126
|
+
toolSurfaceReads.push(conversationId);
|
|
127
|
+
return mockSourceToolSurface;
|
|
128
|
+
},
|
|
129
|
+
}));
|
|
130
|
+
|
|
120
131
|
const watchdogEvents: Array<{
|
|
121
132
|
checkName: string;
|
|
122
133
|
value?: number | null;
|
|
@@ -509,6 +520,8 @@ describe("memoryRetrospectiveJob", () => {
|
|
|
509
520
|
resolveUserSlugCalls = [];
|
|
510
521
|
mockV3TierActive = true;
|
|
511
522
|
mockSkillImprovementActive = false;
|
|
523
|
+
mockSourceToolSurface = null;
|
|
524
|
+
toolSurfaceReads = [];
|
|
512
525
|
});
|
|
513
526
|
|
|
514
527
|
test("first-run happy path: no state row, no prior retrospective, both pointer fields set on success", async () => {
|
|
@@ -1842,6 +1855,39 @@ describe("memoryRetrospectiveJob", () => {
|
|
|
1842
1855
|
});
|
|
1843
1856
|
});
|
|
1844
1857
|
|
|
1858
|
+
test("replays the source's recorded wire tool surface verbatim on the fork wake", async () => {
|
|
1859
|
+
mockSourceToolSurface = [
|
|
1860
|
+
{ name: "bash", description: "Run", input_schema: {} },
|
|
1861
|
+
{ name: "remember", description: "Save", input_schema: {} },
|
|
1862
|
+
{ name: "bell_jingle", description: "Ring", input_schema: {} },
|
|
1863
|
+
{ type: "web_search_20250305", name: "web_search", max_uses: 5 },
|
|
1864
|
+
];
|
|
1865
|
+
|
|
1866
|
+
await memoryRetrospectiveJob(makeJob(), stubConfig);
|
|
1867
|
+
|
|
1868
|
+
expect(toolSurfaceReads).toEqual(["src-conv-1"]);
|
|
1869
|
+
expect(wakeCalls).toHaveLength(1);
|
|
1870
|
+
// The exact recorded array rides to the wake; the fork sends it in place
|
|
1871
|
+
// of the surface it would resolve for itself.
|
|
1872
|
+
expect(wakeCalls[0]!.opts.wireToolDefinitions).toEqual(
|
|
1873
|
+
mockSourceToolSurface,
|
|
1874
|
+
);
|
|
1875
|
+
// The execution-side pin still rides alongside it.
|
|
1876
|
+
expect(wakeCalls[0]!.opts.toolGateMode).toBe("execution");
|
|
1877
|
+
expect(wakeCalls[0]!.opts.toolContextPin).toBeDefined();
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
test("no recorded source surface → no wireToolDefinitions; the pin alone shapes the wire", async () => {
|
|
1881
|
+
mockSourceToolSurface = null;
|
|
1882
|
+
|
|
1883
|
+
await memoryRetrospectiveJob(makeJob(), stubConfig);
|
|
1884
|
+
|
|
1885
|
+
expect(toolSurfaceReads).toEqual(["src-conv-1"]);
|
|
1886
|
+
expect(wakeCalls).toHaveLength(1);
|
|
1887
|
+
expect("wireToolDefinitions" in wakeCalls[0]!.opts).toBe(false);
|
|
1888
|
+
expect(wakeCalls[0]!.opts.toolContextPin).toBeDefined();
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1845
1891
|
test("execution mode is unconditional → toolContextPin rides even without a resolved profile", async () => {
|
|
1846
1892
|
await memoryRetrospectiveJob(
|
|
1847
1893
|
makeJob(),
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `memory-run-evidence.ts`: the readers of what a background
|
|
3
|
+
* memory run durably produced, shared by the retrospective's cursor advance
|
|
4
|
+
* and consolidation's buffer consumption.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
collectSuccessfulToolResultIds,
|
|
11
|
+
countDurableToolUses,
|
|
12
|
+
endsWithTextReply,
|
|
13
|
+
extractRememberContents,
|
|
14
|
+
hasCommittedTextReply,
|
|
15
|
+
type MessageLike,
|
|
16
|
+
} from "../memory-run-evidence.js";
|
|
17
|
+
|
|
18
|
+
const DURABLE = new Set(["file_write", "remember"]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Persisted message rows carry their blocks as a JSON string; the readers
|
|
22
|
+
* parse it. Rows are built that way here so the test exercises the same
|
|
23
|
+
* shape the jobs read.
|
|
24
|
+
*/
|
|
25
|
+
function row(role: string, blocks: unknown[]): MessageLike {
|
|
26
|
+
return { role, content: JSON.stringify(blocks) };
|
|
27
|
+
}
|
|
28
|
+
const use = (id: string, name: string, input: unknown = {}) => ({
|
|
29
|
+
type: "tool_use",
|
|
30
|
+
id,
|
|
31
|
+
name,
|
|
32
|
+
input,
|
|
33
|
+
});
|
|
34
|
+
const result = (id: string, isError = false) => ({
|
|
35
|
+
type: "tool_result",
|
|
36
|
+
tool_use_id: id,
|
|
37
|
+
is_error: isError,
|
|
38
|
+
content: "…",
|
|
39
|
+
});
|
|
40
|
+
const text = (value: string) => ({ type: "text", text: value });
|
|
41
|
+
|
|
42
|
+
describe("collectSuccessfulToolResultIds", () => {
|
|
43
|
+
test("collects non-error tool_result ids from user rows only, tolerating malformed rows", () => {
|
|
44
|
+
const ids = collectSuccessfulToolResultIds([
|
|
45
|
+
row("user", [result("ok"), result("bad", true)]),
|
|
46
|
+
row("assistant", [result("not-a-user-row")]),
|
|
47
|
+
{ role: "user", content: "{not json" },
|
|
48
|
+
row("user", [result("second")]),
|
|
49
|
+
]);
|
|
50
|
+
expect([...ids].sort()).toEqual(["ok", "second"]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("countDurableToolUses", () => {
|
|
55
|
+
const messages = [
|
|
56
|
+
row("assistant", [
|
|
57
|
+
use("a", "file_write"),
|
|
58
|
+
use("b", "file_write"),
|
|
59
|
+
use("c", "file_read"),
|
|
60
|
+
]),
|
|
61
|
+
row("user", [result("a"), result("b", true), result("c")]),
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
test("counts only the named tools, and with a success set only verified executions", () => {
|
|
65
|
+
expect(countDurableToolUses(messages, DURABLE, null)).toBe(2);
|
|
66
|
+
expect(
|
|
67
|
+
countDurableToolUses(
|
|
68
|
+
messages,
|
|
69
|
+
DURABLE,
|
|
70
|
+
collectSuccessfulToolResultIds(messages),
|
|
71
|
+
),
|
|
72
|
+
).toBe(1);
|
|
73
|
+
expect(countDurableToolUses(messages, new Set(["file_read"]), null)).toBe(
|
|
74
|
+
1,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("hasCommittedTextReply", () => {
|
|
80
|
+
test("true only when the LAST assistant row carries non-blank text", () => {
|
|
81
|
+
expect(
|
|
82
|
+
hasCommittedTextReply([
|
|
83
|
+
row("assistant", [text("narrating")]),
|
|
84
|
+
row("assistant", [text(" ")]),
|
|
85
|
+
]),
|
|
86
|
+
).toBe(false);
|
|
87
|
+
expect(
|
|
88
|
+
hasCommittedTextReply([
|
|
89
|
+
row("assistant", [use("a", "file_write")]),
|
|
90
|
+
row("user", [result("a")]),
|
|
91
|
+
row("assistant", [text("Done.")]),
|
|
92
|
+
]),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
expect(hasCommittedTextReply([{ role: "assistant", content: "{" }])).toBe(
|
|
95
|
+
false,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("endsWithTextReply", () => {
|
|
101
|
+
test("true only when the final row is an assistant reply with text and no tool call", () => {
|
|
102
|
+
expect(
|
|
103
|
+
endsWithTextReply([
|
|
104
|
+
row("assistant", [use("a", "file_write")]),
|
|
105
|
+
row("user", [result("a")]),
|
|
106
|
+
row("assistant", [text("Filed it.")]),
|
|
107
|
+
]),
|
|
108
|
+
).toBe(true);
|
|
109
|
+
// Narration on the row that also calls a tool, then a tool result: the
|
|
110
|
+
// run stopped mid-loop, whatever the narration said.
|
|
111
|
+
expect(
|
|
112
|
+
endsWithTextReply([
|
|
113
|
+
row("assistant", [
|
|
114
|
+
text("Fixing that page now."),
|
|
115
|
+
use("a", "file_write"),
|
|
116
|
+
]),
|
|
117
|
+
row("user", [result("a")]),
|
|
118
|
+
]),
|
|
119
|
+
).toBe(false);
|
|
120
|
+
expect(
|
|
121
|
+
endsWithTextReply([
|
|
122
|
+
row("assistant", [text("Done."), use("b", "file_read")]),
|
|
123
|
+
]),
|
|
124
|
+
).toBe(false);
|
|
125
|
+
expect(endsWithTextReply([row("assistant", [text(" ")])])).toBe(false);
|
|
126
|
+
expect(endsWithTextReply([{ role: "assistant", content: "{" }])).toBe(
|
|
127
|
+
false,
|
|
128
|
+
);
|
|
129
|
+
expect(endsWithTextReply([])).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("extractRememberContents", () => {
|
|
134
|
+
const messages = [
|
|
135
|
+
row("assistant", [
|
|
136
|
+
use("a", "remember", { content: " one " }),
|
|
137
|
+
use("b", "remember", { content: ["two", "", "three"] }),
|
|
138
|
+
use("c", "remember", { content: "failed" }),
|
|
139
|
+
use("d", "file_write", { content: "not a remember" }),
|
|
140
|
+
]),
|
|
141
|
+
row("user", [result("a"), result("b"), result("c", true)]),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
test("flattens batched facts, trims, and drops blanks", () => {
|
|
145
|
+
expect(extractRememberContents(messages)).toEqual([
|
|
146
|
+
"one",
|
|
147
|
+
"two",
|
|
148
|
+
"three",
|
|
149
|
+
"failed",
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("with a success set, a failed remember's facts are excluded", () => {
|
|
154
|
+
expect(
|
|
155
|
+
extractRememberContents(
|
|
156
|
+
messages,
|
|
157
|
+
collectSuccessfulToolResultIds(messages),
|
|
158
|
+
),
|
|
159
|
+
).toEqual(["one", "two", "three"]);
|
|
160
|
+
});
|
|
161
|
+
});
|