This commit is contained in:
Dax Raad
2025-09-27 02:53:20 -04:00
parent 26ebf85b0e
commit d0043a4a78
8 changed files with 483 additions and 188 deletions

View File

@@ -0,0 +1,12 @@
export namespace ConfigMarkdown {
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export const SHELL_REGEX = /`[^`]+`/g
export function files(template: string) {
return Array.from(template.matchAll(FILE_REGEX))
}
export function shell(template: string) {
return Array.from(template.matchAll(SHELL_REGEX))
}
}

View File

@@ -48,6 +48,7 @@ import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $ } from "bun"
import { ConfigMarkdown } from "../config/markdown"
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
@@ -1364,7 +1365,6 @@ export namespace SessionPrompt {
* Matches @ followed by file paths, excluding commas, periods at end of sentences, and backticks
* Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
*/
export const fileRegex = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
export async function command(input: CommandInput) {
log.info("command", input)
@@ -1373,10 +1373,10 @@ export namespace SessionPrompt {
let template = command.template.replace("$ARGUMENTS", input.arguments)
const bash = Array.from(template.matchAll(bashRegex))
if (bash.length > 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))

View File

@@ -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<Tool.Info[]> {
const custom = await state().then((x) => x.custom)
return [...BUILTIN, ...custom]
return [...builtin(), ...custom]
}
export async function ids() {

View File

@@ -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)
},
})
})

View File

@@ -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)
})

View File

@@ -2,12 +2,12 @@ import { $ } from "bun"
import os from "os"
import path from "path"
type TmpDirOptions<Init extends Record<string, any>> = {
type TmpDirOptions<T> = {
git?: boolean
init?: (dir: string) => Promise<Init>
dispose?: (dir: string) => Promise<void>
init?: (dir: string) => Promise<T>
dispose?: (dir: string) => Promise<T>
}
export async function tmpdir<Init extends Record<string, any>>(options?: TmpDirOptions<Init>) {
export async function tmpdir<T>(options?: TmpDirOptions<T>) {
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<Init extends Record<string, any>>(options?: TmpDirO
await $`rm -rf ${dirpath}`.quiet()
},
path: dirpath,
extra: extra as Init,
extra: extra as T,
}
return result
}

View File

@@ -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)
})
})

View File

@@ -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()
})
})