Skip to content

refactor(runtime): add OpenCode ndjson stream parser - #6147

Merged
ralphbean merged 4 commits into
fullsend-ai:mainfrom
sonupreetam:feat/opencode-stream-parser
Aug 14, 2026
Merged

refactor(runtime): add OpenCode ndjson stream parser#6147
ralphbean merged 4 commits into
fullsend-ai:mainfrom
sonupreetam:feat/opencode-stream-parser

Conversation

@sonupreetam

Copy link
Copy Markdown
Contributor

Summary

Implement parseOpenCodeStream() that maps OpenCode's --format json ndjson events to the normalized AgentEvent types, enabling real-time progress rendering and metrics capture for OpenCode agent runs. This is the second step toward OpenCode runtime support, following the stub registration in #6035.

Related Issue

Refs #1260

Changes

  • Create internal/runtime/opencode_progress.go — parser mapping 6 OpenCode ndjson event types (tool_use, text, reasoning, step_finish, step_start, error) to fullsend AgentEvent types
  • Returns sessionID from the ndjson envelope (needed for opencode export in transcript extraction)
  • Synthesizes ResultEvent at EOF from accumulated step_finish cost/token data (OpenCode has no explicit result event like Claude Code)
  • Only emits ToolUseEvent for terminal states (completed/error), filtering intermediate pending/running states
  • Creates 6 ndjson test fixtures under internal/runtime/testdata/opencode/
  • 9 test functions covering: basic run, error detection, reasoning/thinking, multi-step token accumulation, malformed input resilience, empty stream, sessionID extraction, read errors, and oversized line handling

Testing

  • make lint passes (stage changes first, then run)
  • Tests added/updated for new or modified logic

Checklist

  • PR title follows Conventional Commits (correct type, ! for breaking changes)
  • Commits are signed off (DCO) — human and human-directed agent sessions only
  • I wrote this contribution myself and can explain all changes in it

@sonupreetam
sonupreetam requested a review from a team as a code owner August 12, 2026 16:55
@github-actions

Copy link
Copy Markdown

E2E tests did not run

E2E tests run automatically for org/repo members and collaborators on pull requests.

For other contributors, a maintainer must add the ok-to-test label after the latest push.

See E2E testing guide for details.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

runtime: add OpenCode NDJSON stream parser for normalized AgentEvent output

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Parse OpenCode NDJSON stream into normalized AgentEvent callbacks for live progress.
• Accumulate per-step token/cost data and emit a synthesized ResultEvent at EOF.
• Add fixtures and tests covering errors, malformed input, and oversized line handling.
Diagram

graph TD
  OC{{"OpenCode NDJSON"}} --> P["parseOpenCodeStream()"] --> M["Map to AgentEvent"] --> CB(("onEvent callback")) --> UI(("Progress renderer"))
  CB --> MET(("Metrics capture"))
  M --> RS["Accumulate + EOF ResultEvent"] --> CB

  subgraph Legend
    direction LR
    _ext{{"External"}} ~~~ _proc["Process"] ~~~ _cons(("Consumer"))
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Single-pass decode with json.RawMessage
  • ➕ Avoids double-unmarshal per line (envelope + typed event)
  • ➕ Keeps strict control over which parts are decoded for each type
  • ➖ Slightly more complex code (RawMessage plumbing)
  • ➖ Harder to read than the current straightforward approach
2. bufio.Scanner with enlarged buffer
  • ➕ Simpler line iteration API than ReadLine
  • ➕ Built-in tokenization fits NDJSON naturally
  • ➖ Still requires careful buffer sizing; defaults are too small
  • ➖ Scanner can be less flexible than ReadLine for oversized-line skip behavior

Recommendation: The current approach (ReadLine + best-effort JSON unmarshal + tolerant skipping) is a good fit for progress streams: it is resilient to malformed lines and mirrors the existing oversized-line handling pattern. If performance becomes a concern (very large streams), consider the RawMessage single-pass decode to reduce per-line overhead, but keep today’s implementation for clarity and maintainability.

Files changed (8) +523 / -0

Enhancement (1) +219 / -0
opencode_progress.goAdd OpenCode NDJSON stream parser emitting normalized AgentEvent +219/-0

Add OpenCode NDJSON stream parser emitting normalized AgentEvent

• Implements parseOpenCodeStream to read OpenCode '--format json' NDJSON events and emit normalized AgentEvent values via a callback. Tracks step_finish token/cost totals to synthesize a ResultEvent at EOF, captures the first sessionID, filters tool_use to terminal states, and skips malformed/unknown/oversized lines.

internal/runtime/opencode_progress.go

Tests (7) +304 / -0
opencode_progress_test.goAdd unit tests for OpenCode stream parsing and edge cases +281/-0

Add unit tests for OpenCode stream parsing and edge cases

• Adds tests validating event mapping (text, tool_use terminal states, reasoning→ThinkingEvent, error propagation), token/cost accumulation across steps, and ResultEvent synthesis ordering. Covers resilience cases including malformed NDJSON, empty streams, read errors, sessionID capture, and oversized line skipping.

internal/runtime/opencode_progress_test.go

basic_run.ndjsonAdd fixture for successful single-step OpenCode run +4/-0

Add fixture for successful single-step OpenCode run

• Provides a minimal NDJSON transcript including step_start, text, tool_use(completed), and step_finish with token/cost values for baseline parsing assertions.

internal/runtime/testdata/opencode/basic_run.ndjson

empty.ndjsonAdd empty NDJSON fixture +0/-0

Add empty NDJSON fixture

• Provides an empty stream fixture to validate that the parser emits only a synthesized ResultEvent and marks the run as error when no steps are present.

internal/runtime/testdata/opencode/empty.ndjson

error_run.ndjsonAdd fixture for tool and API error scenario +4/-0

Add fixture for tool and API error scenario

• Includes tool_use(error), an error event, and a final step_finish to validate ErrorEvent emission and ResultEvent error fields/token totals.

internal/runtime/testdata/opencode/error_run.ndjson

malformed.ndjsonAdd fixture containing malformed and blank lines +5/-0

Add fixture containing malformed and blank lines

• Mixes invalid JSON, empty lines, and valid events to verify the parser tolerates malformed input and still emits events from valid lines.

internal/runtime/testdata/opencode/malformed.ndjson

multi_step.ndjsonAdd fixture for multi-step token/cost accumulation +6/-0

Add fixture for multi-step token/cost accumulation

• Provides two step_finish events across multiple steps to validate token/cache/cost totals are accumulated into the synthesized ResultEvent.

internal/runtime/testdata/opencode/multi_step.ndjson

reasoning_run.ndjsonAdd fixture for reasoning-to-thinking mapping +4/-0

Add fixture for reasoning-to-thinking mapping

• Includes a reasoning event followed by text and step_finish to validate ThinkingEvent mapping and that reasoning tokens don’t affect input/output totals.

internal/runtime/testdata/opencode/reasoning_run.ndjson

@qodo-code-review

qodo-code-review Bot commented Aug 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Tool summaries leak secrets ✓ Resolved 🐞 Bug ⛨ Security
Description
parseOpenCodeStream forwards OpenCode tool state.title / state.error directly into
ToolUseEvent.Summary, bypassing the secret redaction used for Claude tool context strings. This
can disclose tokens/credentials in terminal output and GitHub Actions ::notice:: logs.
Code

internal/runtime/opencode_progress.go[R146-149]

+			case "completed":
+				onEvent(ToolUseEvent{Name: evt.Part.Tool, Summary: evt.Part.State.Title})
+			case "error":
+				onEvent(ToolUseEvent{Name: evt.Part.Tool, Summary: evt.Part.State.Error})
Relevance

●●● Strong

Repo recently accepted redaction to prevent secrets leaking via progress/tool output and CI
annotations.

PR-#3186

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The OpenCode parser emits raw tool titles/errors as ToolUseEvent.Summary, while the existing
Claude path explicitly redacts tool context via progressRedactor before anything is rendered.
EventRenderer then prints ToolUseEvent messages to terminal and CI notices, making unredacted
summaries a direct leak vector.

internal/runtime/opencode_progress.go[138-150]
internal/runtime/claude_progress.go[357-372]
internal/runtime/renderer.go[63-76]
PR-#3186

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`parseOpenCodeStream()` emits `ToolUseEvent` summaries from OpenCode’s `state.title` / `state.error` without applying the existing progress secret redaction. This can leak credentials (e.g., Authorization headers, tokens in commands) to terminal output and CI annotations.

## Issue Context
The Claude stream parser uses `extractSafeContext()` which runs tool context strings through `progressRedactor` (SecretRedactor) before rendering. OpenCode’s parser should apply the same redaction boundary for parity and safety.

## Fix Focus Areas
- internal/runtime/opencode_progress.go[138-150]
- internal/runtime/claude_progress.go[357-372]
- internal/runtime/renderer.go[63-76]

### Suggested approach
- Before calling `onEvent(ToolUseEvent{...})`, pass the chosen summary (`title`/`error`) through the same secret-redaction mechanism used by Claude (e.g., `progressRedactor.Scan(summary).Sanitized` with a fallback to the raw summary when Sanitized is empty).
- Add/extend a unit test fixture where `state.title` (or `state.error`) contains a token-like string and assert the emitted `ToolUseEvent.Summary` is redacted (and does not contain the secret substring).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Reasoning tokens dropped ✓ Resolved 🐞 Bug ≡ Correctness
Description
parseOpenCodeStream decodes tokens.reasoning from step_finish events but never includes it in
emitted TokensEvent values or the synthesized ResultEvent totals. This loses OpenCode token
usage data and can under-report usage/cost metrics.
Code

internal/runtime/opencode_progress.go[R172-175]

+			totalCostUSD += evt.Part.Cost
+			totalInput += evt.Part.Tokens.Input
+			totalOutput += evt.Part.Tokens.Output
+			totalCacheRead += evt.Part.Tokens.Cache.Read
Relevance

●●● Strong

Token-usage accounting correctness fixes in progress parsing/metrics were previously accepted;
reasoning tokens omission is similar.

PR-#3186

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The fixture includes a non-zero tokens.reasoning value, the parser decodes it into
ocStepTokens.Reasoning, but the subsequent accumulation and event emission only use
input/output/cache fields, so reasoning usage is dropped from all downstream metrics.

internal/runtime/testdata/opencode/reasoning_run.ndjson[1-4]
internal/runtime/opencode_progress.go[57-62]
internal/runtime/opencode_progress.go[166-183]
internal/runtime/event.go[42-63]
internal/runtime/runtime.go[11-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
OpenCode `step_finish` events include a `tokens.reasoning` counter, and the parser decodes it, but it is ignored when emitting `TokensEvent` and when accumulating totals for the synthesized `ResultEvent`. As a result, normalized token metrics can be inaccurate for OpenCode runs.

## Issue Context
Normalized event/metrics structs currently have no dedicated “reasoning tokens” field. You need to define a mapping:
- Option A: add a dedicated reasoning token field across `TokensEvent`, `ResultEvent`, and `RunMetrics`.
- Option B: explicitly fold reasoning tokens into an existing aggregate (commonly `OutputTokens`) to preserve total usage parity.

## Fix Focus Areas
- internal/runtime/opencode_progress.go[57-62]
- internal/runtime/opencode_progress.go[166-183]
- internal/runtime/event.go[42-63]
- internal/runtime/runtime.go[11-21]
- internal/runtime/testdata/opencode/reasoning_run.ndjson[1-4]

### Suggested approach
- Decide the intended semantics for `tokens.reasoning`.
- Implement the chosen mapping consistently:
 - per-step: when building `TokensEvent`
 - totals: when accumulating `totalOutput` (or new totals)
 - final: when building `ResultEvent`
- Add a unit test asserting the fixture containing `"reasoning":50` results in the expected normalized totals (either separately, or folded into output tokens).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context
✅ Compliance rules (platform): 54 rules
✅ Skills: writing-user-docs, writing-adrs

Grey Divider

Tip of the day
💡 Did you know, you can describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread internal/runtime/opencode_progress.go Outdated
Comment thread internal/runtime/opencode_progress.go

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review squad pass (Claude x2 + Grok, cross-verified against the actual anomalyco/opencode emitter source). Posting the unique medium+ findings not already covered by the existing qodo-code-review comments (unredacted ToolUseEvent.Summary and dropped reasoning tokens are already flagged there and not re-posted here).

Comment thread internal/runtime/opencode_progress.go
Comment thread internal/runtime/opencode_progress.go
Comment thread internal/runtime/opencode_progress.go
Comment thread internal/runtime/opencode_progress.go
Comment thread internal/runtime/opencode_progress.go Outdated
Comment thread internal/runtime/opencode_progress.go
Comment thread internal/runtime/opencode_progress.go
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.14729% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/runtime/opencode_progress.go 87.37% 7 Missing and 6 partials ⚠️
internal/runtime/claude.go 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Comment thread internal/cli/run.go Outdated
@sonupreetam
sonupreetam force-pushed the feat/opencode-stream-parser branch from 20358e8 to 7e2b84a Compare August 13, 2026 12:55
@sonupreetam

Copy link
Copy Markdown
Contributor Author

Addressed all review findings from @qodo-code-review, @waynesun09, and @maruiz93 in two fix commits:

Commit d78f037fix(runtime): address review findings for OpenCode stream parser

  • Secret redaction on tool summaries via shared progressRedactor (HIGH, qodo + maruiz93)
  • ReasoningTokens threaded through full pipeline: events → renderer → RunMetrics → OTEL → aggregation (MEDIUM, qodo)
  • step_finish.reason mapped to ResultEvent.Subtype (MEDIUM, waynesun09)
  • Consolidated duplicate SecretRedactor instances (DRY)
  • Documented truncated-stream false-success limitation
  • Fixed errors variable shadow → errEvents
  • Tests: secret redaction (dirty + clean), pending/running filtering, reasoning tokens in renderer/telemetry/aggregation

Commit 7e2b84afix(runtime): address additional review findings

  • Error messages redacted via redactSummary() (HIGH, maruiz93)
  • Documented InitEvent as caller responsibility — wire format lacks model metadata (HIGH, maruiz93)
  • Documented --thinking flag requirement for reasoning events (MEDIUM, waynesun09)
  • Pinned emitter source: anomalyco/opencode run.ts, pre-GA 2026-08 (MEDIUM, waynesun09)
  • Extracted shared streamBufSize constant for both parsers (LOW, maruiz93)

Side note: the Go convention pack (.opencode/uf/packs/go.md) rules TC-001/TC-002 mandate stdlib testing only — no testify. However, 72% of test files across the project (156/216) use testify/assert + testify/require, including 33 packages exclusively. The pack rule appears stale. Would this be a good issue for an external contributor to file and update the pack to match actual project conventions?

@qodo-code-review

Copy link
Copy Markdown

Addressed all review findings from @qodo-code-review, @waynesun09, and @maruiz93 in two fix commits:

Commit d78f037fix(runtime): address review findings for OpenCode stream parser

  • Secret redaction on tool summaries via shared progressRedactor (HIGH, qodo + maruiz93)
  • ReasoningTokens threaded through full pipeline: events → renderer → RunMetrics → OTEL → aggregation (MEDIUM, qodo)
  • step_finish.reason mapped to ResultEvent.Subtype (MEDIUM, waynesun09)
  • Consolidated duplicate SecretRedactor instances (DRY)
  • Documented truncated-stream false-success limitation
  • Fixed errors variable shadow → errEvents
  • Tests: secret redaction (dirty + clean), pending/running filtering, reasoning tokens in renderer/telemetry/aggregation

Commit 7e2b84afix(runtime): address additional review findings

  • Error messages redacted via redactSummary() (HIGH, maruiz93)
  • Documented InitEvent as caller responsibility — wire format lacks model metadata (HIGH, maruiz93)
  • Documented --thinking flag requirement for reasoning events (MEDIUM, waynesun09)
  • Pinned emitter source: anomalyco/opencode run.ts, pre-GA 2026-08 (MEDIUM, waynesun09)
  • Extracted shared streamBufSize constant for both parsers (LOW, maruiz93)

Side note: the Go convention pack (.opencode/uf/packs/go.md) rules TC-001/TC-002 mandate stdlib testing only — no testify. However, 72% of test files across the project (156/216) use testify/assert + testify/require, including 33 packages exclusively. The pack rule appears stale. Would this be a good issue for an external contributor to file and update the pack to match actual project conventions?

The referenced Qodo findings are addressed: finding 1 and finding 2 are both implemented, and there are no active Qodo findings remaining.

Yes—filing an issue for an external contributor would be worthwhile. The current TC-001/TC-002 guidance appears inconsistent with the repository’s established practice (testify in 156/216 test files, including many package-wide usages). The issue should include:

  • The repository usage figures and representative examples.
  • A proposal to either permit testify/assert and testify/require, or redefine the rules as guidance rather than a hard prohibition.
  • Clarification of whether new or modified tests should follow local package conventions instead of applying a repository-wide mandate.
  • A request to update or version the convention pack so stale guidance does not reject otherwise idiomatic project changes.

An external contributor could first confirm the intended policy with maintainers, then update the pack and add documentation/tests covering both stdlib-only and existing testify-based packages.

@sonupreetam
sonupreetam force-pushed the feat/opencode-stream-parser branch from b240ead to fa7fab8 Compare August 13, 2026 15:18

@waynesun09 waynesun09 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated locally at fa7fab8: build, gofmt -l, and the full runtime/cli test surface pass. All review findings are resolved — secret redaction on tool summaries and error messages (test-covered), reasoning tokens threaded end-to-end (TokensEvent/ResultEvent/RunMetrics/aggregation/renderer/OTEL), step_finish.reason now carried as Subtype, the --thinking and schema-drift constraints recorded in code comments for the wiring PR, and the reasoning JSON tag made consistent with its siblings in fa7fab8. Truncation ambiguity and InitEvent are explicitly documented as OpenCodeRuntime.Run() wiring responsibilities, which is the right scope for this parser-only step. LGTM.

Implement parseOpenCodeStream() that maps OpenCode's --format json
ndjson events (tool_use, text, reasoning, step_finish, step_start,
error) to the normalized AgentEvent types. This enables real-time
progress rendering and metrics capture for OpenCode agent runs.

Key design decisions:
- Returns sessionID from ndjson envelope (needed for opencode export)
- Synthesizes ResultEvent at EOF from accumulated step_finish data
- Only emits ToolUseEvent for terminal states (completed/error)
- Maps reasoning events to ThinkingEvent (OpenCode naming difference)
- Silently absorbs step_start events (no useful AgentEvent mapping)

Known gaps: no InitEvent.Model from stream (populated by caller from
RunParams.Model), no RetryEvent (OpenCode does not surface retries).

Refs: fullsend-ai#1260
Signed-off-by: sonupreetam <spreetam@redhat.com>
- Add secret redaction to tool summaries via shared progressRedactor,
  preventing credential leaks to terminal and CI annotations
- Track reasoning tokens through the full pipeline: TokensEvent,
  ResultEvent, RunMetrics, renderer, OTEL spans, and aggregation
- Map step_finish reason to ResultEvent.Subtype for parity with
  Claude parser subtype field
- Consolidate duplicate SecretRedactor instances (DRY)
- Capture ReasoningTokens in ClaudeRuntime.Run handler and root
  OTEL span
- Document truncated-stream false-success limitation
- Fix variable shadowing (errors -> errEvents)
- Add tests: secret redaction, clean passthrough, pending/running
  filtering, reasoning tokens in renderer/telemetry/aggregation

Signed-off-by: sonupreetam <spreetam@redhat.com>
- Redact error messages through progressRedactor to prevent secret
  leakage via ErrorEvent.Message and ResultEvent.ErrorMessage
- Document InitEvent caller responsibility (OpenCode wire format
  lacks model/version metadata)
- Document --thinking flag requirement for reasoning event emission
- Pin emitter source reference (anomalyco/opencode run.ts, pre-GA
  2026-08) for fixture drift detection
- Document silent-skip risk for unknown event types
- Extract shared streamBufSize constant (1 MiB) used by both
  Claude and OpenCode parsers

Signed-off-by: sonupreetam <spreetam@redhat.com>
Keep reasoning field consistent with sibling token fields (input,
output, cache_creation, cache_read) which all serialize zero values.
A zero is meaningful data ("no reasoning tokens used"), not absent
data.

Signed-off-by: sonupreetam <spreetam@redhat.com>
@sonupreetam
sonupreetam force-pushed the feat/opencode-stream-parser branch from fa7fab8 to 6d7f987 Compare August 13, 2026 15:59
@ralphbean
ralphbean enabled auto-merge August 13, 2026 17:27
@ralphbean
ralphbean added this pull request to the merge queue Aug 14, 2026
Merged via the queue into fullsend-ai:main with commit 682fd26 Aug 14, 2026
15 checks passed
@fullsend-ai-retro

fullsend-ai-retro Bot commented Aug 14, 2026

Copy link
Copy Markdown

🤖 Finished Retro · ✅ Success · Started 3:25 PM UTC · Completed 3:39 PM UTC

Commit: 6d7f987 · View workflow run →

@fullsend-ai-retro

Copy link
Copy Markdown

Retro: PR #6147refactor(runtime): add OpenCode ndjson stream parser

Outcome: Well-reviewed human-authored PR; one new proposal, three existing issues corroborated.

PR #6147 added parseOpenCodeStream() (+794/−16, 16 files) as the second step toward OpenCode runtime support (refs #1260). The author (sonupreetam, external contributor from Red Hat) opened the PR on 2026-08-12; it merged 2026-08-14 after 4 commits and thorough review.

What happened

  • No fullsend review agent ran. All 18 pre-merge fullsend.yaml dispatch runs matched "No stage matched" because the author lacks triage permission on the repo (ADR 0054 authorization gate). No maintainer triggered /fs-review or applied the ready-for-review label.
  • qodo-code-review[bot] posted 2 findings: tool summaries leaking secrets (HIGH) and reasoning tokens dropped from metrics (MEDIUM). Both real issues, both resolved.
  • waynesun09 ran a manual "review squad" (Claude x2 + Grok, cross-verified against the actual OpenCode emitter source) and posted 4 additional findings covering gofmt failures, truncated-stream false-success, --thinking flag undocumented, and pre-GA fixture provenance.
  • maruiz93 independently confirmed the security findings and added 3 more: error messages leaking secrets (HIGH), missing InitEvent (HIGH), and oversized lines silently skipped (LOW).
  • All 10 findings were resolved across 3 fix commits. Final patch coverage: 89.14%.

Review quality assessment

Human review was thorough and high-quality. The two reviewers independently identified a security surface (secret leakage) that qodo also caught, plus deeper architectural issues (InitEvent gap, stream truncation semantics, schema drift risk) that required domain knowledge of the OpenCode emitter. The manual review squad approach provided multi-model diversity.

Supporting evidence for existing issues

Proposals filed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants