Skip to content

Commit 1989908

Browse files
authored
feat(parser): detect opencode bash tool failures from metadata.exit (#1257)
## Problem OpenCode bash tool failures leave `metadata.exit > 0` in the tool state but the output text does NOT contain the `exit status N` pattern that AgentsView's `isBashFailure()` checks. This is not Windows-specific: on a Linux `opencode.db`, all 24 bash parts with a non-zero exit (1, 127, 128) had output text with no `exit status` marker, while all 81 successful parts recorded `exit=0`. Example outputs from real sessions: - `'choco' is not recognized as an internal or external command` - exit=1, no "exit status" text - (empty output) - exit=1, no text at all - `rsync error: some files could not be transferred` - exit=1 The exit code is reliably recorded in `metadata.exit` by the agent, but the parser never reads it. ## Impact On a Windows machine with 273 opencode sessions, 1233 bash tool failures are missed because they lack the `exit status` output pattern. Only 51 are currently detected (via the `tool:"invalid"` fix). The remaining ~1200 are invisible to signals, insights, and health scoring. ## Fix In `extractOpenCodeToolCall()`, after parsing the tool state, check `metadata.exit`: ```go if len(state.Metadata) > 0 { var m struct { Exit int `json:"exit"` } if err := json.Unmarshal(state.Metadata, &m); err == nil && m.Exit > 0 { isFailure = true } } ``` This is opt-in per agent - only opencode records metadata.exit. Other agents are unaffected. `dataVersion` moves to 73 so existing opencode rows are re-parsed and historical sessions backfill the failure events, and the format evidence is recorded in `docs/internal/session-format-sources.md`. ## Branch https://github.com/ajinkyajacob/agentsview/tree/feat/opencode-metadata-exit-failure-detection ## Risk - Non-zero exit doesn't always mean failure (e.g., `grep` returns 1 for no match). However, AgentsView's existing `exitStatusRe` already makes the same assumption via output text. This just adds parity for the metadata path. - Only applies when `metadata` key exists in the tool state (opencode format), so other agents are isolated. Co-authored-by: ajinkyajacob <ajinkyajacob@users.noreply.github.com>
1 parent 8aa989f commit 1989908

5 files changed

Lines changed: 135 additions & 6 deletions

File tree

docs/internal/session-format-sources.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,24 @@ Grok section and remove the explicit registry exception in the coverage test.
235235
`state.status` is `completed` with the error text in the output. Agentsview
236236
attaches an errored result event to `tool:"invalid"` parts so tool health
237237
counts them as failures (verified 2026-07-24; see #1254).
238+
- **Bash exit codes:** The `bash` tool declares a structured output of
239+
`{exit, truncated, timeout}` and returns the child process exit code as
240+
`exit`
241+
([bash.ts](https://github.com/anomalyco/opencode/blob/67caf894e0843ee370e72839e8265e483233479b/packages/core/src/tool/bash.ts)
242+
at the pinned commit). That structured output is persisted as the tool
243+
part's `state.metadata`, so `state.metadata.exit` is the authoritative
244+
failure signal. The tool's own output text carries no `exit status N`
245+
marker, and the shell is `COMSPEC`/`cmd.exe` on Windows, so text-pattern
246+
matching alone misses these failures on every platform. Agentsview treats a
247+
non-zero `state.metadata.exit` on a `bash` tool part as a failure and attaches
248+
an errored result event. Only `bash` parts record `exit`; other tools omit the
249+
key. Verified 2026-07-24 against a live `opencode.db` where all 24 bash
250+
parts with `exit` in `{1, 127, 128}` had output text without an
251+
`exit status` marker, and the 81 successful parts recorded `exit=0`. Known
252+
gaps: a command that legitimately exits non-zero (`grep` with no match)
253+
counts as a failure, matching the existing `exit status N` heuristic, and a
254+
timed-out command records `timeout: true` with no `exit` key, so it is not
255+
detected here. See #1256.
238256
- **Agentsview:** `internal/parser/opencode.go`,
239257
`internal/parser/opencode_provider.go`, and
240258
`internal/parser/opencode_storage_state.go`; legacy and database layouts are

internal/db/db.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,12 @@ const projectIdentityRemoteScrubCompletedKey = "project_identity_remote_scrub_v1
325325
// records unknown-tool calls as a synthetic "invalid" tool that completes
326326
// successfully, so existing rows carry no failure signal. Re-parsing attaches
327327
// the errored event so tool-health failure counts cover historical sessions.)
328-
const dataVersion = 72
328+
// (73: OpenCode bash tool calls emit an errored result event when the tool
329+
// state records a non-zero metadata.exit. Windows shells produce no "exit
330+
// status N" output text, so existing rows carry no failure signal. Re-parsing
331+
// attaches the errored event so tool-health failure counts cover historical
332+
// OpenCode sessions on every platform.)
333+
const dataVersion = 73
329334

330335
const tokenCoverageRepairStatsKey = "token_coverage_repair_v1"
331336

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 TestCurrentDataVersionOpenCodeInvalidToolFailure(t *testing.T) {
1011-
assert.Equal(t, 72, CurrentDataVersion(),
1012-
"OpenCode invalid-tool failure detection requires a data version bump")
1010+
func TestCurrentDataVersionOpenCodeBashExitFailure(t *testing.T) {
1011+
assert.Equal(t, 73, CurrentDataVersion(),
1012+
"OpenCode bash metadata.exit failure detection requires a data version bump")
10131013
}
10141014

10151015
func TestInsertMessages_PreservesToolResultEvents(t *testing.T) {

internal/parser/opencode.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -984,7 +984,13 @@ type openCodeToolData struct {
984984

985985
// openCodeToolState holds the nested state of a tool call.
986986
type openCodeToolState struct {
987-
Input json.RawMessage `json:"input"`
987+
Input json.RawMessage `json:"input"`
988+
Metadata json.RawMessage `json:"metadata"`
989+
}
990+
991+
// openCodeToolMetadata holds the optional metadata from a tool state.
992+
type openCodeToolMetadata struct {
993+
Exit int `json:"exit"`
988994
}
989995

990996
func extractOpenCodeToolCall(data, cwd string) ParsedToolCall {
@@ -993,13 +999,26 @@ func extractOpenCodeToolCall(data, cwd string) ParsedToolCall {
993999
return ParsedToolCall{}
9941000
}
9951001

996-
var inputJSON string
1002+
var (
1003+
inputJSON string
1004+
isFailure bool
1005+
)
9971006
if len(d.State) > 0 {
9981007
var state openCodeToolState
9991008
if err := json.Unmarshal(d.State, &state); err == nil {
10001009
if len(state.Input) > 0 {
10011010
inputJSON = string(state.Input)
10021011
}
1012+
// OpenCode records the shell exit code in the tool
1013+
// state metadata. On Windows the output text carries
1014+
// no "exit status N" marker, so metadata.exit is the
1015+
// only reliable failure signal.
1016+
if d.ToolName == "bash" && len(state.Metadata) > 0 {
1017+
var m openCodeToolMetadata
1018+
if err := json.Unmarshal(state.Metadata, &m); err == nil && m.Exit > 0 {
1019+
isFailure = true
1020+
}
1021+
}
10031022
}
10041023
}
10051024

@@ -1027,6 +1046,10 @@ func extractOpenCodeToolCall(data, cwd string) ParsedToolCall {
10271046
// is "completed" and carries no error signal. Attach an errored
10281047
// result event so tool health counts these as failures.
10291048
if d.ToolName == "invalid" {
1049+
isFailure = true
1050+
}
1051+
1052+
if isFailure {
10301053
tc.ResultEvents = append(tc.ResultEvents, ParsedToolResultEvent{
10311054
ToolUseID: d.CallID,
10321055
Status: "errored",

internal/parser/opencode_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,89 @@ func TestParseOpenCodeDB_InvalidToolCall(t *testing.T) {
929929
assertEq(t, "ResultEvents[0].Status", ast.ToolCalls[0].ResultEvents[0].Status, "errored")
930930
}
931931

932+
// TestParseOpenCodeDB_BashExitFailure verifies that a bash tool whose
933+
// state metadata records a non-zero exit is reported as a failure even
934+
// when the output text lacks an "exit status N" marker, and that a
935+
// successful or exit-less part stays clean.
936+
func TestParseOpenCodeDB_BashExitFailure(t *testing.T) {
937+
tests := []struct {
938+
name string
939+
tool string
940+
state string
941+
wantErrored bool
942+
}{
943+
{
944+
name: "non-zero exit without exit-status text",
945+
tool: "bash",
946+
state: `{"input":{"command":"build"},"output":"error: command failed","metadata":{"exit":1}}`,
947+
wantErrored: true,
948+
},
949+
{
950+
name: "non-zero exit with empty output",
951+
tool: "bash",
952+
state: `{"input":{"command":"build"},"output":"","metadata":{"exit":127}}`,
953+
wantErrored: true,
954+
},
955+
{
956+
name: "zero exit is not a failure",
957+
tool: "bash",
958+
state: `{"input":{"command":"build"},"output":"ok","metadata":{"exit":0}}`,
959+
wantErrored: false,
960+
},
961+
{
962+
name: "metadata without an exit key is not a failure",
963+
tool: "bash",
964+
state: `{"input":{"command":"build"},"output":"ok","metadata":{"truncated":false}}`,
965+
wantErrored: false,
966+
},
967+
{
968+
name: "no metadata is not a failure",
969+
tool: "bash",
970+
state: `{"input":{"command":"build"},"output":"ok"}`,
971+
wantErrored: false,
972+
},
973+
{
974+
name: "non-bash metadata exit is not a failure",
975+
tool: "mcp_lookup",
976+
state: `{"input":{"query":"exit routes"},"output":"route 1","metadata":{"exit":1}}`,
977+
wantErrored: false,
978+
},
979+
}
980+
981+
for _, tt := range tests {
982+
t.Run(tt.name, func(t *testing.T) {
983+
dbPath, seeder, db := newTestDB(t)
984+
defer db.Close()
985+
986+
seeder.AddProject("prj_1", "/tmp/proj")
987+
seeder.AddSession("ses_bexit", "prj_1", "", "", 1700000000000, 1700000030000)
988+
989+
seeder.AddMessage("msg_u", "ses_bexit", 1700000000000, 1700000000000, `{"role":"user"}`)
990+
seeder.AddPart("prt_u", "msg_u", "ses_bexit", 1700000000000, 1700000000000, `{"type":"text","text":"build"}`)
991+
992+
seeder.AddMessage("msg_a", "ses_bexit", 1700000010000, 1700000010000, `{"role":"assistant"}`)
993+
seeder.AddPart("prt_t", "msg_a", "ses_bexit", 1700000010000, 1700000010000,
994+
`{"type":"tool","tool":"`+tt.tool+`","callID":"call_exit","state":`+tt.state+`}`)
995+
996+
sessions, err := parseOpenCodeAll(dbPath, "m")
997+
require.NoError(t, err, "ParseOpenCodeDB")
998+
require.Len(t, sessions, 1, "sessions len")
999+
1000+
msgs := sessions[0].Messages
1001+
require.Len(t, msgs, 2, "messages len")
1002+
1003+
ast := msgs[1]
1004+
require.Len(t, ast.ToolCalls, 1, "tool calls len")
1005+
if !tt.wantErrored {
1006+
assert.Empty(t, ast.ToolCalls[0].ResultEvents, "result events")
1007+
return
1008+
}
1009+
require.Len(t, ast.ToolCalls[0].ResultEvents, 1, "result events len")
1010+
assertEq(t, "ResultEvents[0].Status", ast.ToolCalls[0].ResultEvents[0].Status, "errored")
1011+
})
1012+
}
1013+
}
1014+
9321015
// TestParseOpenCodeDB_SkillNameFromReadTool verifies that a
9331016
// "read" tool part whose input points at a real on-disk SKILL.md
9341017
// infers the skill name from the file's frontmatter, matching the

0 commit comments

Comments
 (0)