recallnest 2.5.4 → 2.6.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/CHANGELOG.md +37 -0
- package/README.md +34 -9
- package/README_CN.md +37 -12
- package/package.json +4 -4
- package/src/__tests__/access-tracker-novelty.test.ts +66 -0
- package/src/__tests__/api-server-security.test.ts +102 -0
- package/src/__tests__/belief-history.test.ts +220 -0
- package/src/__tests__/capture-engine.test.ts +93 -3
- package/src/__tests__/category-min-scores.test.ts +83 -0
- package/src/__tests__/cluster-consolidation.test.ts +167 -4
- package/src/__tests__/conflict-store.test.ts +46 -2
- package/src/__tests__/consolidation-engine.test.ts +97 -0
- package/src/__tests__/contracts.test.ts +46 -0
- package/src/__tests__/decay-exemption.test.ts +45 -0
- package/src/__tests__/dream-pipeline.test.ts +464 -5
- package/src/__tests__/freshness.test.ts +186 -0
- package/src/__tests__/hotness-blend.test.ts +207 -0
- package/src/__tests__/llm-client-pivot.test.ts +628 -0
- package/src/__tests__/maintenance-locks.test.ts +15 -0
- package/src/__tests__/memory-boundaries.test.ts +32 -0
- package/src/__tests__/noise-prototype-bank.test.ts +280 -0
- package/src/__tests__/pii-redact.test.ts +19 -0
- package/src/__tests__/pivot-apply.test.ts +373 -0
- package/src/__tests__/pivot-distill-supervisor.test.ts +457 -0
- package/src/__tests__/pivot-distill.test.ts +1178 -0
- package/src/__tests__/preference-matcher.test.ts +54 -2
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +679 -0
- package/src/__tests__/recall-gate.test.ts +266 -0
- package/src/__tests__/resolve-db-path.test.ts +130 -0
- package/src/__tests__/resolve-tier.test.ts +64 -0
- package/src/__tests__/retrieval-eval.test.ts +163 -0
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +123 -0
- package/src/__tests__/skill-promotion.test.ts +53 -0
- package/src/__tests__/stable-context-length-contract.test.ts +62 -0
- package/src/__tests__/store-read-consistency.test.ts +122 -0
- package/src/__tests__/store-schema-migration.test.ts +87 -0
- package/src/__tests__/store-upsert.test.ts +17 -0
- package/src/__tests__/term-registry-terminal-roster.test.ts +127 -0
- package/src/__tests__/ui-server-security.test.ts +93 -0
- package/src/access-tracker.ts +53 -3
- package/src/adaptive-retrieval.ts +11 -0
- package/src/api-server.ts +9 -2
- package/src/auto-recall.ts +61 -1
- package/src/belief-history.ts +164 -0
- package/src/capture-engine.ts +88 -11
- package/src/cli.ts +109 -14
- package/src/conflict-engine.ts +24 -3
- package/src/consolidation-engine.ts +146 -12
- package/src/context-composer-stable-selection.ts +7 -1
- package/src/data-checkup.ts +1 -1
- package/src/decay-engine.ts +59 -7
- package/src/doctor.ts +4 -2
- package/src/dream-pipeline.ts +359 -11
- package/src/env-config.ts +41 -0
- package/src/eval.ts +154 -11
- package/src/freshness.ts +192 -0
- package/src/http-request-policy.ts +148 -0
- package/src/ingest.ts +277 -2
- package/src/kg-extractor.ts +7 -1
- package/src/llm-client.ts +106 -11
- package/src/mcp-server.ts +2 -1
- package/src/mcp-tools-advanced.ts +13 -0
- package/src/mcp-tools-core.ts +5 -1
- package/src/memory-boundaries.ts +23 -2
- package/src/memory-health-rebalance.ts +4 -0
- package/src/memory-output.ts +16 -2
- package/src/memory-store-port.ts +3 -0
- package/src/noise-prototype-bank.ts +408 -0
- package/src/pii-detector.ts +52 -2
- package/src/pivot-apply.ts +399 -0
- package/src/preference-matcher.ts +37 -3
- package/src/recall-gate.ts +305 -0
- package/src/retrieval-profiles.ts +46 -4
- package/src/retriever.ts +95 -18
- package/src/retroactive-boost.ts +6 -2
- package/src/runtime-config.ts +70 -5
- package/src/skill-promotion.ts +58 -2
- package/src/store.ts +54 -19
- package/src/term-registry.ts +35 -0
- package/src/ui-server.ts +7 -0
- package/src/version.ts +19 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { LLMClient } from "../llm-client.js";
|
|
8
|
+
import {
|
|
9
|
+
buildPivotLlmConfig,
|
|
10
|
+
buildPivotRequestProfile,
|
|
11
|
+
buildStratifiedSample,
|
|
12
|
+
loadFrozenBundle,
|
|
13
|
+
main as pivotMain,
|
|
14
|
+
PIPELINE_VERSION,
|
|
15
|
+
PIVOT_MODEL,
|
|
16
|
+
pivotRequestProfileHash,
|
|
17
|
+
PROMPT_VERSION,
|
|
18
|
+
selectionIdentityHash,
|
|
19
|
+
writeFrozenBundle,
|
|
20
|
+
type SessionMeta,
|
|
21
|
+
} from "../../scripts/pivot-distill.js";
|
|
22
|
+
|
|
23
|
+
interface CapturedRequest {
|
|
24
|
+
method: string;
|
|
25
|
+
url: string;
|
|
26
|
+
body: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface LoopbackStub {
|
|
30
|
+
baseURL: string;
|
|
31
|
+
requests: CapturedRequest[];
|
|
32
|
+
close: () => Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface PlannedResponse {
|
|
36
|
+
body: Record<string, unknown>;
|
|
37
|
+
statusCode?: number;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function startLoopbackStub(
|
|
42
|
+
responseBody: Record<string, unknown> | PlannedResponse[],
|
|
43
|
+
statusCode = 200,
|
|
44
|
+
responseHeaders: Record<string, string> = {},
|
|
45
|
+
): Promise<LoopbackStub> {
|
|
46
|
+
const requests: CapturedRequest[] = [];
|
|
47
|
+
const server = createServer(async (request, response) => {
|
|
48
|
+
try {
|
|
49
|
+
const chunks: Buffer[] = [];
|
|
50
|
+
for await (const chunk of request) {
|
|
51
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
52
|
+
}
|
|
53
|
+
const body: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
54
|
+
requests.push({
|
|
55
|
+
method: request.method ?? "",
|
|
56
|
+
url: request.url ?? "",
|
|
57
|
+
body,
|
|
58
|
+
});
|
|
59
|
+
const planned = Array.isArray(responseBody)
|
|
60
|
+
? responseBody[Math.min(requests.length - 1, responseBody.length - 1)]
|
|
61
|
+
: { body: responseBody, statusCode, headers: responseHeaders };
|
|
62
|
+
response.writeHead(planned.statusCode ?? 200, {
|
|
63
|
+
"content-type": "application/json",
|
|
64
|
+
connection: "close",
|
|
65
|
+
...(planned.headers ?? {}),
|
|
66
|
+
});
|
|
67
|
+
response.end(JSON.stringify(planned.body));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
response.writeHead(500, { "content-type": "text/plain", connection: "close" });
|
|
70
|
+
response.end(error instanceof Error ? error.message : "loopback stub error");
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
await new Promise<void>((resolve, reject) => {
|
|
74
|
+
const onError = (error: Error): void => reject(error);
|
|
75
|
+
server.once("error", onError);
|
|
76
|
+
server.listen(0, "127.0.0.1", () => {
|
|
77
|
+
server.off("error", onError);
|
|
78
|
+
resolve();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
const address = server.address();
|
|
82
|
+
if (!address || typeof address === "string") {
|
|
83
|
+
server.close();
|
|
84
|
+
throw new Error("Loopback stub did not receive a TCP address");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
baseURL: `http://127.0.0.1:${address.port}/v1`,
|
|
89
|
+
requests,
|
|
90
|
+
close: async () => {
|
|
91
|
+
await new Promise<void>((resolve, reject) => {
|
|
92
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
93
|
+
server.closeAllConnections();
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function completionResponse(options: {
|
|
100
|
+
content: string | null;
|
|
101
|
+
finishReason: "stop" | "length";
|
|
102
|
+
model: string;
|
|
103
|
+
usage?: Record<string, unknown>;
|
|
104
|
+
}): Record<string, unknown> {
|
|
105
|
+
return {
|
|
106
|
+
id: "chatcmpl-loopback",
|
|
107
|
+
object: "chat.completion",
|
|
108
|
+
created: 0,
|
|
109
|
+
model: options.model,
|
|
110
|
+
choices: [{
|
|
111
|
+
index: 0,
|
|
112
|
+
message: { role: "assistant", content: options.content },
|
|
113
|
+
logprobs: null,
|
|
114
|
+
finish_reason: options.finishReason,
|
|
115
|
+
}],
|
|
116
|
+
...(options.usage ? { usage: options.usage } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("LLMClient pivot detailed chat", () => {
|
|
121
|
+
it("sends the exact Qwen-compatible request body and returns normalized metadata", async () => {
|
|
122
|
+
const stub = await startLoopbackStub(completionResponse({
|
|
123
|
+
content: " {\"ok\":true} ",
|
|
124
|
+
finishReason: "stop",
|
|
125
|
+
model: "qwen-response-snapshot",
|
|
126
|
+
usage: {
|
|
127
|
+
prompt_tokens: 120,
|
|
128
|
+
completion_tokens: 18,
|
|
129
|
+
total_tokens: 138,
|
|
130
|
+
prompt_tokens_details: { cached_tokens: 12 },
|
|
131
|
+
completion_tokens_details: { reasoning_tokens: 3 },
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
try {
|
|
135
|
+
const unsafeBaseURL = stub.baseURL.replace("http://", "http://user:password@") +
|
|
136
|
+
"?token=secret#fragment";
|
|
137
|
+
const profile = buildPivotRequestProfile(unsafeBaseURL);
|
|
138
|
+
const client = new LLMClient(buildPivotLlmConfig({
|
|
139
|
+
apiKey: "loopback-test-key",
|
|
140
|
+
model: "qwen-request-snapshot",
|
|
141
|
+
baseURL: unsafeBaseURL,
|
|
142
|
+
}, profile, 1_000));
|
|
143
|
+
|
|
144
|
+
const result = await client.chatLongDetailed("system prompt", "user prompt", {
|
|
145
|
+
temperature: 0.05,
|
|
146
|
+
enableThinking: false,
|
|
147
|
+
responseFormat: { type: "json_object" },
|
|
148
|
+
tokenLimit: { parameter: "max_tokens", value: 321 },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(stub.requests).toHaveLength(1);
|
|
152
|
+
expect(stub.requests[0]).toEqual({
|
|
153
|
+
method: "POST",
|
|
154
|
+
url: "/v1/chat/completions",
|
|
155
|
+
body: {
|
|
156
|
+
model: PIVOT_MODEL,
|
|
157
|
+
messages: [
|
|
158
|
+
{ role: "system", content: "system prompt" },
|
|
159
|
+
{ role: "user", content: "user prompt" },
|
|
160
|
+
],
|
|
161
|
+
temperature: 0.05,
|
|
162
|
+
max_tokens: 321,
|
|
163
|
+
response_format: { type: "json_object" },
|
|
164
|
+
enable_thinking: false,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
expect(result).toEqual({
|
|
168
|
+
content: "{\"ok\":true}",
|
|
169
|
+
finishReason: "stop",
|
|
170
|
+
responseModel: "qwen-response-snapshot",
|
|
171
|
+
usage: {
|
|
172
|
+
promptTokens: 120,
|
|
173
|
+
completionTokens: 18,
|
|
174
|
+
totalTokens: 138,
|
|
175
|
+
cachedPromptTokens: 12,
|
|
176
|
+
reasoningTokens: 3,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
} finally {
|
|
180
|
+
await stub.close();
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("disables hidden SDK retries so one outer attempt sends one HTTP request", async () => {
|
|
185
|
+
const stub = await startLoopbackStub({
|
|
186
|
+
error: { message: "temporary failure", type: "server_error" },
|
|
187
|
+
}, 500, { "retry-after": "0" });
|
|
188
|
+
try {
|
|
189
|
+
const client = new LLMClient({
|
|
190
|
+
apiKey: "loopback-test-key",
|
|
191
|
+
model: "qwen-request-snapshot",
|
|
192
|
+
baseURL: stub.baseURL,
|
|
193
|
+
timeoutMs: 1_000,
|
|
194
|
+
maxRetries: 0,
|
|
195
|
+
});
|
|
196
|
+
await expect(client.chatLongDetailed("system", "user")).rejects.toThrow();
|
|
197
|
+
expect(stub.requests).toHaveLength(1);
|
|
198
|
+
} finally {
|
|
199
|
+
await stub.close();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("supports max_completion_tokens and exposes a length finish reason", async () => {
|
|
204
|
+
const stub = await startLoopbackStub(completionResponse({
|
|
205
|
+
content: "{\"partial\":true}",
|
|
206
|
+
finishReason: "length",
|
|
207
|
+
model: "qwen-response-snapshot",
|
|
208
|
+
}));
|
|
209
|
+
try {
|
|
210
|
+
const client = new LLMClient({
|
|
211
|
+
apiKey: "loopback-test-key",
|
|
212
|
+
model: "qwen-request-snapshot",
|
|
213
|
+
baseURL: stub.baseURL,
|
|
214
|
+
timeoutMs: 1_000,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const result = await client.chatLongDetailed("system", "user", {
|
|
218
|
+
tokenLimit: { parameter: "max_completion_tokens", value: 456 },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(stub.requests[0].body).toEqual({
|
|
222
|
+
model: "qwen-request-snapshot",
|
|
223
|
+
messages: [
|
|
224
|
+
{ role: "system", content: "system" },
|
|
225
|
+
{ role: "user", content: "user" },
|
|
226
|
+
],
|
|
227
|
+
temperature: 0.1,
|
|
228
|
+
max_completion_tokens: 456,
|
|
229
|
+
});
|
|
230
|
+
expect(result).toEqual({
|
|
231
|
+
content: "{\"partial\":true}",
|
|
232
|
+
finishReason: "length",
|
|
233
|
+
responseModel: "qwen-response-snapshot",
|
|
234
|
+
usage: null,
|
|
235
|
+
});
|
|
236
|
+
} finally {
|
|
237
|
+
await stub.close();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("keeps chatLong as a string-or-null max_tokens wrapper", async () => {
|
|
242
|
+
const stub = await startLoopbackStub(completionResponse({
|
|
243
|
+
content: " legacy content ",
|
|
244
|
+
finishReason: "stop",
|
|
245
|
+
model: "legacy-response-model",
|
|
246
|
+
}));
|
|
247
|
+
try {
|
|
248
|
+
const client = new LLMClient({
|
|
249
|
+
apiKey: "loopback-test-key",
|
|
250
|
+
model: "legacy-request-model",
|
|
251
|
+
baseURL: stub.baseURL,
|
|
252
|
+
timeoutMs: 1_000,
|
|
253
|
+
temperature: 0.25,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const result: string | null = await client.chatLong("legacy system", "legacy user", 77);
|
|
257
|
+
|
|
258
|
+
expect(result).toBe("legacy content");
|
|
259
|
+
expect(stub.requests[0].body).toEqual({
|
|
260
|
+
model: "legacy-request-model",
|
|
261
|
+
messages: [
|
|
262
|
+
{ role: "system", content: "legacy system" },
|
|
263
|
+
{ role: "user", content: "legacy user" },
|
|
264
|
+
],
|
|
265
|
+
temperature: 0.25,
|
|
266
|
+
max_tokens: 77,
|
|
267
|
+
});
|
|
268
|
+
} finally {
|
|
269
|
+
await stub.close();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("runs authorized transport only from the frozen bundle with bounded retries and complete audit identity", async () => {
|
|
274
|
+
const invalidUsage = { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 };
|
|
275
|
+
const successUsage = { prompt_tokens: 11, completion_tokens: 3, total_tokens: 14 };
|
|
276
|
+
const success = completionResponse({
|
|
277
|
+
content: JSON.stringify({ hasPivot: false, candidates: [] }),
|
|
278
|
+
finishReason: "stop",
|
|
279
|
+
model: PIVOT_MODEL,
|
|
280
|
+
usage: successUsage,
|
|
281
|
+
});
|
|
282
|
+
const stub = await startLoopbackStub([
|
|
283
|
+
{
|
|
284
|
+
statusCode: 429,
|
|
285
|
+
headers: { "retry-after": "0" },
|
|
286
|
+
body: { error: { message: "rate limited", type: "rate_limit_error" } },
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
body: completionResponse({
|
|
290
|
+
content: "not-json",
|
|
291
|
+
finishReason: "stop",
|
|
292
|
+
model: PIVOT_MODEL,
|
|
293
|
+
usage: invalidUsage,
|
|
294
|
+
}),
|
|
295
|
+
},
|
|
296
|
+
{ body: success },
|
|
297
|
+
]);
|
|
298
|
+
const root = mkdtempSync(join(tmpdir(), "pivot-transport-loopback-"));
|
|
299
|
+
const sourcePath = join(root, "source.jsonl");
|
|
300
|
+
writeFileSync(sourcePath, "{}\n");
|
|
301
|
+
const sourceStat = statSync(sourcePath, { bigint: true });
|
|
302
|
+
const profile = buildPivotRequestProfile(stub.baseURL, 600);
|
|
303
|
+
const descriptor = writeFrozenBundle(root, [{
|
|
304
|
+
session: {
|
|
305
|
+
key: "codex:loopback-session",
|
|
306
|
+
sessionId: "loopback-session",
|
|
307
|
+
rawHarness: "codex",
|
|
308
|
+
harness: "codex",
|
|
309
|
+
parserKind: "codex-rollout",
|
|
310
|
+
project: "test",
|
|
311
|
+
path: sourcePath,
|
|
312
|
+
date: "2026-08-03",
|
|
313
|
+
sizeBytes: Number(sourceStat.size),
|
|
314
|
+
mtimeNs: sourceStat.mtimeNs.toString(),
|
|
315
|
+
origin: "deja",
|
|
316
|
+
fingerprint: "placeholder",
|
|
317
|
+
} satisfies SessionMeta,
|
|
318
|
+
sample: buildStratifiedSample([
|
|
319
|
+
{ role: "user", text: "请保留可以回查的出处" },
|
|
320
|
+
{ role: "assistant", text: "已经记录出处" },
|
|
321
|
+
], 600),
|
|
322
|
+
}], profile, pivotRequestProfileHash(profile));
|
|
323
|
+
const bundleDir = join(root, "input-bundle");
|
|
324
|
+
const loaded = loadFrozenBundle(bundleDir, descriptor.bundleHash, descriptor.requestProfileHash);
|
|
325
|
+
const entry = loaded.entries[0];
|
|
326
|
+
const configPath = join(root, "config.json");
|
|
327
|
+
writeFileSync(configPath, JSON.stringify({
|
|
328
|
+
llm: { apiKey: "loopback-test-key", model: "qwen-turbo", baseURL: stub.baseURL },
|
|
329
|
+
}));
|
|
330
|
+
const outputDir = join(root, "transport-output");
|
|
331
|
+
mkdirSync(outputDir, { mode: 0o755 });
|
|
332
|
+
const dejaMarker = join(root, "deja-was-called");
|
|
333
|
+
const fakeDeja = join(root, "must-not-run-deja.sh");
|
|
334
|
+
writeFileSync(fakeDeja, `#!/bin/sh\ntouch "${dejaMarker}"\nexit 99\n`, { mode: 0o700 });
|
|
335
|
+
chmodSync(fakeDeja, 0o700);
|
|
336
|
+
const args = [
|
|
337
|
+
"--mode", "transport",
|
|
338
|
+
"--input-bundle", bundleDir,
|
|
339
|
+
"--bundle-hash", descriptor.bundleHash,
|
|
340
|
+
"--request-profile-hash", descriptor.requestProfileHash,
|
|
341
|
+
"--session-key", entry.session.key,
|
|
342
|
+
"--output-dir", outputDir,
|
|
343
|
+
"--deja-bin", fakeDeja,
|
|
344
|
+
"--allow-external-llm",
|
|
345
|
+
];
|
|
346
|
+
const previousConfig = process.env.LOCAL_MEMORY_CONFIG;
|
|
347
|
+
process.env.LOCAL_MEMORY_CONFIG = configPath;
|
|
348
|
+
try {
|
|
349
|
+
await pivotMain(args);
|
|
350
|
+
expect(stub.requests).toHaveLength(3);
|
|
351
|
+
expect(stub.requests.every((request) => request.url === "/v1/chat/completions")).toBe(true);
|
|
352
|
+
expect(existsSync(dejaMarker)).toBe(false);
|
|
353
|
+
const resultPath = join(outputDir, "results", `${entry.session.fingerprint}.json`);
|
|
354
|
+
const result = JSON.parse(readFileSync(resultPath, "utf8")) as {
|
|
355
|
+
mode: string;
|
|
356
|
+
selectionHash: string;
|
|
357
|
+
outboundSampleSha256: string;
|
|
358
|
+
status: string;
|
|
359
|
+
attempts: number;
|
|
360
|
+
responseModel: string;
|
|
361
|
+
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
|
362
|
+
attemptErrors: string[];
|
|
363
|
+
};
|
|
364
|
+
expect(result).toMatchObject({
|
|
365
|
+
mode: "transport",
|
|
366
|
+
outboundSampleSha256: entry.sampleSha256,
|
|
367
|
+
status: "ok",
|
|
368
|
+
attempts: 3,
|
|
369
|
+
responseModel: PIVOT_MODEL,
|
|
370
|
+
usage: { promptTokens: 21, completionTokens: 5, totalTokens: 26 },
|
|
371
|
+
});
|
|
372
|
+
expect(result.selectionHash).toHaveLength(64);
|
|
373
|
+
expect(result.attemptErrors).toHaveLength(2);
|
|
374
|
+
const invalid = JSON.parse(
|
|
375
|
+
readFileSync(join(outputDir, "invalid", `${entry.session.fingerprint}.json`), "utf8"),
|
|
376
|
+
) as { attempts: Array<{ usage?: { promptTokens: number; completionTokens: number; totalTokens: number } }> };
|
|
377
|
+
expect(invalid.attempts).toHaveLength(2);
|
|
378
|
+
expect(invalid.attempts[1].usage).toEqual({
|
|
379
|
+
promptTokens: 10, completionTokens: 2, totalTokens: 12,
|
|
380
|
+
});
|
|
381
|
+
const summary = JSON.parse(readFileSync(join(outputDir, "summary.json"), "utf8")) as Record<string, unknown>;
|
|
382
|
+
const reportState = JSON.parse(
|
|
383
|
+
readFileSync(join(outputDir, "report-state.json"), "utf8"),
|
|
384
|
+
) as Record<string, unknown>;
|
|
385
|
+
expect(summary).toMatchObject({
|
|
386
|
+
reportStatus: "complete",
|
|
387
|
+
mode: "transport",
|
|
388
|
+
selectionHash: result.selectionHash,
|
|
389
|
+
manifestSessions: 1,
|
|
390
|
+
resultSessions: 1,
|
|
391
|
+
usage: { promptTokens: 21, completionTokens: 5, totalTokens: 26 },
|
|
392
|
+
attemptAudit: {
|
|
393
|
+
validLedgers: 1,
|
|
394
|
+
invalidLedgers: 0,
|
|
395
|
+
startedAttempts: 3,
|
|
396
|
+
inFlightAttempts: 0,
|
|
397
|
+
recordedUsage: { promptTokens: 21, completionTokens: 5, totalTokens: 26 },
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
expect(reportState).toMatchObject({
|
|
401
|
+
status: "complete", mode: "transport", selectionHash: result.selectionHash,
|
|
402
|
+
});
|
|
403
|
+
expect(statSync(outputDir).mode & 0o777).toBe(0o700);
|
|
404
|
+
expect(statSync(join(outputDir, "results")).mode & 0o777).toBe(0o700);
|
|
405
|
+
expect(statSync(join(outputDir, "attempts")).mode & 0o777).toBe(0o700);
|
|
406
|
+
expect(statSync(resultPath).mode & 0o777).toBe(0o600);
|
|
407
|
+
expect(statSync(join(outputDir, "attempts", `${entry.session.fingerprint}.json`)).mode & 0o777)
|
|
408
|
+
.toBe(0o600);
|
|
409
|
+
expect(statSync(join(outputDir, "summary.json")).mode & 0o777).toBe(0o600);
|
|
410
|
+
|
|
411
|
+
await pivotMain(args);
|
|
412
|
+
expect(stub.requests).toHaveLength(3);
|
|
413
|
+
result.responseModel = "tampered-model";
|
|
414
|
+
writeFileSync(resultPath, `${JSON.stringify(result)}\n`);
|
|
415
|
+
await expect(pivotMain(args)).rejects.toThrow("failed strict identity validation");
|
|
416
|
+
expect(stub.requests).toHaveLength(3);
|
|
417
|
+
expect(JSON.parse(readFileSync(join(outputDir, "report-state.json"), "utf8")))
|
|
418
|
+
.toMatchObject({ status: "partial" });
|
|
419
|
+
expect(JSON.parse(readFileSync(join(outputDir, "summary.json"), "utf8")))
|
|
420
|
+
.toMatchObject({ reportStatus: "partial", resultSessions: 0 });
|
|
421
|
+
} finally {
|
|
422
|
+
if (previousConfig === undefined) delete process.env.LOCAL_MEMORY_CONFIG;
|
|
423
|
+
else process.env.LOCAL_MEMORY_CONFIG = previousConfig;
|
|
424
|
+
await stub.close();
|
|
425
|
+
}
|
|
426
|
+
}, 15_000);
|
|
427
|
+
|
|
428
|
+
it("preserves empty-content usage and refuses to reset a failed run's three-attempt budget", async () => {
|
|
429
|
+
const usage = [
|
|
430
|
+
{ prompt_tokens: 4, completion_tokens: 1, total_tokens: 5 },
|
|
431
|
+
{ prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
|
432
|
+
{ prompt_tokens: 6, completion_tokens: 3, total_tokens: 9 },
|
|
433
|
+
];
|
|
434
|
+
const stub = await startLoopbackStub(usage.map((attemptUsage, index) => ({
|
|
435
|
+
body: completionResponse({
|
|
436
|
+
content: index === 0 ? null : "not-json",
|
|
437
|
+
finishReason: "stop",
|
|
438
|
+
model: PIVOT_MODEL,
|
|
439
|
+
usage: attemptUsage,
|
|
440
|
+
}),
|
|
441
|
+
})));
|
|
442
|
+
const root = mkdtempSync(join(tmpdir(), "pivot-failed-resume-loopback-"));
|
|
443
|
+
const sourcePath = join(root, "source.jsonl");
|
|
444
|
+
writeFileSync(sourcePath, "{}\n");
|
|
445
|
+
const sourceStat = statSync(sourcePath, { bigint: true });
|
|
446
|
+
const profile = buildPivotRequestProfile(stub.baseURL, 600);
|
|
447
|
+
const descriptor = writeFrozenBundle(root, [{
|
|
448
|
+
session: {
|
|
449
|
+
key: "codex:failed-resume-session",
|
|
450
|
+
sessionId: "failed-resume-session",
|
|
451
|
+
rawHarness: "codex",
|
|
452
|
+
harness: "codex",
|
|
453
|
+
parserKind: "codex-rollout",
|
|
454
|
+
project: "test",
|
|
455
|
+
path: sourcePath,
|
|
456
|
+
date: "2026-08-03",
|
|
457
|
+
sizeBytes: Number(sourceStat.size),
|
|
458
|
+
mtimeNs: sourceStat.mtimeNs.toString(),
|
|
459
|
+
origin: "deja",
|
|
460
|
+
fingerprint: "placeholder",
|
|
461
|
+
} satisfies SessionMeta,
|
|
462
|
+
sample: buildStratifiedSample([
|
|
463
|
+
{ role: "user", text: "请保留可以回查的出处" },
|
|
464
|
+
{ role: "assistant", text: "已经记录出处" },
|
|
465
|
+
], 600),
|
|
466
|
+
}], profile, pivotRequestProfileHash(profile));
|
|
467
|
+
const bundleDir = join(root, "input-bundle");
|
|
468
|
+
const entry = loadFrozenBundle(
|
|
469
|
+
bundleDir, descriptor.bundleHash, descriptor.requestProfileHash,
|
|
470
|
+
).entries[0];
|
|
471
|
+
const configPath = join(root, "config.json");
|
|
472
|
+
writeFileSync(configPath, JSON.stringify({
|
|
473
|
+
llm: { apiKey: "loopback-test-key", model: "qwen-turbo", baseURL: stub.baseURL },
|
|
474
|
+
}));
|
|
475
|
+
const outputDir = join(root, "transport-output");
|
|
476
|
+
const args = [
|
|
477
|
+
"--mode", "transport",
|
|
478
|
+
"--input-bundle", bundleDir,
|
|
479
|
+
"--bundle-hash", descriptor.bundleHash,
|
|
480
|
+
"--request-profile-hash", descriptor.requestProfileHash,
|
|
481
|
+
"--session-key", entry.session.key,
|
|
482
|
+
"--output-dir", outputDir,
|
|
483
|
+
"--allow-external-llm",
|
|
484
|
+
];
|
|
485
|
+
const previousConfig = process.env.LOCAL_MEMORY_CONFIG;
|
|
486
|
+
process.env.LOCAL_MEMORY_CONFIG = configPath;
|
|
487
|
+
try {
|
|
488
|
+
await expect(pivotMain(args)).rejects.toThrow("Stopped after 3 failed attempt(s)");
|
|
489
|
+
expect(stub.requests).toHaveLength(3);
|
|
490
|
+
const resultPath = join(outputDir, "results", `${entry.session.fingerprint}.json`);
|
|
491
|
+
const resultBeforeResume = readFileSync(resultPath, "utf8");
|
|
492
|
+
const result = JSON.parse(resultBeforeResume) as {
|
|
493
|
+
status: string;
|
|
494
|
+
attempts: number;
|
|
495
|
+
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
|
496
|
+
attemptErrors: string[];
|
|
497
|
+
};
|
|
498
|
+
expect(result).toMatchObject({
|
|
499
|
+
status: "invalid",
|
|
500
|
+
attempts: 3,
|
|
501
|
+
usage: { promptTokens: 15, completionTokens: 6, totalTokens: 21 },
|
|
502
|
+
});
|
|
503
|
+
expect(result.attemptErrors).toHaveLength(3);
|
|
504
|
+
const invalid = JSON.parse(
|
|
505
|
+
readFileSync(join(outputDir, "invalid", `${entry.session.fingerprint}.json`), "utf8"),
|
|
506
|
+
) as {
|
|
507
|
+
recovered: boolean;
|
|
508
|
+
attempts: Array<{
|
|
509
|
+
responseModel?: string;
|
|
510
|
+
finishReason?: string | null;
|
|
511
|
+
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
|
512
|
+
}>;
|
|
513
|
+
};
|
|
514
|
+
expect(invalid.recovered).toBeFalse();
|
|
515
|
+
expect(invalid.attempts).toHaveLength(3);
|
|
516
|
+
expect(invalid.attempts[0]).toMatchObject({
|
|
517
|
+
responseModel: PIVOT_MODEL,
|
|
518
|
+
finishReason: "stop",
|
|
519
|
+
usage: { promptTokens: 4, completionTokens: 1, totalTokens: 5 },
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
await expect(pivotMain(args)).rejects.toThrow("already records 3 attempt(s)");
|
|
523
|
+
expect(stub.requests).toHaveLength(3);
|
|
524
|
+
expect(readFileSync(resultPath, "utf8")).toBe(resultBeforeResume);
|
|
525
|
+
} finally {
|
|
526
|
+
if (previousConfig === undefined) delete process.env.LOCAL_MEMORY_CONFIG;
|
|
527
|
+
else process.env.LOCAL_MEMORY_CONFIG = previousConfig;
|
|
528
|
+
await stub.close();
|
|
529
|
+
}
|
|
530
|
+
}, 15_000);
|
|
531
|
+
|
|
532
|
+
it("refuses a crash-marked in-flight attempt without sending another request", async () => {
|
|
533
|
+
const stub = await startLoopbackStub(completionResponse({
|
|
534
|
+
content: JSON.stringify({ hasPivot: false, candidates: [] }),
|
|
535
|
+
finishReason: "stop",
|
|
536
|
+
model: PIVOT_MODEL,
|
|
537
|
+
}));
|
|
538
|
+
const root = mkdtempSync(join(tmpdir(), "pivot-in-flight-loopback-"));
|
|
539
|
+
const sourcePath = join(root, "source.jsonl");
|
|
540
|
+
writeFileSync(sourcePath, "{}\n");
|
|
541
|
+
const sourceStat = statSync(sourcePath, { bigint: true });
|
|
542
|
+
const profile = buildPivotRequestProfile(stub.baseURL, 600);
|
|
543
|
+
const descriptor = writeFrozenBundle(root, [{
|
|
544
|
+
session: {
|
|
545
|
+
key: "codex:in-flight-session",
|
|
546
|
+
sessionId: "in-flight-session",
|
|
547
|
+
rawHarness: "codex",
|
|
548
|
+
harness: "codex",
|
|
549
|
+
parserKind: "codex-rollout",
|
|
550
|
+
project: "test",
|
|
551
|
+
path: sourcePath,
|
|
552
|
+
date: "2026-08-03",
|
|
553
|
+
sizeBytes: Number(sourceStat.size),
|
|
554
|
+
mtimeNs: sourceStat.mtimeNs.toString(),
|
|
555
|
+
origin: "deja",
|
|
556
|
+
fingerprint: "placeholder",
|
|
557
|
+
} satisfies SessionMeta,
|
|
558
|
+
sample: buildStratifiedSample([
|
|
559
|
+
{ role: "user", text: "崩溃后不要偷偷重发请求" },
|
|
560
|
+
{ role: "assistant", text: "会先保留请求预算记录" },
|
|
561
|
+
], 600),
|
|
562
|
+
}], profile, pivotRequestProfileHash(profile));
|
|
563
|
+
const bundleDir = join(root, "input-bundle");
|
|
564
|
+
const entry = loadFrozenBundle(
|
|
565
|
+
bundleDir, descriptor.bundleHash, descriptor.requestProfileHash,
|
|
566
|
+
).entries[0];
|
|
567
|
+
const configPath = join(root, "config.json");
|
|
568
|
+
writeFileSync(configPath, JSON.stringify({
|
|
569
|
+
llm: { apiKey: "loopback-test-key", model: "qwen-turbo", baseURL: stub.baseURL },
|
|
570
|
+
}));
|
|
571
|
+
const outputDir = join(root, "transport-output");
|
|
572
|
+
const attemptsDir = join(outputDir, "attempts");
|
|
573
|
+
mkdirSync(attemptsDir, { recursive: true, mode: 0o700 });
|
|
574
|
+
const selectionHash = selectionIdentityHash("transport", [entry]);
|
|
575
|
+
writeFileSync(join(attemptsDir, `${entry.session.fingerprint}.json`), `${JSON.stringify({
|
|
576
|
+
schemaVersion: 1,
|
|
577
|
+
mode: "transport",
|
|
578
|
+
selectionHash,
|
|
579
|
+
outboundSampleSha256: entry.sampleSha256,
|
|
580
|
+
promptVersion: PROMPT_VERSION,
|
|
581
|
+
pipelineVersion: PIPELINE_VERSION,
|
|
582
|
+
model: PIVOT_MODEL,
|
|
583
|
+
requestProfileHash: descriptor.requestProfileHash,
|
|
584
|
+
bundleHash: descriptor.bundleHash,
|
|
585
|
+
session: entry.session,
|
|
586
|
+
maxSessionAttempts: profile.retryPolicy.maxSessionAttempts,
|
|
587
|
+
attempts: [{
|
|
588
|
+
attempt: 1,
|
|
589
|
+
state: "in-flight",
|
|
590
|
+
startedAt: "2026-08-03T00:00:00.000Z",
|
|
591
|
+
}],
|
|
592
|
+
updatedAt: "2026-08-03T00:00:00.000Z",
|
|
593
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
594
|
+
const args = [
|
|
595
|
+
"--mode", "transport",
|
|
596
|
+
"--input-bundle", bundleDir,
|
|
597
|
+
"--bundle-hash", descriptor.bundleHash,
|
|
598
|
+
"--request-profile-hash", descriptor.requestProfileHash,
|
|
599
|
+
"--session-key", entry.session.key,
|
|
600
|
+
"--output-dir", outputDir,
|
|
601
|
+
"--allow-external-llm",
|
|
602
|
+
];
|
|
603
|
+
const previousConfig = process.env.LOCAL_MEMORY_CONFIG;
|
|
604
|
+
process.env.LOCAL_MEMORY_CONFIG = configPath;
|
|
605
|
+
try {
|
|
606
|
+
await expect(pivotMain(args)).rejects.toThrow("incomplete attempt ledger");
|
|
607
|
+
expect(stub.requests).toHaveLength(0);
|
|
608
|
+
expect(JSON.parse(readFileSync(join(outputDir, "report-state.json"), "utf8")))
|
|
609
|
+
.toMatchObject({ status: "partial" });
|
|
610
|
+
expect(JSON.parse(readFileSync(join(outputDir, "summary.json"), "utf8")))
|
|
611
|
+
.toMatchObject({
|
|
612
|
+
reportStatus: "partial",
|
|
613
|
+
resultSessions: 0,
|
|
614
|
+
attemptAudit: {
|
|
615
|
+
validLedgers: 1,
|
|
616
|
+
invalidLedgers: 0,
|
|
617
|
+
startedAttempts: 1,
|
|
618
|
+
inFlightAttempts: 1,
|
|
619
|
+
recordedUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
} finally {
|
|
623
|
+
if (previousConfig === undefined) delete process.env.LOCAL_MEMORY_CONFIG;
|
|
624
|
+
else process.env.LOCAL_MEMORY_CONFIG = previousConfig;
|
|
625
|
+
await stub.close();
|
|
626
|
+
}
|
|
627
|
+
}, 15_000);
|
|
628
|
+
});
|
|
@@ -71,6 +71,8 @@ describe("runDream cross-process lock", () => {
|
|
|
71
71
|
it("skips its consolidation phase when the scope's consolidate lock is held", async () => {
|
|
72
72
|
const scope = "project:dream-consolidate-test";
|
|
73
73
|
holdLock(`consolidate-${scope}`); // simulate a standalone consolidate_memories running
|
|
74
|
+
// 2026-08-12:预置写计数,用于在末尾断言「锁被占 = 没真正处理 = 计数必须留着」。
|
|
75
|
+
incrementWriteCount(scope, 15, { statsPath: DREAM_STATS });
|
|
74
76
|
|
|
75
77
|
const activeMeta = JSON.stringify({
|
|
76
78
|
evolution: { status: "active", version: 1, accessCount: 0, lastAccessedAt: null, validFrom: Date.now(), validUntil: null },
|
|
@@ -103,6 +105,13 @@ describe("runDream cross-process lock", () => {
|
|
|
103
105
|
expect(result.ran).toBe(true);
|
|
104
106
|
const consolidatePhase = result.phases.find((p) => p.phase === "consolidate");
|
|
105
107
|
expect(consolidatePhase?.detail).toContain("skipped");
|
|
108
|
+
|
|
109
|
+
// 2026-08-12 防「修过头」:末尾的 reset 必须是 `if (consolidateOutcome.ran)` 条件式。
|
|
110
|
+
// 锁被占时这个 scope 并没有被真正处理完,若无条件清计数,它会被当成「已处理」而
|
|
111
|
+
// 丢掉本轮积累的写入,下次要重新攒够阈值才轮得到 —— 是比原 bug 更隐蔽的数据损失。
|
|
112
|
+
// 注意本条不是必红测试:修复前的无参 resetWriteCount() 什么也不清,同样是绿。
|
|
113
|
+
// 它守的是修复的形状,不是修复的存在(存在性由 dream-pipeline.test.ts 那条守)。
|
|
114
|
+
expect(getWriteCount(scope, { statsPath: DREAM_STATS })).toBe(15);
|
|
106
115
|
});
|
|
107
116
|
|
|
108
117
|
it("resets the scope's write counter after a run (no perpetual self-triggering)", async () => {
|
|
@@ -114,6 +123,12 @@ describe("runDream cross-process lock", () => {
|
|
|
114
123
|
|
|
115
124
|
const store = {
|
|
116
125
|
stats: async () => ({ totalCount: 5, scopeCounts: {}, categoryCounts: {} }),
|
|
126
|
+
// ⚠️ 2026-08-12 标注:这里 list 返回空数组,走的是 **completed_early 早退路径**,
|
|
127
|
+
// 而早退路径 (dream-pipeline.ts:226) 的 resetWriteCount(scope, statsCfg) 一直是对的。
|
|
128
|
+
// 本条测试因此从未覆盖「完整跑完」那条路径 —— 而 bug 恰恰在那条路径上(:301 漏传 scope)。
|
|
129
|
+
// 测试名 "resets the scope's write counter after a run" 读起来像覆盖了 reset 全部行为,
|
|
130
|
+
// 实际只覆盖了两条路径中正确的那一条,于是 bug 在有测试的情况下活了 41 天。
|
|
131
|
+
// 完整路径的覆盖已补在 dream-pipeline.test.ts「清空已处理 scope 的写计数」一条。
|
|
117
132
|
list: async () => [], // no active entries → completed_early path (still resets)
|
|
118
133
|
listPage: async () => [],
|
|
119
134
|
update: async () => null,
|