Skip to content

Commit 8aa989f

Browse files
authored
fix(parser): detect opencode invalid tool calls as failures (#1255)
Fixes #1254 ## Problem Opencode stores failed tool calls with `tool: "invalid"` and `state.status: "completed"` — the parser never emitted a `ResultEvent`, so `ComputeToolHealth` had no error signal to detect. All opencode sessions showed `tool_failure_signal_count: 0` despite confirmed invalid tool calls. ## Fix In `extractOpenCodeToolCall()`, when `d.ToolName == "invalid"`, attach a `ResultEvent{Status: "errored"}` so the signal pipeline (`extractToolCallRows` → `signals.IsFailure` → `ComputeToolHealth`) counts it as a failure. ## Test Added `TestParseOpenCodeDB_InvalidToolCall` that seeds a session with an invalid tool part and asserts `ResultEvents[0].Status == "errored"`. All existing opencode tests continue to pass. Co-authored-by: ajinkyajacob <ajinkyajacob@users.noreply.github.com>
1 parent 14ef572 commit 8aa989f

5 files changed

Lines changed: 59 additions & 5 deletions

File tree

docs/internal/session-format-sources.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,13 @@ Grok section and remove the explicit registry exception in the coverage test.
228228
prefers a concrete `session.directory` over `project.worktree` when resolving
229229
cwd/project (verified against live `opencode.db` rows under `project_id=global`
230230
on 2026-07-23; see #1236).
231+
- **Invalid tool calls:** Model calls to unknown or malformed tools are
232+
recorded as a synthetic `invalid` tool part whose `execute` succeeds
233+
(`packages/opencode/src/tool/invalid.ts`, registered in
234+
`packages/opencode/src/tool/registry.ts` at the pinned commit), so
235+
`state.status` is `completed` with the error text in the output. Agentsview
236+
attaches an errored result event to `tool:"invalid"` parts so tool health
237+
counts them as failures (verified 2026-07-24; see #1254).
231238
- **Agentsview:** `internal/parser/opencode.go`,
232239
`internal/parser/opencode_provider.go`, and
233240
`internal/parser/opencode_storage_state.go`; legacy and database layouts are

internal/db/db.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,11 @@ const projectIdentityRemoteScrubCompletedKey = "project_identity_remote_scrub_v1
321321
// (71: OpenCode SQLite cwd/project derivation now prefers a concrete
322322
// session.directory over the synthetic global project worktree "/". Existing
323323
// OpenCode rows need re-parsing so unchanged sessions refresh cwd and project.)
324-
const dataVersion = 71
324+
// (72: OpenCode invalid tool calls emit an errored result event. OpenCode
325+
// records unknown-tool calls as a synthetic "invalid" tool that completes
326+
// successfully, so existing rows carry no failure signal. Re-parsing attaches
327+
// the errored event so tool-health failure counts cover historical sessions.)
328+
const dataVersion = 72
325329

326330
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
327331

internal/db/db_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,9 +1007,9 @@ func TestMigration_ToolResultEventsTable(t *testing.T) {
10071007
"expected tool_result_events table after reopen")
10081008
}
10091009

1010-
func TestCurrentDataVersionOpenCodeSessionDirectory(t *testing.T) {
1011-
assert.Equal(t, 71, CurrentDataVersion(),
1012-
"OpenCode cwd/project derivation requires a data version bump")
1010+
func TestCurrentDataVersionOpenCodeInvalidToolFailure(t *testing.T) {
1011+
assert.Equal(t, 72, CurrentDataVersion(),
1012+
"OpenCode invalid-tool failure detection requires a data version bump")
10131013
}
10141014

10151015
func TestInsertMessages_PreservesToolResultEvents(t *testing.T) {

internal/parser/opencode.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1014,13 +1014,26 @@ func extractOpenCodeToolCall(data, cwd string) ParsedToolCall {
10141014
skillName = inferOpenCodeSkillName(d.ToolName, inputJSON, cwd)
10151015
}
10161016

1017-
return ParsedToolCall{
1017+
tc := ParsedToolCall{
10181018
ToolUseID: d.CallID,
10191019
ToolName: d.ToolName,
10201020
Category: NormalizeToolCategory(d.ToolName),
10211021
InputJSON: inputJSON,
10221022
SkillName: skillName,
10231023
}
1024+
1025+
// OpenCode records model calls to unknown or malformed tools as a
1026+
// synthetic "invalid" tool whose execute succeeds, so state.status
1027+
// is "completed" and carries no error signal. Attach an errored
1028+
// result event so tool health counts these as failures.
1029+
if d.ToolName == "invalid" {
1030+
tc.ResultEvents = append(tc.ResultEvents, ParsedToolResultEvent{
1031+
ToolUseID: d.CallID,
1032+
Status: "errored",
1033+
})
1034+
}
1035+
1036+
return tc
10241037
}
10251038

10261039
func inferOpenCodeSkillName(toolName, inputJSON, cwd string) string {

internal/parser/opencode_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,36 @@ func TestParseOpenCodeDB_SkillTool(t *testing.T) {
899899
assertEq(t, "SkillName", ast.ToolCalls[0].SkillName, "doc-writer")
900900
}
901901

902+
// TestParseOpenCodeDB_InvalidToolCall verifies that an invalid
903+
// tool call (tool:"invalid") populates ResultEvents with
904+
// Status:"errored" so the signal engine detects it as a failure.
905+
func TestParseOpenCodeDB_InvalidToolCall(t *testing.T) {
906+
dbPath, seeder, db := newTestDB(t)
907+
defer db.Close()
908+
909+
seeder.AddProject("prj_1", "/tmp/proj")
910+
seeder.AddSession("ses_inv", "prj_1", "", "", 1700000000000, 1700000030000)
911+
912+
seeder.AddMessage("msg_u", "ses_inv", 1700000000000, 1700000000000, `{"role":"user"}`)
913+
seeder.AddPart("prt_u", "msg_u", "ses_inv", 1700000000000, 1700000000000, `{"type":"text","text":"do something"}`)
914+
915+
seeder.AddMessage("msg_a", "ses_inv", 1700000010000, 1700000010000, `{"role":"assistant"}`)
916+
seeder.AddPart("prt_t", "msg_a", "ses_inv", 1700000010000, 1700000010000,
917+
`{"type":"tool","tool":"invalid","callID":"call_inv","state":{"input":{"tool":"nonexistent_tool","error":"Model tried to call unavailable tool 'nonexistent_tool'"}}}`)
918+
919+
sessions, err := parseOpenCodeAll(dbPath, "m")
920+
require.NoError(t, err, "ParseOpenCodeDB")
921+
require.Len(t, sessions, 1, "sessions len")
922+
923+
msgs := sessions[0].Messages
924+
require.Len(t, msgs, 2, "messages len")
925+
926+
ast := msgs[1]
927+
require.Len(t, ast.ToolCalls, 1, "tool calls len")
928+
require.Len(t, ast.ToolCalls[0].ResultEvents, 1, "result events len")
929+
assertEq(t, "ResultEvents[0].Status", ast.ToolCalls[0].ResultEvents[0].Status, "errored")
930+
}
931+
902932
// TestParseOpenCodeDB_SkillNameFromReadTool verifies that a
903933
// "read" tool part whose input points at a real on-disk SKILL.md
904934
// infers the skill name from the file's frontmatter, matching the

0 commit comments

Comments
 (0)