From 09968e7be0002385ef2db3c684cd6c1cdb6574fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 12:14:12 +0000 Subject: [PATCH] test: session git-path decoding + recap loop detection boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests for two previously untested code paths: 1. SessionSummary's unquoteGitPath — decodes git's C-style quoted paths with octal-encoded UTF-8 (CJK, accented characters), preventing garbled filenames in session diffs. 2. Recap loop detection boundary behavior — history pruning at 201 entries preserves recent evidence, and two simultaneous distinct loops are both detected correctly. Co-Authored-By: Claude Opus 4.6 (1M context) https://claude.ai/code/session_01UgL6G8jZAPtoUUKBphH7f4 --- .../opencode/test/altimate/tracing.test.ts | 94 ++++++++++ .../test/session/summary-git-path.test.ts | 175 ++++++++++++++++++ 2 files changed, 269 insertions(+) create mode 100644 packages/opencode/test/session/summary-git-path.test.ts diff --git a/packages/opencode/test/altimate/tracing.test.ts b/packages/opencode/test/altimate/tracing.test.ts index 2140bc99a7..cb06797955 100644 --- a/packages/opencode/test/altimate/tracing.test.ts +++ b/packages/opencode/test/altimate/tracing.test.ts @@ -1021,6 +1021,100 @@ describe("Loop detection", () => { // Only 2 repeats in window — should not trigger loop detection expect(trace.summary.loops).toBeUndefined() }) + + test("history pruning at 201 entries preserves recent loop evidence", async () => { + const exporter = new FileExporter(tmpDir) + const recap = Recap.withExporters([exporter]) + + recap.startTrace("s-loop-prune", { prompt: "test" }) + recap.logStepStart({ id: "1" }) + + // Fill 198 unique tool calls to push toward the pruning boundary + for (let i = 0; i < 198; i++) { + recap.logToolCall({ + tool: `filler-${i}`, + callID: `c-filler-${i}`, + state: { + status: "completed", + input: { i }, + output: "ok", + time: { start: 1000 + i, end: 2000 + i }, + }, + }) + } + + // Now add 3 identical calls (entries 199, 200, 201) — triggers prune at 201 + // After pruning (>200 → last 100), these 3 calls are at positions 98-100 + // of the surviving slice, well within the last-10 detection window + for (let i = 0; i < 3; i++) { + recap.logToolCall({ + tool: "bash", + callID: `c-loop-${i}`, + state: { + status: "completed", + input: { command: "ls -la" }, + output: "total 0", + time: { start: 5000 + i, end: 6000 + i }, + }, + }) + } + + recap.logStepFinish(makeStepFinish()) + const filePath = await recap.endTrace() + + const trace: TraceFile = JSON.parse(await fs.readFile(filePath!, "utf-8")) + // The 3 identical "bash" calls should still be detected after pruning + expect(trace.summary.loops).toBeDefined() + expect(trace.summary.loops!.length).toBeGreaterThanOrEqual(1) + expect(trace.summary.loops!.find((l) => l.tool === "bash")).toBeDefined() + }) + + test("two distinct loops detected simultaneously", async () => { + const exporter = new FileExporter(tmpDir) + const recap = Recap.withExporters([exporter]) + + recap.startTrace("s-multi-loop", { prompt: "test" }) + recap.logStepStart({ id: "1" }) + + // Interleave two different loops: bash(ls) and read(file.ts) + for (let i = 0; i < 4; i++) { + recap.logToolCall({ + tool: "bash", + callID: `c-bash-${i}`, + state: { + status: "completed", + input: { command: "ls" }, + output: "file1.ts", + time: { start: 1000 + i * 2, end: 2000 + i * 2 }, + }, + }) + recap.logToolCall({ + tool: "read", + callID: `c-read-${i}`, + state: { + status: "completed", + input: { file: "config.ts" }, + output: "content", + time: { start: 1001 + i * 2, end: 2001 + i * 2 }, + }, + }) + } + + recap.logStepFinish(makeStepFinish()) + const filePath = await recap.endTrace() + + const trace: TraceFile = JSON.parse(await fs.readFile(filePath!, "utf-8")) + // Both loops should be detected + expect(trace.summary.loops).toBeDefined() + expect(trace.summary.loops!.length).toBeGreaterThanOrEqual(2) + + const bashLoop = trace.summary.loops!.find((l) => l.tool === "bash") + const readLoop = trace.summary.loops!.find((l) => l.tool === "read") + expect(bashLoop).toBeDefined() + expect(bashLoop!.count).toBeGreaterThanOrEqual(3) + expect(readLoop).toBeDefined() + expect(readLoop!.count).toBeGreaterThanOrEqual(3) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/test/session/summary-git-path.test.ts b/packages/opencode/test/session/summary-git-path.test.ts new file mode 100644 index 0000000000..f50d873354 --- /dev/null +++ b/packages/opencode/test/session/summary-git-path.test.ts @@ -0,0 +1,175 @@ +import { describe, test, expect } from "bun:test" +import path from "path" +import { SessionSummary } from "../../src/session/summary" +import { Instance } from "../../src/project/instance" +import { Storage } from "../../src/storage/storage" +import { Log } from "../../src/util/log" +import { Identifier } from "../../src/id/id" + +/** + * Tests for the unquoteGitPath function used in SessionSummary.diff(). + * + * Git quotes file paths containing non-ASCII bytes using C-style escaping with + * octal sequences (e.g., \303\251 for UTF-8 "é"). This function decodes those + * paths back to their original Unicode representation. Without correct decoding, + * session diffs show garbled filenames for non-ASCII files (CJK, accented, emoji). + * + * We test indirectly via SessionSummary.diff() which applies unquoteGitPath to + * stored FileDiff entries. + */ + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +// Helper: write fake diffs to Storage for a session, then read them back via diff() +async function roundtrip(files: string[]): Promise { + const sessionID = Identifier.ascending("session") as any + const diffs = files.map((file) => ({ + file, + before: "", + after: "", + additions: 1, + deletions: 0, + status: "added" as const, + })) + + await Storage.write(["session_diff", sessionID], diffs) + const result = await SessionSummary.diff({ sessionID }) + return result.map((d) => d.file) +} + +describe("SessionSummary.diff: unquoteGitPath decoding", () => { + test("plain ASCII paths pass through unchanged", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const files = await roundtrip([ + "src/index.ts", + "README.md", + "packages/opencode/test/file.test.ts", + ]) + expect(files).toEqual([ + "src/index.ts", + "README.md", + "packages/opencode/test/file.test.ts", + ]) + }, + }) + }) + + test("git-quoted path with octal-encoded UTF-8 (2-byte: é = \\303\\251)", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // Git quotes "café.txt" as "caf\\303\\251.txt" + const files = await roundtrip(['"caf\\303\\251.txt"']) + expect(files).toEqual(["café.txt"]) + }, + }) + }) + + test("git-quoted path with 3-byte UTF-8 octal (CJK character 中 = \\344\\270\\255)", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // Git quotes "中文.txt" as "\\344\\270\\255\\346\\226\\207.txt" + const files = await roundtrip(['"\\344\\270\\255\\346\\226\\207.txt"']) + expect(files).toEqual(["中文.txt"]) + }, + }) + }) + + test("git-quoted path with standard escape sequences (\\n, \\t, \\\\, \\\")", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const files = await roundtrip([ + '"path\\\\with\\\\backslashes"', + '"file\\twith\\ttabs"', + '"line\\nbreak"', + ]) + expect(files).toEqual([ + "path\\with\\backslashes", + "file\twith\ttabs", + "line\nbreak", + ]) + }, + }) + }) + + test("mixed octal and plain ASCII in one path", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // "docs/résumé.md" → git quotes accented chars only + // é = \303\251 in UTF-8 + const files = await roundtrip(['"docs/r\\303\\251sum\\303\\251.md"']) + expect(files).toEqual(["docs/résumé.md"]) + }, + }) + }) + + test("unquoted path (no surrounding double quotes) passes through unchanged", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // If git doesn't quote the path, it should pass through as-is + const files = await roundtrip(["normal/path.ts", "another-file.js"]) + expect(files).toEqual(["normal/path.ts", "another-file.js"]) + }, + }) + }) + + test("path with embedded double quote (\\\")", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const files = await roundtrip(['"file\\"name.txt"']) + expect(files).toEqual(['file"name.txt']) + }, + }) + }) + + test("empty string passes through unchanged", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const files = await roundtrip([""]) + expect(files).toEqual([""]) + }, + }) + }) + + test("Japanese filename with 3-byte UTF-8 sequences (テスト = \\343\\203\\206\\343\\202\\271\\343\\203\\210)", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // テ = E3 83 86 = \343\203\206 + // ス = E3 82 B9 = \343\202\271 + // ト = E3 83 88 = \343\203\210 + const files = await roundtrip(['"\\343\\203\\206\\343\\202\\271\\343\\203\\210.sql"']) + expect(files).toEqual(["テスト.sql"]) + }, + }) + }) + + test("multiple files: some quoted, some not", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const files = await roundtrip([ + "plain.ts", + '"caf\\303\\251.txt"', + "normal/path.js", + '"\\344\\270\\255.md"', + ]) + expect(files).toEqual([ + "plain.ts", + "café.txt", + "normal/path.js", + "中.md", + ]) + }, + }) + }) +})