Skip to content

Commit 244de65

Browse files
fix: emit OpenCode shell deletion side effects
Co-authored-by: Shukan Shah <shukanshah14@gmail.com>
1 parent 5d9bb9a commit 244de65

5 files changed

Lines changed: 91 additions & 16 deletions

File tree

cli/beacon-hooks/cmd/opencode_event.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ func opencodeEndpointEvents(input map[string]interface{}, sessionID string) []op
8989
action, category = "tool.completed", "tool"
9090
}
9191
}
92-
return one(action, category, "info", opencodeToolMessage(action), fields)
92+
events := one(action, category, "info", opencodeToolMessage(action), fields)
93+
return append(events, opencodeFileMutationEvents(input, fields)...)
9394
case "message.updated":
9495
info := opencodeMap(input, "message_info", "info")
9596
if len(info) == 0 {
@@ -622,6 +623,41 @@ func opencodeDiffEvents(input map[string]interface{}, base map[string]interface{
622623
return events
623624
}
624625

626+
func opencodeFileMutationEvents(input map[string]interface{}, base map[string]interface{}) []opencodeNormalizedEvent {
627+
command, _ := base["command"].(map[string]interface{})
628+
if exitCode, ok := opencodeInt(command["exit_code"]); ok && exitCode != 0 {
629+
return nil
630+
}
631+
items, _ := input["file_mutations"].([]interface{})
632+
if len(items) == 0 {
633+
return nil
634+
}
635+
var events []opencodeNormalizedEvent
636+
for _, item := range items {
637+
mutation, ok := item.(map[string]interface{})
638+
if !ok {
639+
continue
640+
}
641+
path := getFirstStr(mutation, "path", "file", "filePath", "file_path")
642+
operation := firstNonEmpty(getFirstStr(mutation, "operation"), "modify")
643+
if path == "" {
644+
continue
645+
}
646+
fields := cloneOpenCodeFields(base)
647+
delete(fields, "command")
648+
fields["file"] = map[string]interface{}{
649+
"path": path,
650+
"operation": operation,
651+
"language": strings.TrimPrefix(filepath.Ext(path), "."),
652+
}
653+
events = append(events, opencodeNormalizedEvent{
654+
action: "file.modified", category: "file", severity: "info",
655+
message: "opencode file " + operation + " observed", fields: fields,
656+
})
657+
}
658+
return events
659+
}
660+
625661
func opencodeApprovalAction(decision string) string {
626662
switch strings.ToLower(strings.TrimSpace(decision)) {
627663
case "once", "always", "accepted", "accept", "allow", "allowed", "approve", "approved":

cli/beacon-hooks/cmd/opencode_event_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,43 @@ func TestOpenCodeWatcherUnlinkNormalizesFileDeletion(t *testing.T) {
380380
}
381381
}
382382

383+
func TestOpenCodeSuccessfulRMEmitsCorrelatedFileDeletion(t *testing.T) {
384+
input := map[string]interface{}{
385+
"type": "tool.execute.after",
386+
"session_id": "ses_test",
387+
"tool_name": "bash",
388+
"call_id": "call_rm",
389+
"tool_input": map[string]interface{}{"command": `rm "/repo/.tmp/e2e.txt"`},
390+
"tool_response": map[string]interface{}{
391+
"output": "",
392+
"metadata": map[string]interface{}{"exit": 0},
393+
},
394+
"file_mutations": []interface{}{
395+
map[string]interface{}{"path": "/repo/.tmp/e2e.txt", "operation": "delete"},
396+
},
397+
}
398+
events := opencodeEndpointEvents(input, "ses_test")
399+
if len(events) != 2 || events[0].action != "command.executed" || events[1].action != "file.modified" {
400+
t.Fatalf("events = %#v", events)
401+
}
402+
file := events[1].fields["file"].(map[string]interface{})
403+
if file["operation"] != "delete" {
404+
t.Fatalf("file = %#v", file)
405+
}
406+
call := events[1].fields["gen_ai"].(map[string]interface{})["tool"].(map[string]interface{})["call"].(map[string]interface{})
407+
if call["id"] != "call_rm" {
408+
t.Fatalf("call = %#v", call)
409+
}
410+
411+
input["tool_response"] = map[string]interface{}{
412+
"output": "failed",
413+
"metadata": map[string]interface{}{"exit": 1},
414+
}
415+
if failed := opencodeEndpointEvents(input, "ses_test"); len(failed) != 1 {
416+
t.Fatalf("failed rm emitted %d events, want command only", len(failed))
417+
}
418+
}
419+
383420
func TestOpenCodePermissionRepliesMapAllowAndDeny(t *testing.T) {
384421
for _, tt := range []struct {
385422
reply string

cli/beacon/internal/endpoint/hooks/assets/opencode/beacon.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
260260
duration_ms: active?.started_at ? Date.now() - active.started_at : undefined,
261261
tool_input: args,
262262
tool_response: output,
263+
file_mutations: shellMutationPaths(input?.tool, args).map((path) => ({ path, operation: "delete" })),
263264
}),
264265
)
265266
activeCalls.delete(input?.callID)
@@ -329,12 +330,9 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
329330
properties.diff = filtered
330331
}
331332
if (type === "file.edited" || type === "file.watcher.updated") {
332-
if (!sid) {
333-
const seen = recentFilePaths.get(filePath(properties))
334-
if (!seen || Date.now() - seen.time >= 5000) return
335-
properties.sessionID = seen.sessionID
336-
properties.callID = seen.callID
337-
}
333+
const seen = recentFilePaths.get(filePath(properties))
334+
if (seen && Date.now() - seen.time < 5000) return
335+
if (!sid) return
338336
}
339337
void enqueue(
340338
payload(type, {

plugins/opencode-beacon/src/beacon.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ describe("BeaconEndpointPlugin", () => {
192192
expect(payloads.some((item) => item.type === "message.part.updated")).toBe(false)
193193
})
194194

195-
test("correlates file deletion watcher events with an active bash call", async () => {
195+
test("records shell deletion side effects and suppresses watcher duplicates", async () => {
196196
const hooks = await plugin()
197197
const path = "/repo/.tmp/beacon-opencode-e2e.txt"
198198
await hooks["tool.execute.before"]!(
@@ -202,12 +202,18 @@ describe("BeaconEndpointPlugin", () => {
202202
await hooks.event!({
203203
event: { type: "file.watcher.updated", properties: { file: path, event: "unlink" } },
204204
} as any)
205+
await hooks["tool.execute.after"]!(
206+
{ tool: "bash", sessionID: "ses_1", callID: "call_rm", args: { command: `rm "${path}"` } },
207+
{ title: "rm", output: "", metadata: { exit: 0 } },
208+
)
205209
await Bun.sleep(20)
206210

211+
expect(payloads.map((item) => item.type)).toEqual(["tool.execute.before", "tool.execute.after"])
207212
expect(payloads[1]).toMatchObject({
208-
type: "file.watcher.updated",
213+
type: "tool.execute.after",
209214
session_id: "ses_1",
210-
properties: { sessionID: "ses_1", callID: "call_rm", file: path, event: "unlink" },
215+
call_id: "call_rm",
216+
file_mutations: [{ path, operation: "delete" }],
211217
})
212218
})
213219
})

plugins/opencode-beacon/src/beacon.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
260260
duration_ms: active?.started_at ? Date.now() - active.started_at : undefined,
261261
tool_input: args,
262262
tool_response: output,
263+
file_mutations: shellMutationPaths(input?.tool, args).map((path) => ({ path, operation: "delete" })),
263264
}),
264265
)
265266
activeCalls.delete(input?.callID)
@@ -329,12 +330,9 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
329330
properties.diff = filtered
330331
}
331332
if (type === "file.edited" || type === "file.watcher.updated") {
332-
if (!sid) {
333-
const seen = recentFilePaths.get(filePath(properties))
334-
if (!seen || Date.now() - seen.time >= 5000) return
335-
properties.sessionID = seen.sessionID
336-
properties.callID = seen.callID
337-
}
333+
const seen = recentFilePaths.get(filePath(properties))
334+
if (seen && Date.now() - seen.time < 5000) return
335+
if (!sid) return
338336
}
339337
void enqueue(
340338
payload(type, {

0 commit comments

Comments
 (0)