Merge pull request #2768 from code-yeongyu/fix/issue-2117
fix: emit formatter events from hashline-edit tool (fixes #2117)
This commit is contained in:
@@ -7,6 +7,7 @@ import { applyMcpConfig } from "./mcp-config-handler";
|
||||
import { applyProviderConfig } from "./provider-config-handler";
|
||||
import { loadPluginComponents } from "./plugin-components-loader";
|
||||
import { applyToolConfig } from "./tool-config-handler";
|
||||
import { clearFormatterCache } from "../tools/hashline-edit/formatter-trigger"
|
||||
|
||||
export { resolveCategoryConfig } from "./category-config-resolver";
|
||||
|
||||
@@ -23,6 +24,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
const formatterConfig = config.formatter;
|
||||
|
||||
applyProviderConfig({ config, modelCacheState });
|
||||
clearFormatterCache()
|
||||
|
||||
const pluginComponents = await loadPluginComponents({ pluginConfig });
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ export function createToolRegistry(args: {
|
||||
|
||||
const hashlineEnabled = pluginConfig.hashline_edit ?? false
|
||||
const hashlineToolsRecord: Record<string, ToolDefinition> = hashlineEnabled
|
||||
? { edit: createHashlineEditTool() }
|
||||
? { edit: createHashlineEditTool(ctx) }
|
||||
: {}
|
||||
|
||||
const allTools: Record<string, ToolDefinition> = {
|
||||
|
||||
376
src/tools/hashline-edit/formatter-trigger.test.ts
Normal file
376
src/tools/hashline-edit/formatter-trigger.test.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { describe, it, expect, beforeEach, mock } from "bun:test"
|
||||
import {
|
||||
runFormattersForFile,
|
||||
clearFormatterCache,
|
||||
resolveFormatters,
|
||||
buildFormatterCommand,
|
||||
type FormatterClient,
|
||||
} from "./formatter-trigger"
|
||||
|
||||
function createMockClient(config: Record<string, unknown> = {}): FormatterClient {
|
||||
return {
|
||||
config: {
|
||||
get: mock(() => Promise.resolve({ data: config })),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("buildFormatterCommand", () => {
|
||||
it("substitutes $FILE with the actual file path", () => {
|
||||
//#given
|
||||
const command = ["prettier", "--write", "$FILE"]
|
||||
const filePath = "/src/index.ts"
|
||||
|
||||
//#when
|
||||
const result = buildFormatterCommand(command, filePath)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["prettier", "--write", "/src/index.ts"])
|
||||
})
|
||||
|
||||
it("substitutes multiple $FILE occurrences in the same arg", () => {
|
||||
//#given
|
||||
const command = ["echo", "$FILE:$FILE"]
|
||||
const filePath = "test.ts"
|
||||
|
||||
//#when
|
||||
const result = buildFormatterCommand(command, filePath)
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["echo", "test.ts:test.ts"])
|
||||
})
|
||||
|
||||
it("returns command unchanged when no $FILE present", () => {
|
||||
//#given
|
||||
const command = ["prettier", "--check", "."]
|
||||
|
||||
//#when
|
||||
const result = buildFormatterCommand(command, "/some/file.ts")
|
||||
|
||||
//#then
|
||||
expect(result).toEqual(["prettier", "--check", "."])
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveFormatters", () => {
|
||||
beforeEach(() => {
|
||||
clearFormatterCache()
|
||||
})
|
||||
|
||||
it("resolves formatters from config.formatter section", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
prettier: {
|
||||
command: ["prettier", "--write", "$FILE"],
|
||||
extensions: [".ts", ".tsx"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.get(".ts")).toEqual([{ command: ["prettier", "--write", "$FILE"], environment: {} }])
|
||||
expect(result.get(".tsx")).toEqual([{ command: ["prettier", "--write", "$FILE"], environment: {} }])
|
||||
})
|
||||
|
||||
it("resolves formatters from experimental.hook.file_edited section", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
experimental: {
|
||||
hook: {
|
||||
file_edited: {
|
||||
".go": [{ command: ["gofmt", "-w", "$FILE"], environment: { GOPATH: "/go" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.get(".go")).toEqual([{ command: ["gofmt", "-w", "$FILE"], environment: { GOPATH: "/go" } }])
|
||||
})
|
||||
|
||||
it("normalizes extensions without leading dot", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
biome: {
|
||||
command: ["biome", "format", "$FILE"],
|
||||
extensions: ["ts", "js"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.has(".ts")).toBe(true)
|
||||
expect(result.has(".js")).toBe(true)
|
||||
})
|
||||
|
||||
it("skips disabled formatters", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
prettier: {
|
||||
disabled: true,
|
||||
command: ["prettier", "--write", "$FILE"],
|
||||
extensions: [".ts"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("skips formatters without command", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
prettier: {
|
||||
extensions: [".ts"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("skips formatters without extensions", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
prettier: {
|
||||
command: ["prettier", "--write", "$FILE"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("returns cached result on subsequent calls", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
prettier: {
|
||||
command: ["prettier", "--write", "$FILE"],
|
||||
extensions: [".ts"],
|
||||
},
|
||||
},
|
||||
})
|
||||
await resolveFormatters(client, "/project")
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(client.config.get).toHaveBeenCalledTimes(1)
|
||||
expect(result.get(".ts")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("returns fresh result after clearFormatterCache", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
prettier: {
|
||||
command: ["prettier", "--write", "$FILE"],
|
||||
extensions: [".ts"],
|
||||
},
|
||||
},
|
||||
})
|
||||
await resolveFormatters(client, "/project")
|
||||
clearFormatterCache()
|
||||
|
||||
//#when
|
||||
await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(client.config.get).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("handles config.get failure gracefully", async () => {
|
||||
//#given
|
||||
const client: FormatterClient = {
|
||||
config: {
|
||||
get: mock(() => Promise.reject(new Error("network error"))),
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("handles missing config data", async () => {
|
||||
//#given
|
||||
const client: FormatterClient = {
|
||||
config: {
|
||||
get: mock(() => Promise.resolve({ data: undefined })),
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("merges formatter and experimental.hook.file_edited for same extension", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
prettier: {
|
||||
command: ["prettier", "--write", "$FILE"],
|
||||
extensions: [".ts"],
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
hook: {
|
||||
file_edited: {
|
||||
".ts": [{ command: ["eslint", "--fix", "$FILE"] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.get(".ts")).toHaveLength(2)
|
||||
expect(result.get(".ts")![0].command).toEqual(["prettier", "--write", "$FILE"])
|
||||
expect(result.get(".ts")![1].command).toEqual(["eslint", "--fix", "$FILE"])
|
||||
})
|
||||
|
||||
it("defaults environment to empty object when not specified", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
experimental: {
|
||||
hook: {
|
||||
file_edited: {
|
||||
".py": [{ command: ["black", "$FILE"] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.get(".py")![0].environment).toEqual({})
|
||||
})
|
||||
|
||||
it("preserves environment from formatter config", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
biome: {
|
||||
command: ["biome", "format", "$FILE"],
|
||||
extensions: [".ts"],
|
||||
environment: { BIOME_LOG: "debug" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.get(".ts")![0].environment).toEqual({ BIOME_LOG: "debug" })
|
||||
})
|
||||
|
||||
it("skips formatter=false config", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: false,
|
||||
})
|
||||
|
||||
//#when
|
||||
const result = await resolveFormatters(client, "/project")
|
||||
|
||||
//#then
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("runFormattersForFile", () => {
|
||||
beforeEach(() => {
|
||||
clearFormatterCache()
|
||||
})
|
||||
|
||||
it("skips files without extensions", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
prettier: {
|
||||
command: ["prettier", "--write", "$FILE"],
|
||||
extensions: [".ts"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when
|
||||
await runFormattersForFile(client, "/project", "Makefile")
|
||||
|
||||
//#then
|
||||
expect(client.config.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("skips when no matching formatters for extension", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
prettier: {
|
||||
command: ["prettier", "--write", "$FILE"],
|
||||
extensions: [".ts"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when — run for a .go file, but only .ts formatters registered
|
||||
await runFormattersForFile(client, "/project", "/src/main.go")
|
||||
|
||||
//#then — no error thrown
|
||||
})
|
||||
|
||||
it("runs formatter for matching extension", async () => {
|
||||
//#given
|
||||
const client = createMockClient({
|
||||
formatter: {
|
||||
echo: {
|
||||
command: ["echo", "$FILE"],
|
||||
extensions: [".ts"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
//#when — echo is a safe no-op command
|
||||
await runFormattersForFile(client, "/tmp", "/tmp/test.ts")
|
||||
|
||||
//#then — should complete without error
|
||||
expect(client.config.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
122
src/tools/hashline-edit/formatter-trigger.ts
Normal file
122
src/tools/hashline-edit/formatter-trigger.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import path from "path"
|
||||
import { log } from "../../shared"
|
||||
|
||||
interface FormatterConfig {
|
||||
disabled?: boolean
|
||||
command?: string[]
|
||||
environment?: Record<string, string>
|
||||
extensions?: string[]
|
||||
}
|
||||
|
||||
interface OpencodeConfig {
|
||||
formatter?:
|
||||
| false
|
||||
| Record<string, FormatterConfig>
|
||||
experimental?: {
|
||||
hook?: {
|
||||
file_edited?: Record<string, Array<{ command: string[]; environment?: Record<string, string> }>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface FormatterClient {
|
||||
config: {
|
||||
get: (options?: { query?: { directory?: string } }) => Promise<{ data?: OpencodeConfig }>
|
||||
}
|
||||
}
|
||||
|
||||
let cachedFormatters: Map<string, Array<{ command: string[]; environment: Record<string, string> }>> | null = null
|
||||
|
||||
export async function resolveFormatters(
|
||||
client: FormatterClient,
|
||||
directory: string,
|
||||
): Promise<Map<string, Array<{ command: string[]; environment: Record<string, string> }>>> {
|
||||
if (cachedFormatters) return cachedFormatters
|
||||
|
||||
const result = new Map<string, Array<{ command: string[]; environment: Record<string, string> }>>()
|
||||
|
||||
try {
|
||||
const response = await client.config.get({ query: { directory } })
|
||||
const config = response.data
|
||||
if (!config) return result
|
||||
|
||||
if (config.formatter && typeof config.formatter === "object") {
|
||||
for (const [, formatter] of Object.entries(config.formatter)) {
|
||||
if (formatter.disabled || !formatter.command?.length || !formatter.extensions?.length) continue
|
||||
for (const ext of formatter.extensions) {
|
||||
const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`
|
||||
const existing = result.get(normalizedExt) ?? []
|
||||
existing.push({
|
||||
command: formatter.command,
|
||||
environment: formatter.environment ?? {},
|
||||
})
|
||||
result.set(normalizedExt, existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.experimental?.hook?.file_edited) {
|
||||
for (const [ext, commands] of Object.entries(config.experimental.hook.file_edited)) {
|
||||
const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`
|
||||
const existing = result.get(normalizedExt) ?? []
|
||||
for (const cmd of commands) {
|
||||
existing.push({
|
||||
command: cmd.command,
|
||||
environment: cmd.environment ?? {},
|
||||
})
|
||||
}
|
||||
result.set(normalizedExt, existing)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log("[formatter-trigger] Failed to fetch formatter config", { error })
|
||||
}
|
||||
|
||||
cachedFormatters = result
|
||||
return result
|
||||
}
|
||||
|
||||
export function buildFormatterCommand(command: string[], filePath: string): string[] {
|
||||
return command.map((arg) => arg.replace(/\$FILE/g, filePath))
|
||||
}
|
||||
|
||||
export async function runFormattersForFile(
|
||||
client: FormatterClient,
|
||||
directory: string,
|
||||
filePath: string,
|
||||
): Promise<void> {
|
||||
const ext = path.extname(filePath)
|
||||
if (!ext) return
|
||||
|
||||
const formatters = await resolveFormatters(client, directory)
|
||||
const matching = formatters.get(ext)
|
||||
if (!matching?.length) return
|
||||
|
||||
for (const formatter of matching) {
|
||||
const cmd = buildFormatterCommand(formatter.command, filePath)
|
||||
try {
|
||||
log("[formatter-trigger] Running formatter", { command: cmd, file: filePath })
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: directory,
|
||||
env: { ...process.env, ...formatter.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
if (proc.exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
log("[formatter-trigger] Formatter failed", {
|
||||
command: cmd,
|
||||
exitCode: proc.exitCode,
|
||||
stderr: stderr.slice(0, 500),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log("[formatter-trigger] Formatter execution error", { command: cmd, error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearFormatterCache(): void {
|
||||
cachedFormatters = null
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalizat
|
||||
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
|
||||
import type { HashlineEdit } from "./types"
|
||||
import { HashlineMismatchError } from "./validation"
|
||||
import { runFormattersForFile, type FormatterClient } from "./formatter-trigger"
|
||||
import type { PluginContext } from "../../plugin/types"
|
||||
|
||||
interface HashlineEditArgs {
|
||||
filePath: string
|
||||
@@ -80,7 +82,7 @@ function buildSuccessMeta(
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext): Promise<string> {
|
||||
export async function executeHashlineEditTool(args: HashlineEditArgs, context: ToolContext, pluginCtx?: PluginContext): Promise<string> {
|
||||
try {
|
||||
const metadataContext = context as ToolContextWithMetadata
|
||||
const filePath = args.filePath
|
||||
@@ -129,6 +131,34 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
|
||||
|
||||
await Bun.write(filePath, writeContent)
|
||||
|
||||
if (pluginCtx?.client) {
|
||||
await runFormattersForFile(pluginCtx.client as FormatterClient, context.directory, filePath)
|
||||
const formattedContent = Buffer.from(await Bun.file(filePath).arrayBuffer()).toString("utf8")
|
||||
if (formattedContent !== writeContent) {
|
||||
const formattedEnvelope = canonicalizeFileText(formattedContent)
|
||||
const formattedMeta = buildSuccessMeta(
|
||||
filePath,
|
||||
oldEnvelope.content,
|
||||
formattedEnvelope.content,
|
||||
applyResult.noopEdits,
|
||||
applyResult.deduplicatedEdits
|
||||
)
|
||||
if (typeof metadataContext.metadata === "function") {
|
||||
metadataContext.metadata(formattedMeta)
|
||||
}
|
||||
const callID = resolveToolCallID(metadataContext)
|
||||
if (callID) {
|
||||
storeToolMetadata(context.sessionID, callID, formattedMeta)
|
||||
}
|
||||
if (rename && rename !== filePath) {
|
||||
await Bun.write(rename, formattedContent)
|
||||
await Bun.file(filePath).delete()
|
||||
return `Moved ${filePath} to ${rename}`
|
||||
}
|
||||
return `Updated ${filePath}`
|
||||
}
|
||||
}
|
||||
|
||||
if (rename && rename !== filePath) {
|
||||
await Bun.write(rename, writeContent)
|
||||
await Bun.file(filePath).delete()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin
|
||||
import { executeHashlineEditTool } from "./hashline-edit-executor"
|
||||
import { HASHLINE_EDIT_DESCRIPTION } from "./tool-description"
|
||||
import type { RawHashlineEdit } from "./normalize-edits"
|
||||
import type { PluginContext } from "../../plugin/types"
|
||||
|
||||
interface HashlineEditArgs {
|
||||
filePath: string
|
||||
@@ -10,7 +11,7 @@ interface HashlineEditArgs {
|
||||
rename?: string
|
||||
}
|
||||
|
||||
export function createHashlineEditTool(): ToolDefinition {
|
||||
export function createHashlineEditTool(ctx?: PluginContext): ToolDefinition {
|
||||
return tool({
|
||||
description: HASHLINE_EDIT_DESCRIPTION,
|
||||
args: {
|
||||
@@ -36,6 +37,6 @@ export function createHashlineEditTool(): ToolDefinition {
|
||||
)
|
||||
.describe("Array of edit operations to apply (empty when delete=true)"),
|
||||
},
|
||||
execute: async (args: HashlineEditArgs, context: ToolContext) => executeHashlineEditTool(args, context),
|
||||
execute: async (args: HashlineEditArgs, context: ToolContext) => executeHashlineEditTool(args, context, ctx),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user