fix(plugin): serialize hooks and enrich tool/turn payloads#1
Open
geekgonecrazy wants to merge 1 commit into
Open
fix(plugin): serialize hooks and enrich tool/turn payloads#1geekgonecrazy wants to merge 1 commit into
geekgonecrazy wants to merge 1 commit into
Conversation
End-to-end recording was failing for several reasons that all surfaced as
"the agent ran a turn but no Atomic change appeared":
1. Concurrent hook calls clobbered session.json. opencode dispatches
`session.created` and `chat.message` as independent Promise chains, so our
per-handler `await hook(...)` calls didn't serialize across handlers — the
`session-start` and `user-prompt` atomic processes raced on session.json,
and last-writer-wins meant `first_prompt` was often null. Reproduced 1-of-5
times with a parallel pair on a clean session. Fixed by chaining all hook
invocations on a single shared promise queue inside the plugin.
2. Wrong turn-end verb. The plugin sent `stop` on `session.idle`, which atomic
doesn't recognize for OpenCode (`HookType::from_verb` only maps `stop` for
Claude Code; OpenCode's TurnEnd verb is `after-agent`). Result: turn never
closed, `turn_count` stayed at 0, no `record_turn` ever fired.
3. Spurious turn-end on bare session-idle. `session.idle` can fire before any
user prompt; we now gate on a `turnActive` flag that flips on `chat.message`
and back off when the after-agent fires.
4. Missing tool payload fields. `tool.execute.before/after` only sent
`tool_name`/`tool_call_id`. Atomic uses `tool_input` (filePath, content) to
attribute the change to a file and produce graph node summaries like
`Edit src/foo.tsx` instead of `Edit file`. Added `tool_input` to both, and
`tool_response` (title/output/metadata) to after-tool.
5. Wrong field name for duration. Plugin sent `duration`; atomic's
`AfterToolInput` expects `duration_ms`. Renamed.
Also added an explicit `"plugin": ["./plugins/atomic-hooks.ts"]` entry to the
shipped opencode.json. opencode auto-discovers plugins from
`~/.config/opencode/plugins/` today, so this is a no-op safety net documenting
intent — and protects us if auto-discovery behavior changes.
Verified end-to-end on a fresh probe project. A turn from prompt
`change to hello world 4`:
Before: phase=idle, turn_count=0, first_prompt=null, atomic log empty
After: phase=idle, turn_count=1, first_prompt="change to hello world 4",
atomic log shows new change with the prompt as message
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end recording was failing for several compounding reasons. After this PR, an opencode session reliably produces one Atomic change per turn with the actual user prompt as the change message, instead of either no change or an auto-generated
Add X.tsxsummary.What was broken
Race on
session.json. opencode dispatchessession.createdandchat.messageas independent Promise chains. Our per-handlerawait hook(...)only serialized within a single handler — thesession-startanduser-promptatomic processes ran in parallel, and atomic'sSessionStore.savehas no file lock. Last-writer-wins clobberedfirst_prompttonull. Reproduced ~1-in-5 with a parallel pair on a clean session.Wrong turn-end verb. Plugin sent
stoponsession.idle. atomic'sHookType::from_verbmapsstoponly for Claude Code; OpenCode's TurnEnd verb isafter-agent. Net effect: turn never closed,turn_countstayed at0,record_turnnever fired.Spurious turn-end gate.
session.idlecan fire before any user prompt. Without a guard, we'd sendafter-agentfor a turn that never started. Now gated on aturnActiveflag that flips onchat.messageand resets afterafter-agent.Missing tool payload fields.
tool.execute.before/afteronly forwardedtool_nameandtool_call_id. Atomic usestool_input(e.g.,filePath,content) to attribute changes and produce graph-node summaries likeEdit src/foo.tsxinstead of genericEdit file. Addedtool_inputto both, andtool_response(title/output/metadata) to after-tool.Wrong duration field. Plugin sent
duration; atomic'sAfterToolInputexpectsduration_ms. Renamed.Fix
Plus the verb/payload corrections noted above.
opencode.jsonalso gets an explicit"plugin": ["./plugins/atomic-hooks.ts"]entry. opencode auto-discovers plugins from~/.config/opencode/plugins/today, so this is a documenting safety net rather than a hard requirement.Companion fix in atomic CLI
The race manifested here because atomic's
SessionStore.saveis a non-atomic read-modify-write. The plugin queue patches the symptom for opencode; the underlying issue (other agents could race the same way) is being addressed separately. There's also an in-flight atomic CLI fix for untracked-file detection inrecord_turn(atomicdotdev/atomic#46) — that PR is required for new-file turns to actually record once the plugin starts feeding atomic correctly.Verification
Probe project, single turn from prompt
change to hello world 4:phaseturn_countfirst_promptnull"change to hello world 4"atomic logTest plan
~/.config/opencode/plugins/)current_promptcleared between turnsafter-agentEmptyTurnpath