diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c802786..3d3388e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,9 @@ jobs: - name: Test history package run: pnpm --dir components/packages/foundation/history test + - name: Test artifact store package + run: pnpm --dir components/packages/foundation/artifact-store test + - name: Test TokenPilot preset run: pnpm --dir components/presets/tokenpilot test @@ -68,6 +71,9 @@ jobs: - name: Test eviction package run: pnpm --dir components/packages/features/eviction test + - name: Test reduction package + run: pnpm --dir components/packages/features/reduction test + - name: Test DeepSeek Harness adapter run: pnpm --dir components/adapters/deepseek-harness test diff --git a/components/packages/features/reduction/src/passes/pass-tool-payload-trim.ts b/components/packages/features/reduction/src/passes/pass-tool-payload-trim.ts index f52b321e..15e2bcf5 100644 --- a/components/packages/features/reduction/src/passes/pass-tool-payload-trim.ts +++ b/components/packages/features/reduction/src/passes/pass-tool-payload-trim.ts @@ -324,6 +324,7 @@ export const toolPayloadTrimPass: ReductionPassHandler = { sessionId: turnCtx.sessionId, segmentId: segment.id, workspaceDir, + originalText: segment.text, }); const replacementText = reduced.text + buildRecoveryHint({ diff --git a/components/packages/features/reduction/tests/archive-path-stability.test.ts b/components/packages/features/reduction/tests/archive-path-stability.test.ts new file mode 100644 index 00000000..f1581fb1 --- /dev/null +++ b/components/packages/features/reduction/tests/archive-path-stability.test.ts @@ -0,0 +1,102 @@ +/** + * Regression matrix: recovery hints are byte-stable across requests + * + * Adapters re-run these passes over the whole history on every model request. The + * hint's `Archive:` path used to carry a timestamp, so the same tool output was sent + * with different text each time (prompt-cache miss from that point on), and + * tool_payload_trim could name a file other than the one it wrote. + * + * tool_payload_trim + * T1 the same input reduced twice, later, gives byte-identical output + * T2 the hint's Archive path is the file actually written, holding the original text + * T3 different content under the same segment id gets a different Archive path + * exec_output_truncation + * T4 the same input truncated twice, later, gives byte-identical output, and the + * hint's Archive path is the file written + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { RuntimeTurnContext } from "@lightrsi/kernel"; +import { execOutputTruncationBeforeCall } from "../src/passes/pass-exec-output-truncation.js"; +import { toolPayloadTrimPass } from "../src/passes/pass-tool-payload-trim.js"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const archiveOf = (text: string) => /Archive: ([^\n]+)/.exec(text)?.[1]; +const LOG = Array.from({ length: 2000 }, (_, i) => `[build] step ${i} compiling src/module_${i}.ts ... ok`).join("\n"); + +function turnCtx(workspaceDir: string, text: string, strategy: string, toolName = "bash"): RuntimeTurnContext { + return { + sessionId: "stability-session", + sessionMode: "single", + provider: "test", + model: "test", + prompt: "", + budget: { maxInputTokens: 100000, reserveOutputTokens: 1000 }, + segments: [{ + id: "tool-call_1", + kind: "volatile", + priority: 1, + text, + metadata: { toolName, fieldName: "output", toolPayload: { toolName }, isToolPayload: true }, + }], + metadata: { + workspaceDir, + latestUserQuery: "did the build pass", + policy: { decisions: { reduction: { instructions: [{ strategy, segmentIds: ["tool-call_1"], parameters: { payloadKind: "stdout" } }] } } }, + }, + }; +} + +async function trim(workspaceDir: string, text: string): Promise { + const result = await toolPayloadTrimPass.beforeCall?.({ + turnCtx: turnCtx(workspaceDir, text, "tool_payload_trim"), + spec: { id: "tool_payload_trim", phase: "before_call", target: "tool_payload", options: { maxChars: 1200 } }, + }); + assert.equal(result?.changed, true, "precondition: the pass trims"); + return result!.turnCtx!.segments[0]!.text; +} + +async function truncate(workspaceDir: string, text: string): Promise { + const result = await execOutputTruncationBeforeCall.beforeCall?.({ + turnCtx: turnCtx(workspaceDir, text, "exec_output_truncation"), + spec: { id: "exec_output_truncation", phase: "before_call", target: "tool_payload", options: { toolThresholds: { bash: 5000 } } }, + }); + assert.equal(result?.changed, true, "precondition: the pass truncates"); + return result!.turnCtx!.segments[0]!.text; +} + +test("T1 tool_payload_trim output is byte-identical across requests", async () => { + const dir = await mkdtemp(join(tmpdir(), "stable-t1-")); + const first = await trim(dir, LOG); + await sleep(5); + assert.equal(await trim(dir, LOG), first); +}); + +test("T2 tool_payload_trim names the file it wrote", async () => { + const dir = await mkdtemp(join(tmpdir(), "stable-t2-")); + const path = archiveOf(await trim(dir, LOG)); + assert.ok(path); + assert.equal(JSON.parse(await readFile(path, "utf8")).originalText, LOG); +}); + +test("T3 different content gets a different archive path", async () => { + const dir = await mkdtemp(join(tmpdir(), "stable-t3-")); + const a = archiveOf(await trim(dir, LOG)); + const b = archiveOf(await trim(dir, `${LOG}\nBUILD OK`)); + assert.ok(a && b); + assert.notEqual(a, b); +}); + +test("T4 exec_output_truncation output is byte-identical across requests", async () => { + const dir = await mkdtemp(join(tmpdir(), "stable-t4-")); + const first = await truncate(dir, LOG); + await sleep(5); + assert.equal(await truncate(dir, LOG), first); + const path = archiveOf(first); + assert.ok(path); + assert.equal(JSON.parse(await readFile(path, "utf8")).originalText, LOG); +}); diff --git a/components/packages/foundation/artifact-store/src/archive-recovery/index.ts b/components/packages/foundation/artifact-store/src/archive-recovery/index.ts index c36f895b..467a151b 100644 --- a/components/packages/foundation/artifact-store/src/archive-recovery/index.ts +++ b/components/packages/foundation/artifact-store/src/archive-recovery/index.ts @@ -54,6 +54,15 @@ export type ArchiveLocationParams = { segmentId: string; workspaceDir?: string; archiveDir?: string; + /** + * The content being archived. When given, the file name is derived from the + * segment id and a hash of the session id and this text instead of the current + * time, so the same content in the same session always gets the same path. Adapters re-reduce the whole history on + * every request; a timestamped name changed the recovery hint each time (breaking + * prompt caching), wrote a new file each time, and could differ from the file + * actually written when the clock ticked between the hint and the write. + */ + originalText?: string; }; export type ArchiveLocation = { @@ -166,8 +175,9 @@ export async function archiveContent(params: ArchiveContentParams): Promise new Promise((resolve) => setTimeout(resolve, ms)); + +test("A1 the same content always maps to the same path", async () => { + const archiveDir = await mkdtemp(join(tmpdir(), "archive-a1-")); + const params = { sessionId: "s", segmentId: "tool-1", archiveDir, originalText: "body" }; + const first = buildArchiveLocation(params); + await sleep(5); + assert.deepEqual(buildArchiveLocation(params), first); + assert.equal(dirname(first.archivePath), archiveDir); +}); + +test("A2 different text, segment or session gives a different path", () => { + const base = { sessionId: "s", segmentId: "tool-1", originalText: "body", workspaceDir: "/w" }; + const path = buildArchiveLocation(base).archivePath; + assert.notEqual(buildArchiveLocation({ ...base, originalText: "body2" }).archivePath, path); + assert.notEqual(buildArchiveLocation({ ...base, segmentId: "tool-2" }).archivePath, path); + assert.notEqual(buildArchiveLocation({ ...base, sessionId: "s2" }).archivePath, path); +}); + +test("A3 without originalText the name stays timestamped", () => { + const before = Date.now(); + const name = basename(buildArchiveLocation({ sessionId: "s", segmentId: "tool-1", archiveDir: "/a" }).archivePath); + const match = /^(\d+)-tool-1\.json$/.exec(name); + assert.ok(match, name); + assert.ok(Number(match[1]) >= before); +}); + +test("A4 unsafe segment ids are sanitized", async () => { + const nativeArchiveDir = await mkdtemp(join(tmpdir(), "archive-a4-")); + const { archivePath, archiveDir } = buildArchiveLocation({ sessionId: "s", segmentId: "../../etc/passwd", archiveDir: nativeArchiveDir, originalText: "x" }); + assert.equal(dirname(archivePath), archiveDir); + assert.ok(!basename(archivePath).includes("/")); +}); + +test("A5 archiveContent writes where the location says, once per content", async () => { + const archiveDir = await mkdtemp(join(tmpdir(), "archive-a5-")); + const params = { sessionId: "s", segmentId: "tool-1", sourcePass: "tool_payload_trim", toolName: "read", dataKey: "/repo/a.ts", originalText: "full body", archiveDir }; + const first = await archiveContent(params); + await sleep(5); + const second = await archiveContent(params); + assert.equal(second.archivePath, first.archivePath); + assert.equal(first.archivePath, buildArchiveLocation(params).archivePath); + const files = (await readdir(archiveDir)).filter((name) => name.endsWith(".json") && name !== "key-lookup.json"); + assert.deepEqual(files, [basename(first.archivePath)]); + assert.equal(JSON.parse(await readFile(first.archivePath, "utf8")).originalText, "full body"); + const lookup = JSON.parse(await readFile(join(archiveDir, "key-lookup.json"), "utf8")) as Record; + assert.equal(lookup["/repo/a.ts"], first.archivePath); +});