|
1 | 1 | import { describe, test, expect } from "bun:test"; |
| 2 | +import fs from "node:fs"; |
| 3 | +import os from "node:os"; |
| 4 | +import path from "node:path"; |
2 | 5 | import { createPruningCompactor } from "./compactor.js"; |
3 | 6 | import { condenseTurns } from "./summarizer.js"; |
| 7 | +import { |
| 8 | + createCompactionArchive, |
| 9 | + wrapCompactorWithCompletenessGate, |
| 10 | +} from "./compaction-archive.js"; |
4 | 11 | import { |
5 | 12 | extractContinuationFacts, |
6 | 13 | repairSummary, |
@@ -221,6 +228,37 @@ describe("verifyOrRepair", () => { |
221 | 228 | ); |
222 | 229 | expect(repaired).toContain(VERIFY_REPAIR_HEADING); |
223 | 230 | }); |
| 231 | + |
| 232 | + test("repair lines are goal, next, exact names, then the rest", () => { |
| 233 | + const facts = extractContinuationFacts(droppedTurns()); |
| 234 | + const misses = verifyCompactionSummary("Work continues.", facts).misses; |
| 235 | + const repaired = repairSummary("Work continues.", facts, misses); |
| 236 | + const goalAt = repaired.indexOf("Goal:"); |
| 237 | + const nextAt = repaired.indexOf("Next:"); |
| 238 | + const exactAt = repaired.indexOf("Exact references:"); |
| 239 | + const blockersAt = repaired.indexOf("Open blockers:"); |
| 240 | + const ranAt = repaired.indexOf("Ran:"); |
| 241 | + expect(goalAt).toBeGreaterThan(-1); |
| 242 | + expect(nextAt).toBeGreaterThan(goalAt); |
| 243 | + expect(exactAt).toBeGreaterThan(nextAt); |
| 244 | + expect(blockersAt).toBeGreaterThan(exactAt); |
| 245 | + expect(ranAt).toBeGreaterThan(blockersAt); |
| 246 | + }); |
| 247 | + |
| 248 | + test("a tight cap keeps goal, next, and names before aborting on the tail", () => { |
| 249 | + const facts = extractContinuationFacts(droppedTurns()); |
| 250 | + const misses = verifyCompactionSummary("Work continues.", facts).misses; |
| 251 | + const full = repairSummary("Work continues.", facts, misses); |
| 252 | + const cap = full.indexOf("Open blockers:"); |
| 253 | + expect(cap).toBeGreaterThan(0); |
| 254 | + const outcome = verifyOrRepair("Work continues.", facts, cap); |
| 255 | + expect(outcome.aborted).toBe(true); |
| 256 | + const kinds = outcome.misses.map((m) => m.kind); |
| 257 | + expect(kinds).not.toContain("goal"); |
| 258 | + expect(kinds).not.toContain("nextAction"); |
| 259 | + expect(kinds).not.toContain("exactName"); |
| 260 | + expect(kinds).toContain("verification"); |
| 261 | + }); |
224 | 262 | }); |
225 | 263 |
|
226 | 264 | describe("pruning compactor verify pass", () => { |
@@ -309,6 +347,104 @@ describe("continuation facts survive many folds", () => { |
309 | 347 | }); |
310 | 348 | }); |
311 | 349 |
|
| 350 | +describe("completeness gate plus verify repair", () => { |
| 351 | + function memoryArchive() { |
| 352 | + const dir = fs.mkdtempSync( |
| 353 | + path.join(os.tmpdir(), "compaction-verify-gate-"), |
| 354 | + ); |
| 355 | + const blobs = new Map<string, Uint8Array>(); |
| 356 | + const archive = createCompactionArchive({ |
| 357 | + sessionId: "sess-verify-gate", |
| 358 | + contextDir: dir, |
| 359 | + writeBlob: async (key, bytes) => { |
| 360 | + blobs.set(key, bytes); |
| 361 | + }, |
| 362 | + readBlob: async (key) => { |
| 363 | + const bytes = blobs.get(key); |
| 364 | + if (bytes === undefined) throw new Error(`missing ${key}`); |
| 365 | + return bytes; |
| 366 | + }, |
| 367 | + }); |
| 368 | + return archive; |
| 369 | + } |
| 370 | + |
| 371 | + async function archiveTurns( |
| 372 | + archive: ReturnType<typeof createCompactionArchive>, |
| 373 | + turns: readonly ConversationTurn[], |
| 374 | + ): Promise<void> { |
| 375 | + for (const turn of turns) { |
| 376 | + for (const block of turn.content) { |
| 377 | + if (block.type === "text" && block.text.length > 0) { |
| 378 | + await archive.recordAuthorizedPayload({ |
| 379 | + kind: turn.role === "assistant" ? "assistant_text" : "user_message", |
| 380 | + payload: block.text, |
| 381 | + }); |
| 382 | + } else if (block.type === "tool_call") { |
| 383 | + await archive.recordAuthorizedPayload({ |
| 384 | + kind: "tool_args", |
| 385 | + payload: { name: block.name, arguments: block.arguments }, |
| 386 | + callId: block.id, |
| 387 | + }); |
| 388 | + } else if (block.type === "tool_result") { |
| 389 | + const text = block.content |
| 390 | + .flatMap((c) => (c.type === "text" ? [c.text] : [])) |
| 391 | + .join(""); |
| 392 | + await archive.recordAuthorizedPayload({ |
| 393 | + kind: "tool_result", |
| 394 | + payload: text, |
| 395 | + callId: block.callId, |
| 396 | + }); |
| 397 | + } |
| 398 | + } |
| 399 | + } |
| 400 | + } |
| 401 | + |
| 402 | + test("two lossy folds through the gate keep facts via adopted handoffs", async () => { |
| 403 | + const archive = memoryArchive(); |
| 404 | + const inner = createPruningCompactor({ |
| 405 | + keepRecentTurns: 2, |
| 406 | + summaryMaxChars: 4000, |
| 407 | + summarize: async () => "Work continues. Next: fix tests.", |
| 408 | + }); |
| 409 | + const wrapped = wrapCompactorWithCompletenessGate(inner, archive); |
| 410 | + let turns: ConversationTurn[] = [ |
| 411 | + ...droppedTurns(), |
| 412 | + textTurn("user", "recent ask"), |
| 413 | + textTurn("assistant", "recent reply"), |
| 414 | + ]; |
| 415 | + await archiveTurns(archive, turns); |
| 416 | + |
| 417 | + const first = await wrapped.apply(turns, mockStrategyCtx); |
| 418 | + expect(first.record.reason).not.toBe("incomplete-evidence-archive"); |
| 419 | + expect(first.record.reason).not.toBe( |
| 420 | + "verify failed — keeping prior context", |
| 421 | + ); |
| 422 | + expect(first.record.decisions).toMatchObject({ verifyRepaired: 1 }); |
| 423 | + const handoffs = (await archive.listOccurrences()).filter( |
| 424 | + (occurrence) => occurrence.provenance === "compaction-handoff", |
| 425 | + ); |
| 426 | + expect(handoffs.length).toBeGreaterThan(0); |
| 427 | + expect(allText(first.output)).toContain("opaque tokens"); |
| 428 | + expect(allText(first.output)).toContain("auth.ts"); |
| 429 | + |
| 430 | + const followUp = [ |
| 431 | + textTurn("user", "follow-up after first fold"), |
| 432 | + textTurn("assistant", "progress note after first fold"), |
| 433 | + ]; |
| 434 | + await archiveTurns(archive, followUp); |
| 435 | + turns = [...first.output, ...followUp]; |
| 436 | + |
| 437 | + const second = await wrapped.apply(turns, mockStrategyCtx); |
| 438 | + expect(second.record.reason).not.toBe("incomplete-evidence-archive"); |
| 439 | + expect(second.record.reason).not.toBe( |
| 440 | + "verify failed — keeping prior context", |
| 441 | + ); |
| 442 | + expect(allText(second.output)).toContain("opaque tokens"); |
| 443 | + expect(allText(second.output)).toContain("auth.ts"); |
| 444 | + expect(allText(second.output)).toContain("refresh assertion"); |
| 445 | + }); |
| 446 | +}); |
| 447 | + |
312 | 448 | describe("condenseTurns keep-set", () => { |
313 | 449 | test("pins the standing goal ahead of the recency window", () => { |
314 | 450 | const turns: ConversationTurn[] = [ |
|
0 commit comments