feat(events): context injection for opencode and JSON-envelope agent hooks - #3934
Merged
Conversation
…hooks Adds first-class context injection to agent runtime events: 1. opencode: maps session_start to experimental.chat.system.transform (injects into system prompt) and user_prompt_submit to chat.message (injects synthetic TextPart). TS plugin captures runEvent stdout (stdio pipe, encoding utf-8) and pushes into output objects. Part IDs derive from output.parts[last].id to preserve OpenCode's prt_ brand and prevent session schema crashes. 2. JSON-envelope hook wrapping: adds events_context_envelope to IntegrationBase so agents that require JSON on stdout receive their target envelope via the dispatcher's 5th argument: - gemini, tabnine, qwen, devin: hookSpecificOutput.additionalContext on session_start/user_prompt_submit; suppress on non-injectable events (prevents systemMessage user-facing noise) - copilot: top-level additionalContext on session_start - cursor: top-level additional_context on session_start; suppress elsewhere - claude, codex: plain stdout passthrough (already injected) 3. Dispatcher template and resolve_and_run_event_command parse the 5th envelope arg and wrap stdout accordingly. Tests added for opencode TextPart schema, part ID derivation, envelope command generation, and dispatcher output wrapping. All 162 events/integration tests pass.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds agent-specific context injection for runtime-event output.
Changes:
- Wraps dispatcher output in supported JSON envelopes.
- Injects OpenCode event output into system prompts and messages.
- Adds envelope and OpenCode generation tests.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/events.py |
Implements envelopes and OpenCode injection. |
src/specify_cli/integrations/base.py |
Defines envelope metadata. |
src/specify_cli/integrations/copilot/__init__.py |
Configures Copilot output. |
src/specify_cli/integrations/cursor_agent/__init__.py |
Configures Cursor output. |
src/specify_cli/integrations/devin/__init__.py |
Configures Devin output. |
src/specify_cli/integrations/gemini/__init__.py |
Configures Gemini output. |
src/specify_cli/integrations/opencode/__init__.py |
Maps injectable OpenCode hooks. |
src/specify_cli/integrations/qwen/__init__.py |
Configures Qwen output. |
src/specify_cli/integrations/tabnine/__init__.py |
Configures Tabnine output. |
tests/integrations/test_events.py |
Tests envelopes and generated plugins. |
Review details
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 10/10 changed files
- Comments generated: 3
- Review effort level: Balanced
mnriem
requested changes
Aug 3, 2026
mnriem
left a comment
Collaborator
There was a problem hiding this comment.
Please address Copilot feedback
- Qwen/Gemini/Tabnine/Devin: include native hookEventName inside hookSpecificOutput envelope (required by Qwen's hooks spec). Thread the native event name from the integration's CANONICAL_TO_NATIVE through _dispatcher_command as a 6th dispatcher argument, through the dispatcher template's main()/_run_inline()/_emit(), and through resolve_and_run_event_command()/_emit_event_stdout(). - Copilot: map user_prompt_submit to additionalContext (previously unmapped, breaking per-prompt context injection despite Copilot CLI supporting it via userPromptSubmitted). - OpenCode: guard experimental.chat.system.transform so canonical session_start handlers only run when input.sessionID is present — OpenCode fires this hook for non-session operations (e.g. agent generation) with no sessionID. Assisted-by: opencode (model: glm-5.2, supervised)
Contributor
There was a problem hiding this comment.
Review details
Suppressed comments (3)
src/specify_cli/integrations/opencode/init.py:31
session_startis a lifecycle event, but this native hook runs once per LLM request. Consequently every extension-declared session-start handler—including non-idempotent setup, telemetry, or file-mutating scripts—will execute repeatedly throughout a session. Re-injecting the context on each request is appropriate, but the generated plugin should execute these handlers once persessionID, cache their stdout, reuse it in each system transform, and evict it onsession.deleted.
"session_start": "experimental.chat.system.transform",
src/specify_cli/events.py:1151
- The envelope is the dispatcher's fifth positional argument, but when
timeout_secondsis omitted this appends it as argv[3]. The dispatcher then treatshookSpecificOutputas an invalid timeout and the native event as an invalid envelope, falling back to plain stdout. This already occurs in the new direct helper calls in the tests. Insert the dispatcher's default timeout before any envelope when no timeout was supplied.
envelope = _context_envelope_for(integration, event_name)
if envelope:
base += f" {_shell_quote(envelope, target_os)}"
src/specify_cli/integrations/copilot/init.py:139
- The PR description's envelope table says Copilot
user_prompt_submitremainsplain (unprocessed), while this mapping and the new tests wrap it asadditionalContext. The current Copilot behavior may be intentional, but the PR description should be updated so reviewers and release notes do not document the opposite protocol.
"user_prompt_submit": "additionalContext",
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
- Positional arg alignment: always emit default timeout (60s) as the 4th dispatcher argument even when timeout_seconds is omitted, so the envelope (5th) and native_event (6th) land in the correct argv slots. Previously, omitting timeout_seconds caused the envelope to be parsed as an invalid timeout, silently falling back to plain stdout. - OpenCode session_start caching: cache handler output per sessionID in the generated TS plugin so non-idempotent handlers (setup, telemetry, file-mutating scripts) run once per session instead of on every LLM request. Cache is evicted on session.deleted. - Updated PR description to reflect Copilot user_prompt_submit now maps to additionalContext (was documented as plain/unprocessed). Assisted-by: opencode (model: glm-5.2, supervised)
Contributor
There was a problem hiding this comment.
Review details
Suppressed comments (5)
src/specify_cli/events.py:1863
- The cache cleanup is not guaranteed: this branch is generated only when a
session_endhandler is configured, so a session-start-only plugin never listens forsession.deleted. It also readsevent.sessionID, while OpenCode events expose the ID asevent.properties.sessionID. In both cases cached entries survive deletion and the map grows for the plugin lifetime. Generate the deletion listener whenever the session-start cache is emitted, usingevent.properties.sessionID, while invoking the configured teardown handler only when present.
# Evict the sessionStartCache when the session is deleted so the
# cache doesn't grow unbounded across sessions.
eviction = ""
if native == "session.deleted":
eviction = "if (event.sessionID) sessionStartCache.delete(event.sessionID); "
src/specify_cli/integrations/gemini/init.py:44
- Gemini's
AfterToolcontract supportshookSpecificOutput.additionalContextand appends it to the tool result. Becausepost_tool_usefalls through to"suppress"here, every after-tool handler's stdout is discarded instead of reaching the model. Add an explicitpost_tool_useenvelope.
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
src/specify_cli/integrations/qwen/init.py:38
- Qwen documents
hookSpecificOutput.additionalContextforPostToolUse, but the wildcard sends canonicalpost_tool_useoutput to the suppress path. This silently drops valid after-tool context. Add an explicit envelope forpost_tool_use.
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
src/specify_cli/integrations/tabnine/init.py:41
- Tabnine's Gemini-compatible
AfterTooloutput supportshookSpecificOutput.additionalContext. The wildcard currently suppresses canonicalpost_tool_usestdout, so valid after-tool context never reaches the model. Add an explicitpost_tool_useenvelope.
events_context_envelope = {
"*": "suppress",
"session_start": "hookSpecificOutput",
"user_prompt_submit": "hookSpecificOutput",
}
src/specify_cli/integrations/opencode/init.py:30
- This comment contradicts the generated plugin: the transform fires every request, but
sessionStartCacheensures the handler runs only once per session and reuses its output. Describing a per-turn handler cost will mislead future changes; document the cache/re-injection behavior instead.
# fires per LLM request, which keeps the context present across
# compaction at the cost of running the handler per turn.
- Files reviewed: 10/10 changed files
- Comments generated: 0 new
- Review effort level: Balanced
mnriem
self-requested a review
August 4, 2026 12:32
mnriem
approved these changes
Aug 4, 2026
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.
🎯 Problem & Motivation
The agent-native runtime events layer (#3704) dispatched event scripts cleanly, but for several agents the script's
stdoutwas either discarded or turned into user-facing noise instead of reaching model context:runEventwithstdio: ['pipe', 'inherit', 'inherit']—stdoutwas inherited by the TUI console, not captured.session_startmapped tosession.created(a lifecycle event with no output channel), anduser_prompt_submitwas not mapped at all.gemini,tabnine,qwen,devin,copilot,cursor): these agents mandate JSON on stdout (hookSpecificOutput.additionalContext,additionalContext, oradditional_context). The dispatcher's plain-textstdouteither failed JSON parsing, was ignored, or (for Gemini/Tabnine) was rendered as user-facingsystemMessagenoise.This PR adds first-class context injection across all 9 event-capable agent integrations.
✨ Key Changes
1. opencode Context Injection
session_start: remapped fromsession.createdtoexperimental.chat.system.transform. Handlers' output is pushed intooutput.system(system-prompt injection, re-applied per LLM request so context survives compaction).experimental.chat.system.transformfires per LLM turn, but session-start handlers should run once per session. The generated plugin caches handler output bysessionIDand reuses it on subsequent turns; the cache is evicted onsession.deleted.experimental.chat.system.transformfor non-session operations (e.g. agent generation) with nosessionID. The plugin guards withif (!input.sessionID) return;so session-start handlers don't inject into internal prompts.user_prompt_submit: mapped tochat.message. Handlers' output is pushed intooutput.partsas a syntheticTextPart.Part.idmust start with OpenCode'sprtbrand; an invalid ID fails user-part schema validation and crashes the session. The generated plugin derivesbasefromoutput.parts[last].id(inheritingprteven if OpenCode changes prefixes), falling back to"prt_"+ ts36 + rand.runEventOutput Capture:stdioupdated to['pipe', 'pipe', 'inherit']withencoding: 'utf-8'sostdoutis captured and returned whilestderrremains inherited for error visibility.2. JSON-Envelope Dispatcher Wrapping
Adds
events_context_envelopemapping toIntegrationBaseand per-integration classes. The native hook command passes the target envelope as a 5th argument to.specify/events.py:session_startuser_prompt_submitclaude,codexstdout)gemini,tabninehookSpecificOutputhookSpecificOutputsuppress{"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}(suppresses non-injectable events to avoidsystemMessagenoise)qwen,devinhookSpecificOutputhookSpecificOutputsuppresscopilotadditionalContextadditionalContext{"additionalContext": ...}(top-level)cursoradditional_contextsuppresssuppress{"additional_context": ...}(top-level,beforeSubmitPrompthas no context field)opencodeThe dispatcher template (
_EVENTS_DISPATCHER_TEMPLATE) andresolve_and_run_event_commandparse the 5th argument and wrap non-emptystdoutin the requested JSON envelope before writing.3.
hookEventNameinhookSpecificOutputQwen's hooks spec marks
hookEventNameas mandatory insidehookSpecificOutput. The native event name (e.g."SessionStart","UserPromptSubmit") is threaded from the integration'sCANONICAL_TO_NATIVEthrough_dispatcher_command(6th arg) to the dispatcher and included in the JSON output. Applies to allhookSpecificOutputagents (Gemini, Tabnine, Qwen, Devin).4. Dispatcher Positional Arg Alignment
The dispatcher always receives the timeout as the 4th positional argument, even when the caller omits
timeout_seconds(defaults to 60s). This keeps the argv order (command event timeout envelope native_event) aligned so the envelope doesn't land in the timeout slot and silently fall back to plain stdout.🧪 Verification
uv run python -m pytest tests/integrations/test_events.py tests/integrations/test_integration_opencode.py -v(107 passed)uv run python -m pytest tests/test_agent_config_consistency.py -q(28 passed)plain,hookSpecificOutput,additionalContext,additional_context,suppress).