Skip to content

Commit 5d9bb9a

Browse files
fix: address OpenCode live trace regressions
Co-authored-by: Shukan Shah <shukanshah14@gmail.com>
1 parent 08f109e commit 5d9bb9a

7 files changed

Lines changed: 242 additions & 28 deletions

File tree

cli/beacon-hooks/cmd/opencode_event.go

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,22 @@ func opencodeEndpointEvents(input map[string]interface{}, sessionID string) []op
113113
return opencodePartEvents(fields, part)
114114
case "session.created":
115115
return one("session.started", "session", "info", "opencode session started", fields)
116+
case "session.deleted":
117+
return one("session.ended", "session", "info", "opencode session deleted", fields)
116118
case "session.status":
117119
status := opencodeMap(opencodeProperties(input), "status")
118120
statusType := getFirstStr(status, "type")
119121
switch statusType {
120122
case "idle":
121-
return one("session.ended", "session", "info", "opencode session idle", fields)
123+
return one("session.status", "session", "info", "opencode session idle", fields)
122124
case "retry":
123125
fields["error"] = map[string]interface{}{"type": "retry"}
124126
return one("model.retry", "session", "medium", "opencode model retry", fields)
125127
default:
126128
return one("session.status", "session", "info", "opencode session "+firstNonEmpty(statusType, "status updated"), fields)
127129
}
128130
case "session.idle":
129-
return one("session.ended", "session", "info", "opencode session ended", fields)
131+
return one("session.status", "session", "info", "opencode session idle", fields)
130132
case "session.error":
131133
errorInfo := opencodeMap(opencodeProperties(input), "error")
132134
if len(errorInfo) > 0 {
@@ -150,6 +152,15 @@ func opencodeEndpointEvents(input map[string]interface{}, sessionID string) []op
150152
"operation": operation,
151153
"language": strings.TrimPrefix(filepath.Ext(path), "."),
152154
}
155+
if callID := getFirstStr(properties, "callID", "call_id"); callID != "" {
156+
fields["gen_ai"] = map[string]interface{}{
157+
"operation": map[string]interface{}{"name": "execute_tool"},
158+
"tool": map[string]interface{}{
159+
"name": "file_watcher",
160+
"call": map[string]interface{}{"id": callID},
161+
},
162+
}
163+
}
153164
return one("file.modified", "file", "info", "opencode file change observed", fields)
154165
case "command.execute.before":
155166
if command := opencodeCommand(input); command != "" {
@@ -208,6 +219,7 @@ func supportedOpenCodeEventTypes() []string {
208219
"permission.v2.asked",
209220
"permission.v2.replied",
210221
"session.created",
222+
"session.deleted",
211223
"session.diff",
212224
"session.error",
213225
"session.idle",
@@ -319,17 +331,18 @@ func opencodeToolFields(input map[string]interface{}, completed bool) map[string
319331
if path := opencodeToolPath(toolInput); path != "" {
320332
operation := fileOperation(toolName)
321333
if operation == "" {
322-
lower := strings.ToLower(toolName)
323-
if strings.Contains(lower, "glob") || strings.Contains(lower, "grep") || strings.Contains(lower, "search") {
324-
operation = "read"
334+
delete(fields, "file")
335+
if tool, ok := fields["tool"].(map[string]interface{}); ok {
336+
delete(tool, "path")
325337
}
338+
} else {
339+
fields["file"] = map[string]interface{}{
340+
"path": path,
341+
"operation": operation,
342+
"language": strings.TrimPrefix(filepath.Ext(path), "."),
343+
}
344+
fields["tool"] = mergeNested(fields["tool"], map[string]interface{}{"name": toolName, "path": path})
326345
}
327-
fields["file"] = map[string]interface{}{
328-
"path": path,
329-
"operation": operation,
330-
"language": strings.TrimPrefix(filepath.Ext(path), "."),
331-
}
332-
fields["tool"] = mergeNested(fields["tool"], map[string]interface{}{"name": toolName, "path": path})
333346
}
334347

335348
if completed {
@@ -355,7 +368,7 @@ func opencodeToolFields(input map[string]interface{}, completed bool) map[string
355368
commandFields["duration_ms"] = duration
356369
}
357370
if metadata := opencodeMap(toolResponse, "metadata"); len(metadata) > 0 {
358-
if exitCode, ok := firstToolIntAcross([]map[string]interface{}{metadata}, "exit_code", "exitCode", "status"); ok {
371+
if exitCode, ok := firstToolIntAcross([]map[string]interface{}{metadata}, "exit", "exit_code", "exitCode", "status"); ok {
359372
commandFields["exit_code"] = exitCode
360373
}
361374
}
@@ -405,14 +418,12 @@ func opencodeDecision(input map[string]interface{}) string {
405418

406419
func opencodeToolAction(input map[string]interface{}) (string, string) {
407420
name := strings.ToLower(opencodeToolName(input))
408-
toolInput := opencodeMap(input, "tool_input", "toolInput")
409421
switch {
410422
case strings.Contains(name, "mcp") || strings.HasPrefix(name, "list_mcp_") || strings.HasPrefix(name, "read_mcp_"):
411423
return "mcp.tool_invoked", "mcp"
412424
case name == "bash" || strings.Contains(name, "shell") || strings.Contains(name, "terminal"):
413425
return "command.executed", "command"
414-
case strings.Contains(name, "read") ||
415-
((strings.Contains(name, "glob") || strings.Contains(name, "grep") || strings.Contains(name, "search")) && opencodeToolPath(toolInput) != ""):
426+
case strings.Contains(name, "read"):
416427
return "file.read", "file"
417428
case strings.Contains(name, "edit") || strings.Contains(name, "write") || strings.Contains(name, "patch"):
418429
return "file.modified", "file"
@@ -437,11 +448,7 @@ func opencodeToolMessage(action string) string {
437448
}
438449

439450
func opencodeToolPath(input map[string]interface{}) string {
440-
path := firstToolString(input, "file_path", "filePath", "path", "target", "destination")
441-
if path == "" {
442-
path = firstToolString(input, "pattern")
443-
}
444-
return hookdiff.NormalizePath(path)
451+
return hookdiff.NormalizePath(firstToolString(input, "file_path", "filePath", "path", "target", "destination"))
445452
}
446453

447454
func opencodeAssistantFields(info map[string]interface{}) map[string]interface{} {

cli/beacon-hooks/cmd/opencode_event_test.go

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ func TestOpenCodeEventIgnoresUnsupportedEvents(t *testing.T) {
164164
assertNoEndpointLog(t, logPath)
165165

166166
runHookWithInput(t, runOpenCodeEvent, map[string]interface{}{
167-
"type": "session.deleted",
167+
"type": "session.compacted",
168168
"session_id": "session-1",
169169
})
170170
assertNoEndpointLog(t, logPath)
@@ -318,6 +318,68 @@ func TestOpenCodeStructuredDiffSuppressesEmptyAndEmitsChangedFiles(t *testing.T)
318318
}
319319
}
320320

321+
func TestOpenCodeIdleIsStatusUntilSessionDeletion(t *testing.T) {
322+
action, _, _, _, _ := opencodeEndpointEvent(map[string]interface{}{
323+
"type": "session.status",
324+
"session_id": "ses_test",
325+
"properties": map[string]interface{}{"status": map[string]interface{}{"type": "idle"}},
326+
}, "ses_test")
327+
if action != "session.status" {
328+
t.Fatalf("idle action = %q, want session.status", action)
329+
}
330+
action, _, _, _, _ = opencodeEndpointEvent(map[string]interface{}{
331+
"type": "session.deleted",
332+
"session_id": "ses_test",
333+
}, "ses_test")
334+
if action != "session.ended" {
335+
t.Fatalf("deleted action = %q, want session.ended", action)
336+
}
337+
}
338+
339+
func TestOpenCodeBashCapturesExitMetadata(t *testing.T) {
340+
_, _, _, _, fields := opencodeEndpointEvent(map[string]interface{}{
341+
"type": "tool.execute.after",
342+
"session_id": "ses_test",
343+
"tool_name": "bash",
344+
"call_id": "bash_1",
345+
"tool_input": map[string]interface{}{"command": "test -f missing"},
346+
"tool_response": map[string]interface{}{
347+
"output": "missing",
348+
"metadata": map[string]interface{}{
349+
"exit": 1,
350+
},
351+
},
352+
}, "ses_test")
353+
command := fields["command"].(map[string]interface{})
354+
if command["exit_code"] != 1 {
355+
t.Fatalf("exit_code = %#v, want 1", command["exit_code"])
356+
}
357+
}
358+
359+
func TestOpenCodeWatcherUnlinkNormalizesFileDeletion(t *testing.T) {
360+
action, category, _, _, fields := opencodeEndpointEvent(map[string]interface{}{
361+
"type": "file.watcher.updated",
362+
"session_id": "ses_test",
363+
"properties": map[string]interface{}{
364+
"sessionID": "ses_test",
365+
"file": "/repo/.tmp/e2e.txt",
366+
"event": "unlink",
367+
"callID": "call_rm",
368+
},
369+
}, "ses_test")
370+
if action != "file.modified" || category != "file" {
371+
t.Fatalf("event = %s/%s", action, category)
372+
}
373+
file := fields["file"].(map[string]interface{})
374+
if file["operation"] != "delete" || file["path"] != "/repo/.tmp/e2e.txt" {
375+
t.Fatalf("file = %#v", file)
376+
}
377+
call := fields["gen_ai"].(map[string]interface{})["tool"].(map[string]interface{})["call"].(map[string]interface{})
378+
if call["id"] != "call_rm" {
379+
t.Fatalf("call = %#v", call)
380+
}
381+
}
382+
321383
func TestOpenCodePermissionRepliesMapAllowAndDeny(t *testing.T) {
322384
for _, tt := range []struct {
323385
reply string
@@ -377,7 +439,7 @@ func TestOpenCodeTidyPlanetTraceReplay(t *testing.T) {
377439
want := []string{
378440
"prompt.submitted", "prompt.submitted",
379441
"tool.invoked", "file.read",
380-
"tool.invoked", "file.read",
442+
"tool.invoked", "tool.completed",
381443
"prompt.submitted", "tool.invoked", "tool.completed",
382444
"prompt.submitted", "tool.invoked", "file.modified",
383445
"prompt.submitted", "tool.invoked", "command.executed",

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

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const forwardedEvents = new Set([
2020
"permission.v2.asked",
2121
"permission.v2.replied",
2222
"session.created",
23+
"session.deleted",
2324
"session.idle",
2425
"session.status",
2526
"session.error",
@@ -116,6 +117,19 @@ function isFileMutationTool(tool) {
116117
return ["edit", "write", "patch", "apply_patch", "create"].some((part) => name.includes(part))
117118
}
118119

120+
function shellMutationPaths(tool, args) {
121+
const name = String(tool || "").toLowerCase()
122+
if (name !== "bash" && !name.includes("shell")) return []
123+
const command = String(args?.command || args?.cmd || "")
124+
const paths = []
125+
const pattern = /\b(?:rm|unlink)\s+(?:-[^\s]+\s+)*(?:"([^"]+)"|'([^']+)'|(\S+))/g
126+
for (const match of command.matchAll(pattern)) {
127+
const path = match[1] || match[2] || match[3]
128+
if (path) paths.push(path)
129+
}
130+
return paths
131+
}
132+
119133
export const BeaconEndpointPlugin = async ({ project, directory, worktree, client }) => {
120134
const context = { project, directory, worktree }
121135
const activeCalls = new Map()
@@ -124,6 +138,7 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
124138
const pendingParts = new Map()
125139
const partDeltas = new Map()
126140
const messageModels = new Map()
141+
const messageRoles = new Map()
127142
const completedMessages = new Set()
128143
const permissionRequests = new Map()
129144
const sessionStates = new Map()
@@ -138,9 +153,10 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
138153
return eventQueue
139154
}
140155
const rememberFilePath = (tool, args, sid, callID) => {
141-
if (!isFileMutationTool(tool)) return
142-
const path = filePath(args)
143-
if (path) recentFilePaths.set(path, { time: Date.now(), sessionID: sid, callID })
156+
const paths = []
157+
if (isFileMutationTool(tool) && filePath(args)) paths.push(filePath(args))
158+
paths.push(...shellMutationPaths(tool, args))
159+
for (const path of paths) recentFilePaths.set(path, { time: Date.now(), sessionID: sid, callID })
144160
}
145161
const partKey = (part) => `${part?.sessionID || ""}:${part?.messageID || ""}:${part?.id || ""}`
146162
const emitPart = (part, sid, model) => {
@@ -155,6 +171,19 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
155171
if (status !== "completed" && status !== "error") return
156172
if (status === "completed" && completedCalls.has(next.callID)) return
157173
} else if (next.type === "text" || next.type === "reasoning") {
174+
if (next.type === "text") {
175+
const role = messageRoles.get(next.messageID)
176+
if (role === "user") {
177+
emittedParts.add(key)
178+
pendingParts.delete(key)
179+
partDeltas.delete(key)
180+
return
181+
}
182+
if (role !== "assistant") {
183+
pendingParts.set(key, { part: next, session_id: sid, model })
184+
return
185+
}
186+
}
158187
if (!next.time?.end) {
159188
pendingParts.set(key, { part: next, session_id: sid, model })
160189
return
@@ -178,6 +207,7 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
178207

179208
return {
180209
"chat.message": async (input, output) => {
210+
if (output?.message?.id) messageRoles.set(output.message.id, "user")
181211
await sendToBeacon(client, {
182212
type: "chat.message",
183213
session_id: sessionID(input),
@@ -199,11 +229,14 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
199229
)
200230
},
201231
"tool.execute.before": async (input, output) => {
232+
const sid = sessionID(input)
202233
activeCalls.set(input?.callID, {
203234
tool: input?.tool,
204235
args: structuredClone(output?.args || {}),
236+
session_id: sid,
205237
started_at: Date.now(),
206238
})
239+
rememberFilePath(input?.tool, output?.args || {}, sid, input?.callID)
207240
await sendToBeacon(
208241
client,
209242
payload("tool.execute.before", {
@@ -240,6 +273,7 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
240273
const sid = sessionID(properties)
241274
const info = properties?.info || {}
242275
if (type === "message.updated") {
276+
if (info?.id && info?.role) messageRoles.set(info.id, info.role)
243277
if (info?.role !== "assistant") return
244278
const model = modelName(info)
245279
if (info?.id && model) messageModels.set(info.id, model)
@@ -261,6 +295,7 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
261295
}
262296
if (type === "session.status") {
263297
const status = properties?.status?.type || ""
298+
if (sessionStates.get(sid) === status) return
264299
sessionStates.set(sid, status)
265300
if (status === "idle") flushParts(sid)
266301
}

pkg/asymptoteobserve/privacy.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,24 @@ var secretPatterns = []*regexp.Regexp{
1818
regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`),
1919
}
2020

21+
var assignedSecretPattern = regexp.MustCompile("(?i)(?:api[_-]?key|token|secret|password|authorization)\\s*[:=]\\s*[\"'`]?([^\"'`,\\s]+)")
22+
2123
type PrivacyOptions struct {
2224
RedactSecrets bool
2325
StringLimit int
2426
}
2527

2628
func RedactString(value string) string {
29+
// Remember assignment values before replacing their labeled occurrence so
30+
// repeated bare copies in the same retained prompt/output are also removed.
31+
// This is common in model reasoning that quotes `token=value` and later
32+
// refers to `value` by itself.
33+
var assigned []string
34+
for _, match := range assignedSecretPattern.FindAllStringSubmatch(value, -1) {
35+
if len(match) > 1 && len(match[1]) >= 8 {
36+
assigned = append(assigned, match[1])
37+
}
38+
}
2739
for _, pattern := range secretPatterns {
2840
value = pattern.ReplaceAllStringFunc(value, func(match string) string {
2941
if strings.Contains(match, "=") {
@@ -35,6 +47,9 @@ func RedactString(value string) string {
3547
return "[REDACTED]"
3648
})
3749
}
50+
for _, secret := range assigned {
51+
value = strings.ReplaceAll(value, secret, "[REDACTED]")
52+
}
3853
return value
3954
}
4055

pkg/asymptoteobserve/privacy_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ func TestRedactStringRedactsKnownSecretForms(t *testing.T) {
2222
}
2323
}
2424

25+
func TestRedactStringRedactsRepeatedBareAssignedValue(t *testing.T) {
26+
value := "Use token=beacon-opencode-e2e-secret, then replace beacon-opencode-e2e-secret in the file."
27+
got := RedactString(value)
28+
if strings.Contains(got, "beacon-opencode-e2e-secret") {
29+
t.Fatalf("repeated assigned value leaked: %q", got)
30+
}
31+
if strings.Count(got, "[REDACTED]") != 2 {
32+
t.Fatalf("redactions = %q, want both occurrences redacted", got)
33+
}
34+
}
35+
2536
func TestSanitizeMapDoesNotMutateInput(t *testing.T) {
2637
input := map[string]interface{}{
2738
"token": "token=super-secret",

0 commit comments

Comments
 (0)