Skip to content
Closed
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
94 changes: 94 additions & 0 deletions packages/opencode/test/altimate/tracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

// ---------------------------------------------------------------------------
Expand Down
175 changes: 175 additions & 0 deletions packages/opencode/test/session/summary-git-path.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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",
])
},
})
})
Comment on lines +21 to +174
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use per-test tmpdir() isolation instead of repository root directory.

Line 21 and repeated Instance.provide calls run against a shared repo path; these tests should use tmpdir() with await using and pass tmp.path to keep storage state isolated and auto-cleaned.

Suggested refactor
-import path from "path"
+import { tmpdir } from "../fixture/fixture"
 ...
-const projectRoot = path.join(__dirname, "../..")
 Log.init({ print: false })
+
+async function withInstance<T>(fn: () => Promise<T>) {
+  await using tmp = await tmpdir()
+  return Instance.provide({
+    directory: tmp.path,
+    fn,
+  })
+}
 ...
-    await Instance.provide({
-      directory: projectRoot,
-      fn: async () => {
+    await withInstance(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",
         ])
-      },
     })

As per coding guidelines: Use the tmpdir function from fixture/fixture.ts to create temporary directories for tests with automatic cleanup in test files and Always use await using syntax with tmpdir() for automatic cleanup when the variable goes out of scope.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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<string[]> {
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",
])
},
})
})
import { tmpdir } from "../fixture/fixture"
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<string[]> {
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)
}
async function withInstance<T>(fn: () => Promise<T>) {
await using tmp = await tmpdir()
return Instance.provide({
directory: tmp.path,
fn,
})
}
describe("SessionSummary.diff: unquoteGitPath decoding", () => {
test("plain ASCII paths pass through unchanged", async () => {
await withInstance(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 withInstance(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 withInstance(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 withInstance(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 withInstance(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 withInstance(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 withInstance(async () => {
const files = await roundtrip(['"file\\"name.txt"'])
expect(files).toEqual(['file"name.txt'])
})
})
test("empty string passes through unchanged", async () => {
await withInstance(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 withInstance(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 withInstance(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",
])
})
})
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/test/session/summary-git-path.test.ts` around lines 21 -
174, Tests currently reuse a shared projectRoot for Instance.provide causing
shared Storage state; switch each test to create an isolated temporary directory
via await using tmp = await tmpdir() and pass tmp.path to Instance.provide
(replace references to projectRoot), so roundtrip and SessionSummary.diff run
against an isolated tmp workspace and auto-clean on scope exit; ensure any test
helpers that depend on the repo root (e.g., roundtrip, Identifier.ascending
sessionID usage) continue to work unchanged but are invoked inside the await
using block so Storage writes/read are isolated per test.

})
Loading