From d0043a4a7855a2891992d7a84c9e6a316f54fcb9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 27 Sep 2025 02:53:20 -0400 Subject: [PATCH] sync --- packages/opencode/src/config/markdown.ts | 12 + packages/opencode/src/session/prompt.ts | 12 +- packages/opencode/src/tool/registry.ts | 34 +- packages/opencode/test/config/config.test.ts | 353 ++++++++++++++++++ .../opencode/test/config/markdown.test.ts | 89 +++++ packages/opencode/test/fixture/fixture.ts | 10 +- .../opencode/test/session/fileRegex.test.ts | 91 ----- packages/opencode/test/tool/tool.test.ts | 70 ---- 8 files changed, 483 insertions(+), 188 deletions(-) create mode 100644 packages/opencode/src/config/markdown.ts create mode 100644 packages/opencode/test/config/config.test.ts create mode 100644 packages/opencode/test/config/markdown.test.ts delete mode 100644 packages/opencode/test/session/fileRegex.test.ts delete mode 100644 packages/opencode/test/tool/tool.test.ts diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts new file mode 100644 index 00000000..e0e5afe4 --- /dev/null +++ b/packages/opencode/src/config/markdown.ts @@ -0,0 +1,12 @@ +export namespace ConfigMarkdown { + export const FILE_REGEX = /(? 0) { + const shell = ConfigMarkdown.shell(template) + if (shell.length > 0) { const results = await Promise.all( - bash.map(async ([, cmd]) => { + shell.map(async ([, cmd]) => { try { return await $`${{ raw: cmd }}`.nothrow().text() } catch (error) { @@ -1395,9 +1395,9 @@ export namespace SessionPrompt { }, ] as PromptInput["parts"] - const matches = Array.from(template.matchAll(fileRegex)) + const files = ConfigMarkdown.files(template) await Promise.all( - matches.map(async (match) => { + files.map(async (match) => { const name = match[1] const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index b97575ec..65c54640 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -21,21 +21,23 @@ import { Plugin } from "../plugin" export namespace ToolRegistry { // Built-in tools that ship with opencode - const BUILTIN = [ - InvalidTool, - BashTool, - EditTool, - WebFetchTool, - GlobTool, - GrepTool, - ListTool, - PatchTool, - ReadTool, - WriteTool, - TodoWriteTool, - TodoReadTool, - TaskTool, - ] + function builtin() { + return [ + InvalidTool, + BashTool, + EditTool, + WebFetchTool, + GlobTool, + GrepTool, + ListTool, + PatchTool, + ReadTool, + WriteTool, + TodoWriteTool, + TodoReadTool, + TaskTool, + ] + } export const state = Instance.state(async () => { const custom = [] as Tool.Info[] @@ -91,7 +93,7 @@ export namespace ToolRegistry { async function all(): Promise { const custom = await state().then((x) => x.custom) - return [...BUILTIN, ...custom] + return [...builtin(), ...custom] } export async function ids() { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts new file mode 100644 index 00000000..cae895dc --- /dev/null +++ b/packages/opencode/test/config/config.test.ts @@ -0,0 +1,353 @@ +import { test, expect } from "bun:test" +import { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" + +test("loads config with defaults when no files exist", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.username).toBeDefined() + expect(config.model).toBeDefined() + }, + }) +}) + +test("loads JSON config file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "test/model", + username: "testuser", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("test/model") + expect(config.username).toBe("testuser") + }, + }) +}) + +test("loads JSONC config file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.jsonc"), + `{ + // This is a comment + "$schema": "https://opencode.ai/config.json", + "model": "test/model", + "username": "testuser" + }`, + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("test/model") + expect(config.username).toBe("testuser") + }, + }) +}) + +test("merges multiple config files with correct precedence", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "base", + username: "base", + }), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "override", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("override") + expect(config.username).toBe("base") + }, + }) +}) + +test("handles environment variable substitution", async () => { + const originalEnv = process.env["TEST_VAR"] + process.env["TEST_VAR"] = "test_theme" + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + theme: "{env:TEST_VAR}", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("test_theme") + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["TEST_VAR"] = originalEnv + } else { + delete process.env["TEST_VAR"] + } + } +}) + +test("handles file inclusion substitution", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "included.txt"), "test_theme") + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + theme: "{file:included.txt}", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("test_theme") + }, + }) +}) + +test("validates config schema and throws on invalid fields", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + invalid_field: "should cause error", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Strict schema should throw an error for invalid fields + await expect(Config.get()).rejects.toThrow() + }, + }) +}) + +test("throws error for invalid JSON", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Config.get()).rejects.toThrow() + }, + }) +}) + +test("handles agent configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test_agent: { + model: "test/model", + temperature: 0.7, + description: "test agent", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test_agent"]).toEqual({ + model: "test/model", + temperature: 0.7, + description: "test agent", + }) + }, + }) +}) + +test("handles command configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + command: { + test_command: { + template: "test template", + description: "test command", + agent: "test_agent", + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.command?.["test_command"]).toEqual({ + template: "test template", + description: "test command", + agent: "test_agent", + }) + }, + }) +}) + +test("migrates autoshare to share field", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + autoshare: true, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.share).toBe("auto") + expect(config.autoshare).toBe(true) + }, + }) +}) + +test("migrates mode field to agent field", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mode: { + test_mode: { + model: "test/model", + temperature: 0.5, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test_mode"]).toEqual({ + model: "test/model", + temperature: 0.5, + mode: "primary", + }) + }, + }) +}) + +test("loads config from .opencode directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + const agentDir = path.join(opencodeDir, "agent") + await fs.mkdir(agentDir, { recursive: true }) + + await Bun.write( + path.join(agentDir, "test.md"), + `--- +model: test/model +--- +Test agent prompt`, + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.agent?.["test"]).toEqual({ + name: "test", + model: "test/model", + prompt: "Test agent prompt", + }) + }, + }) +}) + +test("updates config and writes to file", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const newConfig = { model: "updated/model" } + await Config.update(newConfig as any) + + const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text()) + expect(writtenConfig.model).toBe("updated/model") + }, + }) +}) + +test("gets config directories", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const dirs = await Config.directories() + expect(dirs.length).toBeGreaterThanOrEqual(1) + }, + }) +}) diff --git a/packages/opencode/test/config/markdown.test.ts b/packages/opencode/test/config/markdown.test.ts new file mode 100644 index 00000000..392ca391 --- /dev/null +++ b/packages/opencode/test/config/markdown.test.ts @@ -0,0 +1,89 @@ +import { expect, test } from "bun:test" +import { ConfigMarkdown } from "../../src/config/markdown" + +const template = `This is a @valid/path/to/a/file and it should also match at +the beginning of a line: + +@another-valid/path/to/a/file + +but this is not: + + - Adds a "Co-authored-by:" footer which clarifies which AI agent + helped create this commit, using an appropriate \`noreply@...\` + or \`noreply@anthropic.com\` email address. + +We also need to deal with files followed by @commas, ones +with @file-extensions.md, even @multiple.extensions.bak, +hidden directorys like @.config/ or files like @.bashrc +and ones at the end of a sentence like @foo.md. + +Also shouldn't forget @/absolute/paths.txt with and @/without/extensions, +as well as @~/home-files and @~/paths/under/home.txt. + +If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.` + +const matches = ConfigMarkdown.files(template) + +test("should extract exactly 12 file references", () => { + expect(matches.length).toBe(12) +}) + +test("should extract valid/path/to/a/file", () => { + expect(matches[0][1]).toBe("valid/path/to/a/file") +}) + +test("should extract another-valid/path/to/a/file", () => { + expect(matches[1][1]).toBe("another-valid/path/to/a/file") +}) + +test("should extract paths ignoring comma after", () => { + expect(matches[2][1]).toBe("commas") +}) + +test("should extract a path with a file extension and comma after", () => { + expect(matches[3][1]).toBe("file-extensions.md") +}) + +test("should extract a path with multiple dots and comma after", () => { + expect(matches[4][1]).toBe("multiple.extensions.bak") +}) + +test("should extract hidden directory", () => { + expect(matches[5][1]).toBe(".config/") +}) + +test("should extract hidden file", () => { + expect(matches[6][1]).toBe(".bashrc") +}) + +test("should extract a file ignoring period at end of sentence", () => { + expect(matches[7][1]).toBe("foo.md") +}) + +test("should extract an absolute path with an extension", () => { + expect(matches[8][1]).toBe("/absolute/paths.txt") +}) + +test("should extract an absolute path without an extension", () => { + expect(matches[9][1]).toBe("/without/extensions") +}) + +test("should extract an absolute path in home directory", () => { + expect(matches[10][1]).toBe("~/home-files") +}) + +test("should extract an absolute path under home directory", () => { + expect(matches[11][1]).toBe("~/paths/under/home.txt") +}) + +test("should not match when preceded by backtick", () => { + const backtickTest = "This `@should/not/match` should be ignored" + const backtickMatches = ConfigMarkdown.files(backtickTest) + expect(backtickMatches.length).toBe(0) +}) + +test("should not match email addresses", () => { + const emailTest = "Contact user@example.com for help" + const emailMatches = ConfigMarkdown.files(emailTest) + expect(emailMatches.length).toBe(0) +}) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 761b377c..0b83bb31 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -2,12 +2,12 @@ import { $ } from "bun" import os from "os" import path from "path" -type TmpDirOptions> = { +type TmpDirOptions = { git?: boolean - init?: (dir: string) => Promise - dispose?: (dir: string) => Promise + init?: (dir: string) => Promise + dispose?: (dir: string) => Promise } -export async function tmpdir>(options?: TmpDirOptions) { +export async function tmpdir(options?: TmpDirOptions) { const dirpath = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) await $`mkdir -p ${dirpath}`.quiet() if (options?.git) await $`git init`.cwd(dirpath).quiet() @@ -18,7 +18,7 @@ export async function tmpdir>(options?: TmpDirO await $`rm -rf ${dirpath}`.quiet() }, path: dirpath, - extra: extra as Init, + extra: extra as T, } return result } diff --git a/packages/opencode/test/session/fileRegex.test.ts b/packages/opencode/test/session/fileRegex.test.ts deleted file mode 100644 index a1f0875e..00000000 --- a/packages/opencode/test/session/fileRegex.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { SessionPrompt } from "../../src/session/prompt" - -describe("fileRegex", () => { - const template = `This is a @valid/path/to/a/file and it should also match at -the beginning of a line: - -@another-valid/path/to/a/file - -but this is not: - - - Adds a "Co-authored-by:" footer which clarifies which AI agent - helped create this commit, using an appropriate \`noreply@...\` - or \`noreply@anthropic.com\` email address. - -We also need to deal with files followed by @commas, ones -with @file-extensions.md, even @multiple.extensions.bak, -hidden directorys like @.config/ or files like @.bashrc -and ones at the end of a sentence like @foo.md. - -Also shouldn't forget @/absolute/paths.txt with and @/without/extensions, -as well as @~/home-files and @~/paths/under/home.txt. - -If the reference is \`@quoted/in/backticks\` then it shouldn't match at all.` - - const matches = Array.from(template.matchAll(SessionPrompt.fileRegex)) - - test("should extract exactly 12 file references", () => { - expect(matches.length).toBe(12) - }) - - test("should extract valid/path/to/a/file", () => { - expect(matches[0][1]).toBe("valid/path/to/a/file") - }) - - test("should extract another-valid/path/to/a/file", () => { - expect(matches[1][1]).toBe("another-valid/path/to/a/file") - }) - - test("should extract paths ignoring comma after", () => { - expect(matches[2][1]).toBe("commas") - }) - - test("should extract a path with a file extension and comma after", () => { - expect(matches[3][1]).toBe("file-extensions.md") - }) - - test("should extract a path with multiple dots and comma after", () => { - expect(matches[4][1]).toBe("multiple.extensions.bak") - }) - - test("should extract hidden directory", () => { - expect(matches[5][1]).toBe(".config/") - }) - - test("should extract hidden file", () => { - expect(matches[6][1]).toBe(".bashrc") - }) - - test("should extract a file ignoring period at end of sentence", () => { - expect(matches[7][1]).toBe("foo.md") - }) - - test("should extract an absolute path with an extension", () => { - expect(matches[8][1]).toBe("/absolute/paths.txt") - }) - - test("should extract an absolute path without an extension", () => { - expect(matches[9][1]).toBe("/without/extensions") - }) - - test("should extract an absolute path in home directory", () => { - expect(matches[10][1]).toBe("~/home-files") - }) - - test("should extract an absolute path under home directory", () => { - expect(matches[11][1]).toBe("~/paths/under/home.txt") - }) - - test("should not match when preceded by backtick", () => { - const backtickTest = "This `@should/not/match` should be ignored" - const backtickMatches = Array.from(backtickTest.matchAll(SessionPrompt.fileRegex)) - expect(backtickMatches.length).toBe(0) - }) - - test("should not match email addresses", () => { - const emailTest = "Contact user@example.com for help" - const emailMatches = Array.from(emailTest.matchAll(SessionPrompt.fileRegex)) - expect(emailMatches.length).toBe(0) - }) -}) diff --git a/packages/opencode/test/tool/tool.test.ts b/packages/opencode/test/tool/tool.test.ts deleted file mode 100644 index 0560fa09..00000000 --- a/packages/opencode/test/tool/tool.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { GlobTool } from "../../src/tool/glob" -import { ListTool } from "../../src/tool/ls" -import path from "path" -import { Instance } from "../../src/project/instance" - -const ctx = { - sessionID: "test", - messageID: "", - toolCallID: "", - agent: "build", - abort: AbortSignal.any([]), - metadata: () => {}, -} -const glob = await GlobTool.init() -const list = await ListTool.init() - -const projectRoot = path.join(__dirname, "../..") -const fixturePath = path.join(__dirname, "../fixtures/example") - -describe("tool.glob", () => { - test("truncate", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - let result = await glob.execute( - { - pattern: "**/*", - path: "../../node_modules", - }, - ctx, - ) - expect(result.metadata.truncated).toBe(true) - }, - }) - }) - test("basic", async () => { - await Instance.provide({ - directory: projectRoot, - fn: async () => { - let result = await glob.execute( - { - pattern: "*.json", - path: undefined, - }, - ctx, - ) - expect(result.metadata).toMatchObject({ - truncated: false, - count: 2, - }) - }, - }) - }) -}) - -describe("tool.ls", () => { - test("basic", async () => { - const result = await Instance.provide({ - directory: projectRoot, - fn: async () => { - return await list.execute({ path: fixturePath, ignore: [".git"] }, ctx) - }, - }) - - // Normalize absolute path to relative for consistent snapshots - const normalizedOutput = result.output.replace(fixturePath, "packages/opencode/test/fixtures/example") - expect(normalizedOutput).toMatchSnapshot() - }) -})