diff --git a/packages/opencode/src/altimate/tools/finops-formatting.ts b/packages/opencode/src/altimate/tools/finops-formatting.ts index 7996fd1937..c88aa5579a 100644 --- a/packages/opencode/src/altimate/tools/finops-formatting.ts +++ b/packages/opencode/src/altimate/tools/finops-formatting.ts @@ -1,7 +1,9 @@ export function formatBytes(bytes: number): string { if (bytes === 0) return "0 B" + if (!Number.isFinite(bytes)) return "0 B" + const abs = Math.abs(bytes) const units = ["B", "KB", "MB", "GB", "TB", "PB"] - const i = Math.floor(Math.log(bytes) / Math.log(1024)) + const i = Math.max(0, Math.min(Math.floor(Math.log(abs) / Math.log(1024)), units.length - 1)) const value = bytes / Math.pow(1024, i) return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}` } @@ -9,6 +11,9 @@ export function formatBytes(bytes: number): string { export function truncateQuery(text: string, maxLen: number): string { if (!text) return "(empty)" const oneLine = text.replace(/\s+/g, " ").trim() + if (!oneLine) return "(empty)" + if (maxLen <= 0) return "" + if (maxLen < 4) return oneLine.slice(0, maxLen) if (oneLine.length <= maxLen) return oneLine return oneLine.slice(0, maxLen - 3) + "..." } diff --git a/packages/opencode/test/altimate/finops-role-access.test.ts b/packages/opencode/test/altimate/finops-role-access.test.ts new file mode 100644 index 0000000000..c1cf62ce49 --- /dev/null +++ b/packages/opencode/test/altimate/finops-role-access.test.ts @@ -0,0 +1,246 @@ +/** + * Tests for finops role-access formatting functions: + * formatGrants, formatHierarchy, formatUserRoles. + * + * These render RBAC data as markdown tables. Incorrect output + * could cause data engineers to miss security issues during audits. + * Tests use Dispatcher.call spying to supply known RBAC data. + */ +import { describe, test, expect, spyOn, afterAll, beforeEach } from "bun:test" +import * as Dispatcher from "../../src/altimate/native/dispatcher" +import { + FinopsRoleGrantsTool, + FinopsRoleHierarchyTool, + FinopsUserRolesTool, +} from "../../src/altimate/tools/finops-role-access" +import { SessionID, MessageID } from "../../src/session/schema" + +beforeEach(() => { + process.env.ALTIMATE_TELEMETRY_DISABLED = "true" +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +let dispatcherSpy: ReturnType + +function mockDispatcher(responses: Record) { + dispatcherSpy?.mockRestore() + dispatcherSpy = spyOn(Dispatcher, "call").mockImplementation(async (method: string) => { + if (responses[method]) return responses[method] + throw new Error(`No mock for ${method}`) + }) +} + +afterAll(() => { + dispatcherSpy?.mockRestore() + delete process.env.ALTIMATE_TELEMETRY_DISABLED +}) + +describe("formatGrants: privilege summary and grant rows", () => { + test("renders privilege summary and grant table with standard Snowflake fields", async () => { + mockDispatcher({ + "finops.role_grants": { + success: true, + grant_count: 2, + privilege_summary: { SELECT: 2, INSERT: 1 }, + grants: [ + { grantee_name: "ANALYST", privilege: "SELECT", object_type: "TABLE", object_name: "orders" }, + { grantee_name: "ADMIN", privilege: "INSERT", object_type: "TABLE", object_name: "users" }, + ], + }, + }) + + const tool = await FinopsRoleGrantsTool.init() + const result = await tool.execute({ warehouse: "test_wh", limit: 100 }, ctx as any) + + expect(result.title).toContain("2 found") + expect(result.output).toContain("Privilege Summary") + expect(result.output).toContain("SELECT: 2") + expect(result.output).toContain("INSERT: 1") + expect(result.output).toContain("ANALYST | SELECT | TABLE | orders") + expect(result.output).toContain("ADMIN | INSERT | TABLE | users") + }) + + test("uses fallback field aliases (role, granted_on, name)", async () => { + mockDispatcher({ + "finops.role_grants": { + success: true, + grant_count: 1, + privilege_summary: {}, + grants: [ + { role: "DBA", privilege: "USAGE", granted_on: "WAREHOUSE", name: "compute_wh" }, + ], + }, + }) + + const tool = await FinopsRoleGrantsTool.init() + const result = await tool.execute({ warehouse: "test_wh", limit: 100 }, ctx as any) + + // formatGrants should fall back to r.role, r.granted_on, r.name + expect(result.output).toContain("DBA | USAGE | WAREHOUSE | compute_wh") + }) + + test("handles empty grants array", async () => { + mockDispatcher({ + "finops.role_grants": { + success: true, + grant_count: 0, + privilege_summary: {}, + grants: [], + }, + }) + + const tool = await FinopsRoleGrantsTool.init() + const result = await tool.execute({ warehouse: "test_wh", limit: 100 }, ctx as any) + + expect(result.output).toContain("No grants found") + }) + + test("returns error message on Dispatcher failure", async () => { + mockDispatcher({ + "finops.role_grants": { + success: false, + error: "Connection refused", + }, + }) + + const tool = await FinopsRoleGrantsTool.init() + const result = await tool.execute({ warehouse: "test_wh", limit: 100 }, ctx as any) + + expect(result.title).toContain("FAILED") + expect(result.output).toContain("Connection refused") + }) +}) + +describe("formatHierarchy: recursive role tree rendering", () => { + test("renders two-level nested hierarchy with children key", async () => { + mockDispatcher({ + "finops.role_hierarchy": { + success: true, + role_count: 3, + hierarchy: [ + { + name: "SYSADMIN", + children: [ + { name: "DBA", children: [] }, + { name: "ANALYST", children: [] }, + ], + }, + ], + }, + }) + + const tool = await FinopsRoleHierarchyTool.init() + const result = await tool.execute({ warehouse: "test_wh" }, ctx as any) + + expect(result.title).toContain("3 roles") + expect(result.output).toContain("Role Hierarchy") + expect(result.output).toContain("SYSADMIN") + expect(result.output).toContain("-> DBA") + expect(result.output).toContain("-> ANALYST") + }) + + test("uses granted_roles fallback alias for children", async () => { + mockDispatcher({ + "finops.role_hierarchy": { + success: true, + role_count: 2, + hierarchy: [ + { + role: "ACCOUNTADMIN", + granted_roles: [{ role: "SECURITYADMIN" }], + }, + ], + }, + }) + + const tool = await FinopsRoleHierarchyTool.init() + const result = await tool.execute({ warehouse: "test_wh" }, ctx as any) + + // Should use r.role as name and r.granted_roles as children + expect(result.output).toContain("ACCOUNTADMIN") + expect(result.output).toContain("-> SECURITYADMIN") + }) + + test("handles empty hierarchy", async () => { + mockDispatcher({ + "finops.role_hierarchy": { + success: true, + role_count: 0, + hierarchy: [], + }, + }) + + const tool = await FinopsRoleHierarchyTool.init() + const result = await tool.execute({ warehouse: "test_wh" }, ctx as any) + + expect(result.output).toContain("Role Hierarchy") + // No roles rendered but header is present + expect(result.output).not.toContain("->") + }) +}) + +describe("formatUserRoles: user-role assignment table", () => { + test("renders user assignments with standard fields", async () => { + mockDispatcher({ + "finops.user_roles": { + success: true, + assignment_count: 2, + assignments: [ + { grantee_name: "alice@corp.com", role: "ANALYST", granted_by: "SECURITYADMIN" }, + { grantee_name: "bob@corp.com", role: "DBA", granted_by: "ACCOUNTADMIN" }, + ], + }, + }) + + const tool = await FinopsUserRolesTool.init() + const result = await tool.execute({ warehouse: "test_wh", limit: 100 }, ctx as any) + + expect(result.title).toContain("2 assignments") + expect(result.output).toContain("User Role Assignments") + expect(result.output).toContain("alice@corp.com | ANALYST | SECURITYADMIN") + expect(result.output).toContain("bob@corp.com | DBA | ACCOUNTADMIN") + }) + + test("uses fallback aliases (user_name, role_name, grantor)", async () => { + mockDispatcher({ + "finops.user_roles": { + success: true, + assignment_count: 1, + assignments: [ + { user_name: "charlie", role_name: "READER", grantor: "ADMIN" }, + ], + }, + }) + + const tool = await FinopsUserRolesTool.init() + const result = await tool.execute({ warehouse: "test_wh", limit: 100 }, ctx as any) + + // Falls back to r.user_name (via user fallback chain), r.role_name, r.grantor + expect(result.output).toContain("charlie | READER | ADMIN") + }) + + test("handles empty assignments", async () => { + mockDispatcher({ + "finops.user_roles": { + success: true, + assignment_count: 0, + assignments: [], + }, + }) + + const tool = await FinopsUserRolesTool.init() + const result = await tool.execute({ warehouse: "test_wh", limit: 100 }, ctx as any) + + expect(result.output).toContain("No user role assignments found") + }) +}) diff --git a/packages/opencode/test/altimate/tool-lookup.test.ts b/packages/opencode/test/altimate/tool-lookup.test.ts new file mode 100644 index 0000000000..2f27ca111e --- /dev/null +++ b/packages/opencode/test/altimate/tool-lookup.test.ts @@ -0,0 +1,149 @@ +/** + * Tests for the tool_lookup tool — Zod schema introspection + * (describeZodSchema, inferZodType, unwrap, getShape). + * + * The agent uses tool_lookup to discover other tools' parameter contracts. + * Incorrect introspection leads to wrong tool calls. + */ +import { describe, test, expect, beforeEach, afterAll } from "bun:test" +import z from "zod" +import { ToolLookupTool } from "../../src/altimate/tools/tool-lookup" +import { ToolRegistry } from "../../src/tool/registry" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { SessionID, MessageID } from "../../src/session/schema" + +beforeEach(() => { + process.env.ALTIMATE_TELEMETRY_DISABLED = "true" +}) +afterAll(() => { + delete process.env.ALTIMATE_TELEMETRY_DISABLED +}) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "call_test", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +describe("ToolLookupTool: Zod schema introspection", () => { + test("returns parameter info for tool with mixed types", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const testTool = { + id: "__test_lookup_mixed", + init: async () => ({ + description: "Test tool with mixed params", + parameters: z.object({ + name: z.string().describe("The name"), + count: z.number().describe("How many"), + verbose: z.boolean().optional().describe("Enable verbosity"), + tags: z.array(z.string()).describe("Tags list"), + mode: z.enum(["fast", "slow"]).default("fast").describe("Execution mode"), + }), + execute: async () => ({ title: "", output: "", metadata: {} }), + }), + } + await ToolRegistry.register(testTool) + + const tool = await ToolLookupTool.init() + const result = await tool.execute({ tool_name: "__test_lookup_mixed" }, ctx as any) + + // Required string param + expect(result.output).toContain("name") + expect(result.output).toContain("string") + expect(result.output).toContain("required") + expect(result.output).toContain("The name") + + // Number param + expect(result.output).toContain("count") + expect(result.output).toContain("number") + + // Optional boolean + expect(result.output).toContain("verbose") + expect(result.output).toContain("optional") + + // Array param + expect(result.output).toContain("tags") + expect(result.output).toContain("array") + + // Enum with default — inferZodType unwraps default then hits enum + expect(result.output).toContain("mode") + expect(result.output).toContain("enum") + }, + }) + }) + + test("returns 'Tool not found' with available tools list", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const tool = await ToolLookupTool.init() + const result = await tool.execute({ tool_name: "nonexistent_tool_xyz" }, ctx as any) + expect(result.title).toBe("Tool not found") + expect(result.output).toContain('No tool named "nonexistent_tool_xyz"') + expect(result.output).toContain("Available tools:") + }, + }) + }) + + test("handles tool with empty parameters object", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const testTool = { + id: "__test_lookup_empty", + init: async () => ({ + description: "Tool with empty params", + parameters: z.object({}), + execute: async () => ({ title: "", output: "", metadata: {} }), + }), + } + await ToolRegistry.register(testTool) + + const tool = await ToolLookupTool.init() + const result = await tool.execute({ tool_name: "__test_lookup_empty" }, ctx as any) + expect(result.output).toContain("No parameters") + }, + }) + }) + + test("unwraps nested optional/default wrappers correctly", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const testTool = { + id: "__test_lookup_nested", + init: async () => ({ + description: "Tool with nested wrappers", + parameters: z.object({ + // default wrapping optional wrapping string + deep: z.string().optional().default("hello").describe("Deeply wrapped"), + }), + execute: async () => ({ title: "", output: "", metadata: {} }), + }), + } + await ToolRegistry.register(testTool) + + const tool = await ToolLookupTool.init() + const result = await tool.execute({ tool_name: "__test_lookup_nested" }, ctx as any) + + // Should unwrap to the inner string type + expect(result.output).toContain("deep") + expect(result.output).toContain("Deeply wrapped") + // The outer wrapper is default, so it should show as optional + expect(result.output).toContain("optional") + }, + }) + }) +}) diff --git a/packages/opencode/test/altimate/tools/finops-formatting.test.ts b/packages/opencode/test/altimate/tools/finops-formatting.test.ts new file mode 100644 index 0000000000..6598177d2c --- /dev/null +++ b/packages/opencode/test/altimate/tools/finops-formatting.test.ts @@ -0,0 +1,78 @@ +import { describe, test, expect } from "bun:test" +import { formatBytes, truncateQuery } from "../../../src/altimate/tools/finops-formatting" + +describe("formatBytes: normal cases", () => { + test("zero returns 0 B", () => { + expect(formatBytes(0)).toBe("0 B") + }) + + test("exact unit boundaries", () => { + expect(formatBytes(1)).toBe("1 B") + expect(formatBytes(1024)).toBe("1.00 KB") + expect(formatBytes(1024 * 1024)).toBe("1.00 MB") + expect(formatBytes(1024 * 1024 * 1024)).toBe("1.00 GB") + }) + + test("non-boundary values", () => { + expect(formatBytes(500)).toBe("500 B") + expect(formatBytes(1536)).toBe("1.50 KB") + }) +}) + +describe("formatBytes: edge cases", () => { + test("negative bytes displays with sign", () => { + expect(formatBytes(-100)).toBe("-100 B") + expect(formatBytes(-1536)).toBe("-1.50 KB") + }) + + test("fractional bytes clamps to B unit", () => { + expect(formatBytes(0.5)).toBe("1 B") + }) + + test("NaN input returns 0 B", () => { + expect(formatBytes(NaN)).toBe("0 B") + }) + + test("Infinity input returns 0 B", () => { + expect(formatBytes(Infinity)).toBe("0 B") + expect(formatBytes(-Infinity)).toBe("0 B") + }) +}) + +describe("truncateQuery: normal cases", () => { + test("empty/falsy input returns (empty)", () => { + expect(truncateQuery("", 10)).toBe("(empty)") + }) + + test("short text returned as-is", () => { + expect(truncateQuery("SELECT 1", 50)).toBe("SELECT 1") + }) + + test("long text truncated with ellipsis", () => { + const long = "SELECT * FROM very_long_table_name WHERE id = 1" + const result = truncateQuery(long, 20) + expect(result.length).toBeLessThanOrEqual(20) + expect(result).toEndWith("...") + }) + + test("multiline collapsed to single line", () => { + const sql = "SELECT *\n FROM table\n WHERE id = 1" + expect(truncateQuery(sql, 100)).toBe("SELECT * FROM table WHERE id = 1") + }) +}) + +describe("truncateQuery: edge cases", () => { + test("whitespace-only returns (empty)", () => { + expect(truncateQuery(" ", 10)).toBe("(empty)") + }) + + test("maxLen smaller than 4 hard-truncates without ellipsis", () => { + expect(truncateQuery("hello world", 2)).toBe("he") + expect(truncateQuery("hello world", 3)).toBe("hel") + }) + + test("maxLen zero or negative returns empty string", () => { + expect(truncateQuery("hello", 0)).toBe("") + expect(truncateQuery("hello", -5)).toBe("") + }) +}) 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/altimate/training-import.test.ts b/packages/opencode/test/altimate/training-import.test.ts index 40a6941070..fee9dee188 100644 --- a/packages/opencode/test/altimate/training-import.test.ts +++ b/packages/opencode/test/altimate/training-import.test.ts @@ -48,14 +48,14 @@ function setupMocks(opts: { saveSpy?.mockRestore() budgetSpy?.mockRestore() - readFileSpy = spyOn(fs, "readFile").mockImplementation(async () => Buffer.from(opts.fileContent) as any) + readFileSpy = spyOn(fs, "readFile").mockImplementation((() => Promise.resolve(opts.fileContent)) as any) countSpy = spyOn(TrainingStore, "count").mockImplementation(async () => ({ standard: opts.currentCount ?? 0, glossary: opts.currentCount ?? 0, playbook: opts.currentCount ?? 0, + context: opts.currentCount ?? 0, rule: opts.currentCount ?? 0, pattern: opts.currentCount ?? 0, - context: opts.currentCount ?? 0, })) saveSpy = spyOn(TrainingStore, "save").mockImplementation(async () => { if (opts.saveShouldFail) throw new Error("store write failed") diff --git a/packages/opencode/test/config/paths-parsetext.test.ts b/packages/opencode/test/config/paths-parsetext.test.ts new file mode 100644 index 0000000000..b3c8d92c87 --- /dev/null +++ b/packages/opencode/test/config/paths-parsetext.test.ts @@ -0,0 +1,186 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { ConfigPaths } from "../../src/config/paths" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" + +describe("ConfigPaths.parseText: JSONC parsing", () => { + test("parses plain JSON object", async () => { + const result = await ConfigPaths.parseText('{"key": "value"}', "/fake/config.json") + expect(result).toEqual({ key: "value" }) + }) + + test("parses JSONC with comments", async () => { + const text = `{ + // This is a comment + "key": "value" + }` + const result = await ConfigPaths.parseText(text, "/fake/config.jsonc") + expect(result).toEqual({ key: "value" }) + }) + + test("allows trailing commas", async () => { + const text = '{"a": 1, "b": 2,}' + const result = await ConfigPaths.parseText(text, "/fake/config.json") + expect(result).toEqual({ a: 1, b: 2 }) + }) + + test("throws JsonError on invalid JSONC", async () => { + const text = '{"key": value_without_quotes}' + try { + await ConfigPaths.parseText(text, "/fake/bad.json") + expect.unreachable("should have thrown") + } catch (e: any) { + expect(e.constructor.name).toBe("ConfigJsonError") + expect(e.data.path).toBe("/fake/bad.json") + } + }) + + test("error message includes line and column info", async () => { + const text = '{\n "a": 1\n "b": 2\n}' + try { + await ConfigPaths.parseText(text, "/fake/bad.json") + expect.unreachable("should have thrown") + } catch (e: any) { + expect(e.data.message).toContain("line") + expect(e.data.message).toContain("column") + } + }) +}) + +describe("ConfigPaths.parseText: {env:VAR} substitution", () => { + const envKey = "OPENCODE_TEST_PARSE_TEXT_KEY" + + beforeEach(() => { + process.env[envKey] = "test-api-key-12345" + }) + + afterEach(() => { + delete process.env[envKey] + }) + + test("substitutes {env:VAR} with environment variable value", async () => { + const text = `{"apiKey": "{env:${envKey}}"}` + const result = await ConfigPaths.parseText(text, "/fake/config.json") + expect(result).toEqual({ apiKey: "test-api-key-12345" }) + }) + + test("substitutes to empty string when env var is not set", async () => { + const text = '{"apiKey": "{env:OPENCODE_TEST_NONEXISTENT_VAR_XYZ}"}' + const result = await ConfigPaths.parseText(text, "/fake/config.json") + expect(result).toEqual({ apiKey: "" }) + }) + + test("substitutes multiple env vars in same text", async () => { + process.env.OPENCODE_TEST_HOST = "localhost" + process.env.OPENCODE_TEST_PORT = "5432" + try { + const text = '{"host": "{env:OPENCODE_TEST_HOST}", "port": "{env:OPENCODE_TEST_PORT}"}' + const result = await ConfigPaths.parseText(text, "/fake/config.json") + expect(result).toEqual({ host: "localhost", port: "5432" }) + } finally { + delete process.env.OPENCODE_TEST_HOST + delete process.env.OPENCODE_TEST_PORT + } + }) + + test("env var substitution is raw text injection (not JSON-quoted)", async () => { + process.env.OPENCODE_TEST_NUM = "42" + try { + // After env substitution, this becomes {"count": 42} — a number, not a string + const text = '{"count": {env:OPENCODE_TEST_NUM}}' + const result = await ConfigPaths.parseText(text, "/fake/config.json") + expect(result).toEqual({ count: 42 }) + } finally { + delete process.env.OPENCODE_TEST_NUM + } + }) +}) + +describe("ConfigPaths.parseText: {file:path} substitution", () => { + test("substitutes {file:path} with file contents (trimmed)", async () => { + await using tmp = await tmpdir() + const secretPath = path.join(tmp.path, "secret.txt") + await fs.writeFile(secretPath, "my-secret-value\n") + + const text = `{"secret": "{file:${secretPath}}"}` + const result = await ConfigPaths.parseText(text, "/fake/config.json") + expect(result).toEqual({ secret: "my-secret-value" }) + }) + + test("resolves relative file path from config directory", async () => { + await using tmp = await tmpdir() + const secretPath = path.join(tmp.path, "creds.txt") + await fs.writeFile(secretPath, "relative-secret") + + // source is the config file path — {file:creds.txt} resolves relative to its directory + const configPath = path.join(tmp.path, "config.json") + const text = '{"secret": "{file:creds.txt}"}' + const result = await ConfigPaths.parseText(text, configPath) + expect(result).toEqual({ secret: "relative-secret" }) + }) + + test("throws InvalidError when referenced file does not exist", async () => { + const text = '{"secret": "{file:/nonexistent/path/secret.txt}"}' + try { + await ConfigPaths.parseText(text, "/fake/config.json") + expect.unreachable("should have thrown") + } catch (e: any) { + expect(e.constructor.name).toBe("ConfigInvalidError") + expect(e.data.path).toBe("/fake/config.json") + expect(e.data.message).toContain("does not exist") + } + }) + + test("missing='empty' returns empty string for missing files", async () => { + const text = '{"secret": "{file:/nonexistent/path/secret.txt}"}' + const result = await ConfigPaths.parseText(text, "/fake/config.json", "empty") + expect(result).toEqual({ secret: "" }) + }) + + test("skips {file:...} inside // comments", async () => { + await using tmp = await tmpdir() + // The file doesn't exist, but it's in a comment so should not trigger an error + const text = `{ + // Reference: {file:nonexistent.txt} + "key": "value" + }` + const result = await ConfigPaths.parseText(text, path.join(tmp.path, "config.json")) + expect(result).toEqual({ key: "value" }) + }) + + test("escapes file content for JSON safety (quotes and newlines)", async () => { + await using tmp = await tmpdir() + const secretPath = path.join(tmp.path, "multiline.txt") + // Content with characters that need JSON escaping + await fs.writeFile(secretPath, 'value with "quotes" and\nnewlines') + + const text = `{"secret": "{file:${secretPath}}"}` + const result = await ConfigPaths.parseText(text, "/fake/config.json") + // After file read → trim → JSON.stringify escape → parseJsonc, we get original content back + expect(result.secret).toBe('value with "quotes" and\nnewlines') + }) +}) + +describe("ConfigPaths.parseText: combined substitutions", () => { + test("handles both {env:} and {file:} in same config", async () => { + await using tmp = await tmpdir() + const secretPath = path.join(tmp.path, "db-pass.txt") + await fs.writeFile(secretPath, "file-password") + + process.env.OPENCODE_TEST_DB_HOST = "db.example.com" + try { + const text = `{ + "host": "{env:OPENCODE_TEST_DB_HOST}", + "password": "{file:${secretPath}}" + }` + const result = await ConfigPaths.parseText(text, "/fake/config.json") + expect(result).toEqual({ + host: "db.example.com", + password: "file-password", + }) + } finally { + delete process.env.OPENCODE_TEST_DB_HOST + } + }) +}) diff --git a/packages/opencode/test/file/ignore.test.ts b/packages/opencode/test/file/ignore.test.ts index 6387ff63e4..ceab2a121c 100644 --- a/packages/opencode/test/file/ignore.test.ts +++ b/packages/opencode/test/file/ignore.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test" +import { describe, test, expect } from "bun:test" import { FileIgnore } from "../../src/file/ignore" test("match nested and non-nested", () => { @@ -8,3 +8,47 @@ test("match nested and non-nested", () => { expect(FileIgnore.match("node_modules/bar")).toBe(true) expect(FileIgnore.match("node_modules/bar/")).toBe(true) }) + +describe("FileIgnore.match: directory and file patterns", () => { + test("matches registered directory patterns beyond node_modules", () => { + expect(FileIgnore.match("dist/bundle.js")).toBe(true) + expect(FileIgnore.match("build/output.css")).toBe(true) + expect(FileIgnore.match(".git/config")).toBe(true) + expect(FileIgnore.match("__pycache__/module.pyc")).toBe(true) + expect(FileIgnore.match(".next/server/pages")).toBe(true) + expect(FileIgnore.match("out/index.html")).toBe(true) + expect(FileIgnore.match("bin/cli")).toBe(true) + // "desktop" is in FOLDERS — broad match, verify it works as specified + expect(FileIgnore.match("desktop/app.js")).toBe(true) + }) + + test("matches file glob patterns", () => { + expect(FileIgnore.match("src/editor.swp")).toBe(true) + expect(FileIgnore.match("deep/nested/file.swp")).toBe(true) + expect(FileIgnore.match("src/.DS_Store")).toBe(true) + expect(FileIgnore.match("cache.pyc")).toBe(true) + expect(FileIgnore.match("logs/app.log")).toBe(true) + expect(FileIgnore.match("tmp/upload.bin")).toBe(true) + }) + + test("does not match normal source files", () => { + expect(FileIgnore.match("src/index.ts")).toBe(false) + expect(FileIgnore.match("README.md")).toBe(false) + expect(FileIgnore.match("package.json")).toBe(false) + expect(FileIgnore.match("lib/utils.js")).toBe(false) + }) + + test("whitelist overrides directory match", () => { + expect(FileIgnore.match("node_modules/my-package/index.js", { whitelist: ["node_modules/**"] })).toBe(false) + }) + + test("extra patterns extend matching", () => { + expect(FileIgnore.match("config/.env")).toBe(false) + expect(FileIgnore.match("config/.env", { extra: ["**/.env"] })).toBe(true) + }) + + test("handles Windows-style path separators", () => { + expect(FileIgnore.match("node_modules\\package\\index.js")).toBe(true) + expect(FileIgnore.match("src\\.git\\config")).toBe(true) + }) +}) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 90ce4fbc23..093a418833 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -132,6 +132,54 @@ describe("Protected.isSensitiveWrite", () => { expect(Protected.isSensitiveWrite("package.json")).toBeUndefined() expect(Protected.isSensitiveWrite("models/schema.sql")).toBeUndefined() }) + + test("detects private key and certificate extensions", () => { + expect(Protected.isSensitiveWrite("certs/server.pem")).toBe("server.pem") + expect(Protected.isSensitiveWrite("ssl/private.key")).toBe("private.key") + expect(Protected.isSensitiveWrite("auth/cert.p12")).toBe("cert.p12") + expect(Protected.isSensitiveWrite("deploy/signing.pfx")).toBe("signing.pfx") + }) + + test("detects remaining sensitive directories", () => { + expect(Protected.isSensitiveWrite(".gnupg/pubring.kbx")).toBe(".gnupg") + expect(Protected.isSensitiveWrite(".gcloud/credentials.db")).toBe(".gcloud") + expect(Protected.isSensitiveWrite(".kube/config")).toBe(".kube") + expect(Protected.isSensitiveWrite(".docker/config.json")).toBe(".docker") + }) + + test("detects remaining sensitive files", () => { + expect(Protected.isSensitiveWrite(".npmrc")).toBe(".npmrc") + expect(Protected.isSensitiveWrite(".pypirc")).toBe(".pypirc") + expect(Protected.isSensitiveWrite(".netrc")).toBe(".netrc") + expect(Protected.isSensitiveWrite(".htpasswd")).toBe(".htpasswd") + expect(Protected.isSensitiveWrite(".pgpass")).toBe(".pgpass") + expect(Protected.isSensitiveWrite("id_rsa")).toBe("id_rsa") + expect(Protected.isSensitiveWrite("id_ed25519")).toBe("id_ed25519") + }) + + test("detects sensitive files in subdirectories", () => { + expect(Protected.isSensitiveWrite("config/subdir/.npmrc")).toBe(".npmrc") + expect(Protected.isSensitiveWrite("deploy/certs/server.key")).toBe("server.key") + }) + + test("handles Windows backslash paths", () => { + expect(Protected.isSensitiveWrite(".git\\config")).toBe(".git") + expect(Protected.isSensitiveWrite("config\\.env")).toBe(".env") + expect(Protected.isSensitiveWrite(".ssh\\id_rsa")).toBe(".ssh") + }) + + test("detects .env variant files via startsWith check", () => { + expect(Protected.isSensitiveWrite(".env.production.local")).toBe(".env.production.local") + expect(Protected.isSensitiveWrite(".env.test")).toBe(".env.test") + expect(Protected.isSensitiveWrite(".env.custom")).toBe(".env.custom") + }) + + test("does not flag files that merely contain sensitive substrings", () => { + expect(Protected.isSensitiveWrite("src/git-helper.ts")).toBeUndefined() + expect(Protected.isSensitiveWrite("docs/env-setup.md")).toBeUndefined() + expect(Protected.isSensitiveWrite("lib/ssh-utils.js")).toBeUndefined() + expect(Protected.isSensitiveWrite("my-key-manager.ts")).toBeUndefined() + }) }) /* diff --git a/packages/opencode/test/id/id.test.ts b/packages/opencode/test/id/id.test.ts new file mode 100644 index 0000000000..a2ebd6535a --- /dev/null +++ b/packages/opencode/test/id/id.test.ts @@ -0,0 +1,111 @@ +import { describe, test, expect } from "bun:test" +import { Identifier } from "../../src/id/id" + +describe("Identifier: prefix format and length", () => { + test("ascending() generates ID with correct prefix", () => { + const id = Identifier.ascending("session") + expect(id).toMatch(/^ses_/) + }) + + test("descending() generates ID with correct prefix", () => { + const id = Identifier.descending("message") + expect(id).toMatch(/^msg_/) + }) + + test("ID has expected total length (prefix + _ + 26 hex/base62 chars)", () => { + // "ses" (3) + "_" (1) + 26 = 30 + const id = Identifier.ascending("session") + expect(id.length).toBe(30) + }) + + test("tool prefix is 4 chars (outlier)", () => { + // "tool" (4) + "_" (1) + 26 = 31 + const id = Identifier.ascending("tool") + expect(id).toMatch(/^tool_/) + expect(id.length).toBe(31) + }) +}) + +describe("Identifier: ascending sort order", () => { + test("IDs with increasing timestamps sort ascending (string order)", () => { + const t = 1700000000000 + const a = Identifier.create("session", false, t) + const b = Identifier.create("session", false, t + 1) + expect(a < b).toBe(true) + }) + + test("multiple IDs at same timestamp are unique and ascending", () => { + const t = 1700000001000 + const ids = Array.from({ length: 10 }, () => Identifier.create("session", false, t)) + const unique = new Set(ids) + expect(unique.size).toBe(10) + for (let i = 1; i < ids.length; i++) { + expect(ids[i - 1] < ids[i]).toBe(true) + } + }) +}) + +describe("Identifier: descending sort order", () => { + test("IDs with increasing timestamps sort descending (string order)", () => { + const t = 1700000002000 + const a = Identifier.create("session", true, t) + const b = Identifier.create("session", true, t + 1) + // Later timestamp → smaller string for descending + expect(a > b).toBe(true) + }) +}) + +describe("Identifier: timestamp comparison", () => { + test("timestamp() preserves relative ordering for ascending IDs", () => { + const t1 = 1700000003000 + const t2 = 1700000004000 + const id1 = Identifier.create("session", false, t1) + const id2 = Identifier.create("session", false, t2) + // timestamp() may not recover the exact input due to 48-bit storage, + // but it must preserve relative ordering (used for cleanup cutoffs) + expect(Identifier.timestamp(id1)).toBeLessThan(Identifier.timestamp(id2)) + }) + + test("timestamp() returns same value for IDs created at same time", () => { + const t = 1700000005000 + const id1 = Identifier.create("session", false, t) + const id2 = Identifier.create("session", false, t) + // Both IDs at same timestamp should produce the same (or very close) extracted timestamp + // The counter increment adds at most a few units that divide away + expect(Identifier.timestamp(id1)).toBe(Identifier.timestamp(id2)) + }) +}) + +describe("Identifier: given passthrough", () => { + test("returns given ID as-is when prefix matches", () => { + const given = "ses_abcdef1234567890abcdef1234" + const result = Identifier.ascending("session", given) + expect(result).toBe(given) + }) + + test("throws when given ID has wrong prefix", () => { + expect(() => Identifier.ascending("session", "msg_abc")).toThrow( + "does not start with ses", + ) + }) +}) + +describe("Identifier: schema validation", () => { + test("schema accepts valid session ID", () => { + const s = Identifier.schema("session") + const id = Identifier.ascending("session") + expect(s.safeParse(id).success).toBe(true) + }) + + test("schema rejects ID with wrong prefix", () => { + const s = Identifier.schema("session") + expect(s.safeParse("msg_abc123").success).toBe(false) + }) + + test("schema for tool prefix works (4-char prefix)", () => { + const s = Identifier.schema("tool") + const id = Identifier.ascending("tool") + expect(s.safeParse(id).success).toBe(true) + expect(s.safeParse("ses_abc").success).toBe(false) + }) +}) diff --git a/packages/opencode/test/patch/patch.test.ts b/packages/opencode/test/patch/patch.test.ts index 020253bfe2..41c6c51edd 100644 --- a/packages/opencode/test/patch/patch.test.ts +++ b/packages/opencode/test/patch/patch.test.ts @@ -261,6 +261,167 @@ PATCH` }) }) + describe("maybeParseApplyPatchVerified", () => { + test("detects implicit invocation (raw patch without apply_patch command)", async () => { + const patchText = `*** Begin Patch +*** Add File: test.txt ++Content +*** End Patch` + + const result = await Patch.maybeParseApplyPatchVerified([patchText], tempDir) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.CorrectnessError) + if (result.type === Patch.MaybeApplyPatchVerified.CorrectnessError) { + expect(result.error.message).toBe(Patch.ApplyPatchError.ImplicitInvocation) + } + }) + + test("returns NotApplyPatch for single arg that is not a valid patch", async () => { + const result = await Patch.maybeParseApplyPatchVerified(["echo hello"], tempDir) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.NotApplyPatch) + }) + + test("returns NotApplyPatch for unrelated multi-arg commands", async () => { + const result = await Patch.maybeParseApplyPatchVerified(["ls", "-la", "/tmp"], tempDir) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.NotApplyPatch) + }) + + test("returns Body with add change for apply_patch add hunk", async () => { + const patchText = `*** Begin Patch +*** Add File: new-file.txt ++line one ++line two +*** End Patch` + + const result = await Patch.maybeParseApplyPatchVerified(["apply_patch", patchText], tempDir) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.Body) + if (result.type === Patch.MaybeApplyPatchVerified.Body) { + const resolvedPath = path.resolve(tempDir, "new-file.txt") + const change = result.action.changes.get(resolvedPath) + expect(change).toBeDefined() + expect(change!.type).toBe("add") + if (change!.type === "add") { + expect(change!.content).toBe("line one\nline two") + } + expect(result.action.cwd).toBe(tempDir) + } + }) + + test("returns Body with delete change including file content", async () => { + const filePath = path.join(tempDir, "to-delete.txt") + await fs.writeFile(filePath, "original content here") + + const patchText = `*** Begin Patch +*** Delete File: to-delete.txt +*** End Patch` + + const result = await Patch.maybeParseApplyPatchVerified(["apply_patch", patchText], tempDir) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.Body) + if (result.type === Patch.MaybeApplyPatchVerified.Body) { + const resolvedPath = path.resolve(tempDir, "to-delete.txt") + const change = result.action.changes.get(resolvedPath) + expect(change).toBeDefined() + expect(change!.type).toBe("delete") + if (change!.type === "delete") { + expect(change!.content).toBe("original content here") + } + } + }) + + test("returns CorrectnessError when deleting non-existent file", async () => { + const patchText = `*** Begin Patch +*** Delete File: no-such-file.txt +*** End Patch` + + const result = await Patch.maybeParseApplyPatchVerified(["apply_patch", patchText], tempDir) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.CorrectnessError) + if (result.type === Patch.MaybeApplyPatchVerified.CorrectnessError) { + expect(result.error.message).toContain("Failed to read file for deletion") + } + }) + + test("returns Body with update change including unified_diff and new_content", async () => { + const filePath = path.join(tempDir, "to-update.txt") + await fs.writeFile(filePath, "alpha\nbeta\ngamma\n") + + const patchText = `*** Begin Patch +*** Update File: to-update.txt +@@ + alpha +-beta ++BETA + gamma +*** End Patch` + + const result = await Patch.maybeParseApplyPatchVerified(["apply_patch", patchText], tempDir) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.Body) + if (result.type === Patch.MaybeApplyPatchVerified.Body) { + const resolvedPath = path.resolve(tempDir, "to-update.txt") + const change = result.action.changes.get(resolvedPath) + expect(change).toBeDefined() + expect(change!.type).toBe("update") + if (change!.type === "update") { + expect(change!.new_content).toBe("alpha\nBETA\ngamma\n") + expect(change!.unified_diff).toContain("-beta") + expect(change!.unified_diff).toContain("+BETA") + expect(change!.move_path).toBeUndefined() + } + } + }) + + test("returns CorrectnessError when updating file with non-matching old_lines", async () => { + const filePath = path.join(tempDir, "mismatch.txt") + await fs.writeFile(filePath, "actual content\n") + + const patchText = `*** Begin Patch +*** Update File: mismatch.txt +@@ +-completely different content ++replacement +*** End Patch` + + const result = await Patch.maybeParseApplyPatchVerified(["apply_patch", patchText], tempDir) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.CorrectnessError) + if (result.type === Patch.MaybeApplyPatchVerified.CorrectnessError) { + expect(result.error.message).toContain("Failed to find expected lines") + } + }) + + test("resolves move_path for update with move directive", async () => { + const filePath = path.join(tempDir, "old-name.txt") + await fs.writeFile(filePath, "keep this\n") + + const patchText = `*** Begin Patch +*** Update File: old-name.txt +*** Move to: new-name.txt +@@ +-keep this ++keep that +*** End Patch` + + const result = await Patch.maybeParseApplyPatchVerified(["apply_patch", patchText], tempDir) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.Body) + if (result.type === Patch.MaybeApplyPatchVerified.Body) { + // The change is keyed by the resolved move_path, not the original path + const resolvedMovePath = path.resolve(tempDir, "new-name.txt") + const change = result.action.changes.get(resolvedMovePath) + expect(change).toBeDefined() + expect(change!.type).toBe("update") + if (change!.type === "update") { + expect(change!.move_path).toBe(resolvedMovePath) + expect(change!.new_content).toBe("keep that\n") + } + } + }) + + test("returns CorrectnessError for invalid patch syntax", async () => { + const result = await Patch.maybeParseApplyPatchVerified( + ["apply_patch", "this is not a valid patch at all"], + tempDir, + ) + expect(result.type).toBe(Patch.MaybeApplyPatchVerified.CorrectnessError) + }) + }) + describe("error handling", () => { test("should throw error when updating non-existent file", async () => { const nonExistent = path.join(tempDir, "does-not-exist.txt") diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index e0bf94a950..4a6c1d8845 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -4,6 +4,201 @@ import { InstructionPrompt } from "../../src/session/instruction" import { Instance } from "../../src/project/instance" import { Global } from "../../src/global" import { tmpdir } from "../fixture/fixture" +import type { MessageV2 } from "../../src/session/message-v2" +import { SessionID, MessageID, PartID } from "../../src/session/schema" + +// ─── Helpers for InstructionPrompt.loaded() ───────────────────────────────── + +const sid = SessionID.make("test-session") + +function makeUserMsg(id: string, parts: MessageV2.Part[]): MessageV2.WithParts { + return { + info: { + id: MessageID.make(id), + sessionID: sid, + role: "user" as const, + time: { created: 0 }, + agent: "user", + model: { providerID: "test" as any, modelID: "test" as any }, + tools: {}, + mode: "", + } as MessageV2.User, + parts, + } +} + +function readToolPart(opts: { + id: string + messageID: string + status: "completed" | "running" | "error" + loaded?: unknown[] + compacted?: number +}): MessageV2.ToolPart { + if (opts.status === "completed") { + return { + id: PartID.make(opts.id), + sessionID: sid, + messageID: MessageID.make(opts.messageID), + type: "tool", + callID: opts.id, + tool: "read", + state: { + status: "completed", + input: {}, + output: "file content", + title: "Read file", + metadata: opts.loaded !== undefined ? { loaded: opts.loaded } : {}, + time: { start: 0, end: 1, ...(opts.compacted !== undefined ? { compacted: opts.compacted } : {}) }, + }, + } as MessageV2.ToolPart + } + if (opts.status === "running") { + return { + id: PartID.make(opts.id), + sessionID: sid, + messageID: MessageID.make(opts.messageID), + type: "tool", + callID: opts.id, + tool: "read", + state: { + status: "running", + input: {}, + time: { start: 0 }, + }, + } as MessageV2.ToolPart + } + return { + id: PartID.make(opts.id), + sessionID: sid, + messageID: MessageID.make(opts.messageID), + type: "tool", + callID: opts.id, + tool: "read", + state: { + status: "error", + input: {}, + error: "read failed", + time: { start: 0, end: 1 }, + }, + } as MessageV2.ToolPart +} + +function nonReadToolPart(opts: { + id: string + messageID: string + tool: string + loaded?: unknown[] +}): MessageV2.ToolPart { + return { + id: PartID.make(opts.id), + sessionID: sid, + messageID: MessageID.make(opts.messageID), + type: "tool", + callID: opts.id, + tool: opts.tool, + state: { + status: "completed", + input: {}, + output: "done", + title: "Tool done", + metadata: opts.loaded !== undefined ? { loaded: opts.loaded } : {}, + time: { start: 0, end: 1 }, + }, + } as MessageV2.ToolPart +} + +// ─── InstructionPrompt.loaded() ───────────────────────────────────────────── + +describe("InstructionPrompt.loaded", () => { + test("returns empty set for messages with no tool parts", () => { + const textPart = { + id: PartID.make("p1"), + sessionID: sid, + messageID: MessageID.make("m1"), + type: "text", + content: "hello", + } as unknown as MessageV2.Part + const result = InstructionPrompt.loaded([makeUserMsg("m1", [textPart])]) + expect(result.size).toBe(0) + }) + + test("extracts paths from completed read tool parts with loaded metadata", () => { + const part = readToolPart({ + id: "p1", + messageID: "m1", + status: "completed", + loaded: ["/project/subdir/AGENTS.md", "/project/lib/AGENTS.md"], + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part])]) + expect(result.size).toBe(2) + expect(result.has("/project/subdir/AGENTS.md")).toBe(true) + expect(result.has("/project/lib/AGENTS.md")).toBe(true) + }) + + test("skips compacted tool parts", () => { + const part = readToolPart({ + id: "p1", + messageID: "m1", + status: "completed", + loaded: ["/project/AGENTS.md"], + compacted: 12345, + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part])]) + expect(result.size).toBe(0) + }) + + test("skips non-read tool parts even with loaded metadata", () => { + const part = nonReadToolPart({ + id: "p1", + messageID: "m1", + tool: "bash", + loaded: ["/project/AGENTS.md"], + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part])]) + expect(result.size).toBe(0) + }) + + test("skips non-completed read tool parts", () => { + const runningPart = readToolPart({ id: "p1", messageID: "m1", status: "running" }) + const errorPart = readToolPart({ id: "p2", messageID: "m1", status: "error" }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [runningPart, errorPart])]) + expect(result.size).toBe(0) + }) + + test("filters out non-string entries in the loaded array", () => { + const part = readToolPart({ + id: "p1", + messageID: "m1", + status: "completed", + loaded: ["/valid/path", 42, null, { nested: true }, "/another/path", undefined], + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part])]) + expect(result.size).toBe(2) + expect(result.has("/valid/path")).toBe(true) + expect(result.has("/another/path")).toBe(true) + }) + + test("deduplicates paths across multiple messages", () => { + const part1 = readToolPart({ + id: "p1", + messageID: "m1", + status: "completed", + loaded: ["/project/AGENTS.md"], + }) + const part2 = readToolPart({ + id: "p2", + messageID: "m2", + status: "completed", + loaded: ["/project/AGENTS.md", "/project/lib/AGENTS.md"], + }) + const result = InstructionPrompt.loaded([makeUserMsg("m1", [part1]), makeUserMsg("m2", [part2])]) + expect(result.size).toBe(2) + expect(result.has("/project/AGENTS.md")).toBe(true) + expect(result.has("/project/lib/AGENTS.md")).toBe(true) + }) +}) + +// ─── InstructionPrompt.resolve ────────────────────────────────────────────── describe("InstructionPrompt.resolve", () => { test("returns empty when AGENTS.md is at project root (already in systemPaths)", async () => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index e9c6cb729b..91bdc4691c 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -896,3 +896,141 @@ describe("session.message-v2.fromError", () => { }) }) }) + +// ─── filterCompacted ──────────────────────────────────────────────────────── + +describe("session.message-v2.filterCompacted", () => { + // Helper to create a user message with optional compaction part + function userMsg(id: string, opts?: { compaction?: boolean }): MessageV2.WithParts { + const parts: MessageV2.Part[] = [] + if (opts?.compaction) { + parts.push({ + ...basePart(id, `${id}-compact`), + type: "compaction", + auto: true, + } as unknown as MessageV2.Part) + } + parts.push({ + ...basePart(id, `${id}-text`), + type: "text", + content: `user message ${id}`, + } as unknown as MessageV2.Part) + return { + info: { ...userInfo(id) } as MessageV2.User, + parts, + } + } + + // Helper to create an assistant message + function assistantMsg( + id: string, + parentID: string, + opts?: { summary?: boolean; finish?: string; error?: boolean }, + ): MessageV2.WithParts { + const info = assistantInfo(id, parentID) as any + if (opts?.summary) info.summary = true + if (opts?.finish) info.finish = opts.finish + if (opts?.error) { + info.error = { name: "UnknownError", data: { message: "something went wrong" } } + } + return { + info: info as MessageV2.Assistant, + parts: [ + { + ...basePart(id, `${id}-text`), + type: "text", + content: `assistant response ${id}`, + } as unknown as MessageV2.Part, + ], + } + } + + async function* toStream(msgs: MessageV2.WithParts[]) { + for (const msg of msgs) yield msg + } + + test("returns all messages reversed when no compaction point exists", async () => { + const u1 = userMsg("u1") + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2") + const a2 = assistantMsg("a2", "u2", { summary: true, finish: "stop" }) + + // Stream is newest-first (reverse chronological, as the DB query returns) + const result = await MessageV2.filterCompacted(toStream([a2, u2, a1, u1])) + + // Reversed: oldest-first + expect(result.length).toBe(4) + expect(String(result[0].info.id)).toBe("u1") + expect(String(result[1].info.id)).toBe("a1") + expect(String(result[2].info.id)).toBe("u2") + expect(String(result[3].info.id)).toBe("a2") + }) + + test("stops at compaction boundary and returns slice reversed", async () => { + // Stream (newest-first): a3, u3, a2, u2(compacted), a1, u1 + // u2 has a compaction part AND a1 completed successfully with parentID=u1 + // But a2 completed with parentID=u2, so u2 is in completed set + const u1 = userMsg("u1") + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2", { compaction: true }) + const a2 = assistantMsg("a2", "u2", { summary: true, finish: "stop" }) + const u3 = userMsg("u3") + const a3 = assistantMsg("a3", "u3", { summary: true, finish: "stop" }) + + const result = await MessageV2.filterCompacted(toStream([a3, u3, a2, u2, a1, u1])) + + // Should stop at u2 (has compaction part and is in completed set via a2) + // Collected: [a3, u3, a2, u2] then reversed + expect(result.length).toBe(4) + expect(String(result[0].info.id)).toBe("u2") + expect(String(result[1].info.id)).toBe("a2") + expect(String(result[2].info.id)).toBe("u3") + expect(String(result[3].info.id)).toBe("a3") + }) + + test("does not stop at user message with compaction part if no matching assistant completion", async () => { + // u2 has compaction part but no assistant completed with parentID=u2 + const u1 = userMsg("u1") + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2", { compaction: true }) + // a2 is still running (no summary, no finish) + const a2 = assistantMsg("a2", "u2") + const u3 = userMsg("u3") + + const result = await MessageV2.filterCompacted(toStream([u3, a2, u2, a1, u1])) + + // Should NOT stop at u2 because u2 is not in the completed set + // All 5 messages returned, reversed + expect(result.length).toBe(5) + expect(String(result[0].info.id)).toBe("u1") + expect(String(result[4].info.id)).toBe("u3") + }) + + test("errored assistant does not mark parent as completed", async () => { + // a2 has error, summary, finish — but error should prevent marking u2 as completed + const u1 = userMsg("u1") + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2", { compaction: true }) + const a2 = assistantMsg("a2", "u2", { summary: true, finish: "stop", error: true }) + const u3 = userMsg("u3") + + const result = await MessageV2.filterCompacted(toStream([u3, a2, u2, a1, u1])) + + // Should NOT stop at u2 because a2 has an error + expect(result.length).toBe(5) + expect(String(result[0].info.id)).toBe("u1") + expect(String(result[4].info.id)).toBe("u3") + }) + + test("requires both compaction part and completed set membership to stop", async () => { + // a1 completes for u1, so u1 is in completed set, but u1 has NO compaction part + const u1 = userMsg("u1") // no compaction part + const a1 = assistantMsg("a1", "u1", { summary: true, finish: "stop" }) + const u2 = userMsg("u2") + + const result = await MessageV2.filterCompacted(toStream([u2, a1, u1])) + + // Should NOT stop at u1 because u1 has no compaction part + expect(result.length).toBe(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..aa3d5f21bd --- /dev/null +++ b/packages/opencode/test/session/summary-git-path.test.ts @@ -0,0 +1,184 @@ +import { describe, test, expect } from "bun:test" +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" +import { tmpdir } from "../fixture/fixture" + +/** + * 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. + */ + +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 using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + 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 using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + 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 using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + 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 using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + 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 using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + 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 using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + 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 using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const files = await roundtrip(['"file\\"name.txt"']) + expect(files).toEqual(['file"name.txt']) + }, + }) + }) + + test("empty string passes through unchanged", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + 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 using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + 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 using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + 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", + ]) + }, + }) + }) +}) diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/opencode/test/shell/shell.test.ts new file mode 100644 index 0000000000..7f3f64cadc --- /dev/null +++ b/packages/opencode/test/shell/shell.test.ts @@ -0,0 +1,96 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { Shell } from "../../src/shell/shell" + +describe("Shell.acceptable: blacklist enforcement", () => { + let savedShell: string | undefined + + beforeEach(() => { + savedShell = process.env.SHELL + // Reset the lazy caches so each test starts fresh + Shell.acceptable.reset() + Shell.preferred.reset() + }) + + afterEach(() => { + if (savedShell !== undefined) { + process.env.SHELL = savedShell + } else { + delete process.env.SHELL + } + Shell.acceptable.reset() + Shell.preferred.reset() + }) + + test("returns SHELL when set to bash", () => { + process.env.SHELL = "/bin/bash" + expect(Shell.acceptable()).toBe("/bin/bash") + }) + + test("returns SHELL when set to zsh", () => { + process.env.SHELL = "/usr/bin/zsh" + expect(Shell.acceptable()).toBe("/usr/bin/zsh") + }) + + test("rejects fish and returns fallback", () => { + process.env.SHELL = "/usr/bin/fish" + const result = Shell.acceptable() + expect(result).not.toBe("/usr/bin/fish") + // Fallback should be a real shell path + expect(result.length).toBeGreaterThan(0) + }) + + test("rejects nu (nushell) and returns fallback", () => { + process.env.SHELL = "/usr/bin/nu" + const result = Shell.acceptable() + expect(result).not.toBe("/usr/bin/nu") + expect(result.length).toBeGreaterThan(0) + }) + + test("shell containing 'nu' in name but not basename is not blacklisted", () => { + // /opt/menu/bin/bash — basename is "bash", not "nu" + process.env.SHELL = "/opt/nushell/bin/bash" + expect(Shell.acceptable()).toBe("/opt/nushell/bin/bash") + }) + + test("returns fallback when SHELL is unset", () => { + delete process.env.SHELL + const result = Shell.acceptable() + expect(result.length).toBeGreaterThan(0) + // On Linux/macOS, fallback should be a valid shell path + expect(result).toMatch(/\/(bash|zsh|sh|cmd\.exe)$/) + }) +}) + +describe("Shell.preferred: no blacklist filtering", () => { + let savedShell: string | undefined + + beforeEach(() => { + savedShell = process.env.SHELL + Shell.preferred.reset() + }) + + afterEach(() => { + if (savedShell !== undefined) { + process.env.SHELL = savedShell + } else { + delete process.env.SHELL + } + Shell.preferred.reset() + }) + + test("returns SHELL even when blacklisted (fish)", () => { + process.env.SHELL = "/usr/bin/fish" + expect(Shell.preferred()).toBe("/usr/bin/fish") + }) + + test("returns SHELL even when blacklisted (nu)", () => { + process.env.SHELL = "/usr/bin/nu" + expect(Shell.preferred()).toBe("/usr/bin/nu") + }) + + test("returns fallback when SHELL is unset", () => { + delete process.env.SHELL + const result = Shell.preferred() + expect(result.length).toBeGreaterThan(0) + }) +})