Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ export const toolPayloadTrimPass: ReductionPassHandler = {
sessionId: turnCtx.sessionId,
segmentId: segment.id,
workspaceDir,
originalText: segment.text,
});

const replacementText = reduced.text + buildRecoveryHint({
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -166,8 +175,9 @@ export async function archiveContent(params: ArchiveContentParams): Promise<Arch

export function buildArchiveLocation(params: ArchiveLocationParams): ArchiveLocation {
const archiveDir = params.archiveDir ?? defaultArchiveDir(params.sessionId, params.workspaceDir);
const timestamp = Date.now();
const fileName = `${timestamp}-${sanitizePathPart(params.segmentId)}.json`;
const fileName = typeof params.originalText === "string"
? `${sanitizePathPart(params.segmentId)}-${hashText(`${params.sessionId}\u0000${params.originalText}`).slice(0, 16)}.json`
: `${Date.now()}-${sanitizePathPart(params.segmentId)}.json`;
const archivePath = join(archiveDir, fileName);
return { archiveDir, archivePath };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Regression matrix: content-derived archive locations (src/archive-recovery/index.ts)
*
* Adapters re-reduce the full history on every request. A timestamped archive name
* changed the recovery hint every time (breaking prompt caching), wrote a new file
* every time, and could differ from the file actually written.
*
* buildArchiveLocation
* A1 with originalText: the same (session, segment, text) → the same path, however
* much time passes
* A2 with originalText: different text, segment or session → a different path
* A3 without originalText → the previous timestamped name (backward compatible)
* A4 unsafe segment ids are sanitized in the file name (no path traversal)
* archiveContent
* A5 returns the path it wrote, which equals buildArchiveLocation for the same inputs;
* archiving the same content twice leaves one file and the dataKey still resolves
*/
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readdir, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";

import { archiveContent, buildArchiveLocation } from "../src/index.js";

const sleep = (ms: number) => 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<string, string>;
assert.equal(lookup["/repo/a.ts"], first.archivePath);
});
Loading