Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 26 additions & 27 deletions opencode-plugin/copilot-tools-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ function mapToolName(tool: string): string {
return tool
}

function normalizeToolArgs(toolName: string, args: Record<string, unknown>): Record<string, unknown> {
const normalized = { ...args }
if (toolName === "skill" && typeof normalized.name === "string" && typeof normalized.skill !== "string") {
normalized.skill = normalized.name
}
if (toolName === "edit") {
if (typeof normalized.oldString === "string" && typeof normalized.old_str !== "string") normalized.old_str = normalized.oldString
if (typeof normalized.newString === "string" && typeof normalized.new_str !== "string") normalized.new_str = normalized.newString
}
if (toolName === "create") {
if (typeof normalized.content === "string" && typeof normalized.file_text !== "string") normalized.file_text = normalized.content
}
if (typeof normalized.path === "string" && typeof normalized.filePath !== "string") {
normalized.filePath = normalized.path
}
return normalized
}

type HookState = {
sessionStartFired: boolean
sessionId: string
Expand Down Expand Up @@ -105,11 +123,12 @@ export const CopilotToolsBridge: Plugin = async ({ project, client, $, directory

"tool.execute.before": async (input, output) => {
const toolName = mapToolName(input.tool)
const toolArgs = normalizeToolArgs(toolName, output.args ?? {})

const results = await callHookRunner($, client, "preToolUse", {
toolName,
toolArgs: output.args,
toolInput: output.args,
toolArgs,
toolInput: toolArgs,
sessionId: input.sessionID,
callId: input.callID,
cwd: process.cwd(),
Expand All @@ -126,20 +145,21 @@ export const CopilotToolsBridge: Plugin = async ({ project, client, $, directory

"tool.execute.after": async (input, output) => {
const toolName = mapToolName(input.tool)
const toolArgs = normalizeToolArgs(toolName, (input.args ?? {}) as Record<string, unknown>)

const toolResult: Record<string, unknown> = {
title: output.title,
output: output.output,
resultType: output.isError ? "error" : "success",
}
if (typeof input.args === "object" && input.args && (input.args as any).filePath) {
toolResult.filePath = (input.args as any).filePath
if (typeof toolArgs.filePath === "string") {
toolResult.filePath = toolArgs.filePath
}

const results = await callHookRunner($, client, "postToolUse", {
toolName,
toolArgs: input.args,
toolInput: input.args,
toolArgs,
toolInput: toolArgs,
toolResult,
sessionId: input.sessionID,
cwd: process.cwd(),
Expand All @@ -155,27 +175,6 @@ export const CopilotToolsBridge: Plugin = async ({ project, client, $, directory
}
},

"tool.use": async (input, output) => {
const toolName = mapToolName(input.tool)

const results = await callHookRunner($, client, "preToolUse", {
toolName,
toolArgs: output.args,
toolInput: output.args,
sessionId: input.sessionID,
callId: input.callID,
cwd: process.cwd(),
})

if (!results) return

for (const parsed of results) {
if (parsed.permissionDecision === "deny") {
throw new Error(parsed.permissionDecisionReason || `Blocked by ${toolName} rule`)
}
}
},

"chat.message": async (input, output) => {
if (!state.sessionStartFired) {
await fireSessionStart(input.sessionID)
Expand Down
83 changes: 82 additions & 1 deletion tests/test_opencode_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def test_plugin_has_required_exports(self):
text = PLUGIN_SRC.read_text(encoding="utf-8")
self.assertIn("tool.execute.before", text)
self.assertIn("tool.execute.after", text)
self.assertIn("tool.use", text)
self.assertNotIn("tool.use", text) # removed in P2 (undocumented, dead code)
self.assertIn("task", text)
self.assertIn("chat.message", text)
self.assertIn("CopilotToolsBridge", text)
Expand All @@ -100,6 +100,15 @@ def test_plugin_maps_tool_names(self):
self.assertIn('return "view"', text)
self.assertIn('return "edit"', text)

def test_plugin_normalizes_tool_args(self):
text = PLUGIN_SRC.read_text(encoding="utf-8")
self.assertIn("normalizeToolArgs", text)
self.assertIn("normalized.skill = normalized.name", text)
self.assertIn("normalized.old_str = normalized.oldString", text)
self.assertIn("normalized.new_str = normalized.newString", text)
self.assertIn("normalized.file_text = normalized.content", text)
self.assertIn("normalized.filePath = normalized.path", text)


class TestInstallSandboxed(unittest.TestCase):
"""Install tests run against a temp XDG_CONFIG_HOME sandbox with isolated HOME."""
Expand Down Expand Up @@ -332,6 +341,78 @@ def test_user_prompt_submitted(self):
)
self.assertEqual(proc.returncode, 0, f"userPromptSubmitted failed:\n{proc.stderr}")

def test_skill_normalized_args(self):
proc = _run_hook(
"postToolUse",
{
"toolName": "skill",
"toolArgs": {"name": "frontend-dev", "skill": "frontend-dev"},
"toolInput": {"name": "frontend-dev", "skill": "frontend-dev"},
"sessionId": "bridge-test-skill-001",
},
)
self.assertEqual(proc.returncode, 0, f"skill postToolUse failed:\n{proc.stderr}")

def test_skill_without_normalized_skill_field(self):
proc = _run_hook(
"postToolUse",
{
"toolName": "skill",
"toolArgs": {"name": "test-skill"},
"toolInput": {"name": "test-skill"},
"sessionId": "bridge-test-skill-002",
},
)
self.assertEqual(proc.returncode, 0, f"skill postToolUse without 'skill' field:\n{proc.stderr}")

def test_edit_normalized_args(self):
proc = _run_hook(
"preToolUse",
{
"toolName": "edit",
"toolArgs": {
"path": os.path.join(tempfile.gettempdir(), "bridge-test-edit.py"),
"filePath": os.path.join(tempfile.gettempdir(), "bridge-test-edit.py"),
"oldString": "foo",
"newString": "bar",
"old_str": "foo",
"new_str": "bar",
},
"toolInput": {
"path": os.path.join(tempfile.gettempdir(), "bridge-test-edit.py"),
"filePath": os.path.join(tempfile.gettempdir(), "bridge-test-edit.py"),
"oldString": "foo",
"newString": "bar",
"old_str": "foo",
"new_str": "bar",
},
"sessionId": "bridge-test-edit-001",
},
)
self.assertEqual(proc.returncode, 0, f"edit preToolUse failed:\n{proc.stderr}")

def test_write_normalized_args(self):
proc = _run_hook(
"preToolUse",
{
"toolName": "create",
"toolArgs": {
"path": os.path.join(tempfile.gettempdir(), "bridge-test-write.py"),
"filePath": os.path.join(tempfile.gettempdir(), "bridge-test-write.py"),
"content": "print('hello')",
"file_text": "print('hello')",
},
"toolInput": {
"path": os.path.join(tempfile.gettempdir(), "bridge-test-write.py"),
"filePath": os.path.join(tempfile.gettempdir(), "bridge-test-write.py"),
"content": "print('hello')",
"file_text": "print('hello')",
},
"sessionId": "bridge-test-write-001",
},
)
self.assertEqual(proc.returncode, 0, f"create preToolUse failed:\n{proc.stderr}")


class TestMCPConfig(unittest.TestCase):
"""Verify MCP server file exists in repo."""
Expand Down
Loading