Skip to content

Commit 0c6323f

Browse files
fix: address OpenCode Bugbot findings
Co-authored-by: Shukan Shah <shukanshah14@gmail.com>
1 parent 0efeed7 commit 0c6323f

5 files changed

Lines changed: 239 additions & 17 deletions

File tree

cli/beacon-hooks/internal/logging/endpoint_event_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func TestEndpointEventCompactsOversizedRetainedContent(t *testing.T) {
5050
large[i] = strings.Repeat("x", 4096)
5151
}
5252
fields := map[string]interface{}{
53+
"extra": large,
5354
"session": map[string]interface{}{"id": "ses_large"},
5455
"gen_ai": map[string]interface{}{
5556
"tool": map[string]interface{}{

cli/beacon-hooks/internal/logging/logging.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,14 @@ func writeEndpointJSON(path string, event map[string]interface{}) error {
238238
}
239239
}
240240
if len(data) > 64*1024 {
241-
return fmt.Errorf("endpoint event exceeds 64 KiB after content compaction")
241+
event = minimalEndpointEvent(event)
242+
data, err = json.Marshal(sanitizeEndpointMap(event))
243+
if err != nil {
244+
return err
245+
}
246+
}
247+
if len(data) > 64*1024 {
248+
return fmt.Errorf("endpoint event exceeds 64 KiB after metadata fallback")
242249
}
243250
return appendEndpointJSONL(path, append(data, '\n'), defaultEndpointRotateBytes, defaultEndpointRotateArchives)
244251
}
@@ -266,6 +273,41 @@ func compactEndpointContent(event map[string]interface{}) {
266273
}
267274
}
268275

276+
func minimalEndpointEvent(event map[string]interface{}) map[string]interface{} {
277+
out := map[string]interface{}{"field_truncated": true}
278+
for _, key := range []string{
279+
"timestamp", "vendor", "product", "schema_version", "event", "severity",
280+
"endpoint", "user", "harness", "origin", "run", "session", "trace",
281+
"error", "tool", "file", "command", "mcp", "approval", "policy",
282+
"content", "destination", "health", "model", "repository",
283+
"branch", "message",
284+
} {
285+
if value, ok := event[key]; ok && value != nil {
286+
out[key] = value
287+
}
288+
}
289+
if genAI, ok := event["gen_ai"].(map[string]interface{}); ok {
290+
summary := map[string]interface{}{}
291+
for _, key := range []string{"operation", "usage", "response", "provider"} {
292+
if value := genAI[key]; value != nil {
293+
summary[key] = value
294+
}
295+
}
296+
if tool, ok := genAI["tool"].(map[string]interface{}); ok {
297+
toolSummary := map[string]interface{}{"name": tool["name"], "type": tool["type"]}
298+
if call, ok := tool["call"].(map[string]interface{}); ok {
299+
toolSummary["call"] = map[string]interface{}{"id": call["id"]}
300+
}
301+
summary["tool"] = toolSummary
302+
}
303+
if len(summary) > 0 {
304+
out["gen_ai"] = summary
305+
}
306+
}
307+
out["message"] = truncateEndpoint(fmt.Sprint(out["message"]), 1024)
308+
return out
309+
}
310+
269311
func endpointLogPath() string {
270312
if path := firstEnv("BEACON_ENDPOINT_LOG", "BEACON_CLOUD_LOG_PATH", "BEACON_LOG_PATH", "BEACON_RUNTIME_LOG"); path != "" {
271313
return path

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

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ async function sendToBeacon(client, payload) {
6868
])
6969
if ("timeout" in outcome) {
7070
proc.kill()
71+
await Promise.race([proc.exited.catch(() => undefined), Bun.sleep(250)])
7172
await debugLog(client, "Beacon hook command timed out", { type: payload?.type })
7273
return
7374
}
@@ -122,7 +123,8 @@ function shellMutationPaths(tool, args) {
122123
if (name !== "bash" && !name.includes("shell")) return []
123124
const command = String(args?.command || args?.cmd || "")
124125
const paths = []
125-
const pattern = /\b(?:rm|unlink)\s+(?:-[^\s]+\s+)*(?:"([^"]+)"|'([^']+)'|(\S+))/g
126+
const pattern =
127+
/(?:^|(?:&&|\|\||;|\n)\s*)(?:sudo\s+)?(?:command\s+)?(?:\/bin\/)?(?:rm|unlink)\s+(?:-[^\s]+\s+)*(?:"([^"]+)"|'([^']+)'|([^\s;&|]+))/g
126128
for (const match of command.matchAll(pattern)) {
127129
const path = match[1] || match[2] || match[3]
128130
if (path) paths.push(path)
@@ -133,12 +135,13 @@ function shellMutationPaths(tool, args) {
133135
export const BeaconEndpointPlugin = async ({ project, directory, worktree, client }) => {
134136
const context = { project, directory, worktree }
135137
const activeCalls = new Map()
136-
const completedCalls = new Set()
138+
const completedCalls = new Map()
137139
const emittedParts = new Set()
138140
const pendingParts = new Map()
139141
const partDeltas = new Map()
140142
const messageModels = new Map()
141143
const messageRoles = new Map()
144+
const messageSessions = new Map()
142145
const completedMessages = new Set()
143146
const permissionRequests = new Map()
144147
const sessionStates = new Map()
@@ -204,10 +207,40 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
204207
emitPart(part, sid, item.model)
205208
}
206209
}
210+
const flushMessageParts = (messageID, sid, model, completedAt) => {
211+
for (const [key, item] of pendingParts) {
212+
if (item.part?.messageID !== messageID) continue
213+
const part = { ...item.part, time: { ...(item.part.time || {}), end: completedAt || Date.now() } }
214+
pendingParts.delete(key)
215+
emitPart(part, sid, model || item.model)
216+
}
217+
}
218+
const cleanupSession = (sid) => {
219+
for (const [callID, item] of activeCalls) if (item.session_id === sid) activeCalls.delete(callID)
220+
for (const [callID, session] of completedCalls) if (session === sid) completedCalls.delete(callID)
221+
for (const key of emittedParts) if (key.startsWith(`${sid}:`)) emittedParts.delete(key)
222+
for (const [key, item] of pendingParts) if (item.session_id === sid) pendingParts.delete(key)
223+
for (const key of partDeltas.keys()) if (key.startsWith(`${sid}:`)) partDeltas.delete(key)
224+
for (const [messageID, session] of messageSessions) {
225+
if (session !== sid) continue
226+
messageSessions.delete(messageID)
227+
messageModels.delete(messageID)
228+
messageRoles.delete(messageID)
229+
completedMessages.delete(messageID)
230+
}
231+
for (const [requestID, request] of permissionRequests) {
232+
if (request.sessionID === sid) permissionRequests.delete(requestID)
233+
}
234+
sessionStates.delete(sid)
235+
for (const [path, item] of recentFilePaths) if (item.sessionID === sid) recentFilePaths.delete(path)
236+
}
207237

208238
return {
209239
"chat.message": async (input, output) => {
210-
if (output?.message?.id) messageRoles.set(output.message.id, "user")
240+
if (output?.message?.id) {
241+
messageRoles.set(output.message.id, "user")
242+
messageSessions.set(output.message.id, sessionID(input))
243+
}
211244
await enqueue({
212245
type: "chat.message",
213246
session_id: sessionID(input),
@@ -261,7 +294,7 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
261294
}),
262295
)
263296
activeCalls.delete(input?.callID)
264-
completedCalls.add(input?.callID)
297+
completedCalls.set(input?.callID, sessionID(input))
265298
},
266299
event: async ({ event }) => {
267300
const type = event?.type || "event"
@@ -271,11 +304,15 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
271304
const sid = sessionID(properties)
272305
const info = properties?.info || {}
273306
if (type === "message.updated") {
274-
if (info?.id && info?.role) messageRoles.set(info.id, info.role)
307+
if (info?.id) {
308+
if (info?.role) messageRoles.set(info.id, info.role)
309+
messageSessions.set(info.id, sid)
310+
}
275311
if (info?.role !== "assistant") return
276312
const model = modelName(info)
277313
if (info?.id && model) messageModels.set(info.id, model)
278314
if (!info?.time?.completed && !info?.error) return
315+
flushMessageParts(info.id, sid, model, info?.time?.completed)
279316
if (info?.id) {
280317
if (completedMessages.has(info.id)) return
281318
completedMessages.add(info.id)
@@ -302,9 +339,14 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
302339
if (sessionStates.get(sid) === "idle") return
303340
sessionStates.set(sid, "idle")
304341
}
342+
if (type === "session.deleted") flushParts(sid)
305343
if (type === "permission.asked" || type === "permission.v2.asked") {
306344
if (properties?.id) {
307-
permissionRequests.set(properties.id, { tool: properties?.tool, permission: properties?.permission })
345+
permissionRequests.set(properties.id, {
346+
tool: properties?.tool,
347+
permission: properties?.permission,
348+
sessionID: sid,
349+
})
308350
}
309351
}
310352
if (type === "permission.replied" || type === "permission.v2.replied") {
@@ -331,14 +373,15 @@ export const BeaconEndpointPlugin = async ({ project, directory, worktree, clien
331373
if (seen && Date.now() - seen.time < 5000) return
332374
if (!sid) return
333375
}
334-
void enqueue(
376+
const queued = enqueue(
335377
payload(type, {
336378
session_id: sessionID(properties) || sid,
337-
model: modelName(info || properties),
379+
model: modelName(Object.keys(info).length > 0 ? info : properties),
338380
properties,
339381
event,
340382
}),
341383
)
384+
if (type === "session.deleted") void queued.finally(() => cleanupSession(sid))
342385
},
343386
}
344387
}

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,4 +220,97 @@ describe("BeaconEndpointPlugin", () => {
220220
file_mutations: [{ path, operation: "delete" }],
221221
})
222222
})
223+
224+
test("drains completed assistant parts when the message role arrives later", async () => {
225+
const hooks = await plugin()
226+
await hooks.event!({
227+
event: {
228+
type: "message.part.updated",
229+
properties: {
230+
sessionID: "ses_1",
231+
part: {
232+
id: "prt_late",
233+
messageID: "msg_late",
234+
sessionID: "ses_1",
235+
type: "text",
236+
text: "late role",
237+
time: { start: 1, end: 2 },
238+
},
239+
},
240+
},
241+
} as any)
242+
await hooks.event!({
243+
event: {
244+
type: "message.updated",
245+
properties: {
246+
sessionID: "ses_1",
247+
info: {
248+
id: "msg_late",
249+
role: "assistant",
250+
providerID: "moonshotai",
251+
modelID: "kimi-k3",
252+
time: { completed: 3 },
253+
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
254+
},
255+
},
256+
},
257+
} as any)
258+
await Bun.sleep(20)
259+
260+
expect(payloads.map((item) => item.type)).toEqual(["message.part.updated", "message.updated"])
261+
expect(payloads[0].part.text).toBe("late role")
262+
})
263+
264+
test("cleans session state after deletion", async () => {
265+
const hooks = await plugin()
266+
const completed = (sessionID: string) => ({
267+
event: {
268+
type: "message.updated",
269+
properties: {
270+
sessionID,
271+
info: {
272+
id: "msg_reused",
273+
role: "assistant",
274+
providerID: "moonshotai",
275+
modelID: "kimi-k3",
276+
time: { completed: 2 },
277+
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
278+
},
279+
},
280+
},
281+
})
282+
await hooks.event!(completed("ses_1") as any)
283+
await hooks.event!({ event: { type: "session.deleted", properties: { sessionID: "ses_1" } } } as any)
284+
await Bun.sleep(20)
285+
await hooks.event!(completed("ses_2") as any)
286+
await Bun.sleep(20)
287+
288+
expect(payloads.map((item) => item.type)).toEqual(["message.updated", "session.deleted", "message.updated"])
289+
})
290+
291+
test("does not infer deletes from quoted rm text and preserves root model metadata", async () => {
292+
const hooks = await plugin()
293+
await hooks["tool.execute.before"]!(
294+
{ tool: "bash", sessionID: "ses_1", callID: "call_echo" },
295+
{ args: { command: "echo rm /tmp/not-deleted" } },
296+
)
297+
await hooks["tool.execute.after"]!(
298+
{ tool: "bash", sessionID: "ses_1", callID: "call_echo", args: { command: "echo rm /tmp/not-deleted" } },
299+
{ title: "echo", output: "rm /tmp/not-deleted", metadata: { exit: 0 } },
300+
)
301+
await hooks.event!({
302+
event: {
303+
type: "session.status",
304+
properties: {
305+
sessionID: "ses_1",
306+
status: { type: "busy" },
307+
model: { providerID: "moonshotai", modelID: "kimi-k3" },
308+
},
309+
},
310+
} as any)
311+
await Bun.sleep(20)
312+
313+
expect(payloads[1].file_mutations).toEqual([])
314+
expect(payloads[2].model).toBe("moonshotai/kimi-k3")
315+
})
223316
})

0 commit comments

Comments
 (0)