Skip to content

feat(events): context injection for opencode and JSON-envelope agent hooks - #3934

Merged
mnriem merged 3 commits into
github:mainfrom
tikalk:feat/events-context-injection
Aug 4, 2026
Merged

feat(events): context injection for opencode and JSON-envelope agent hooks#3934
mnriem merged 3 commits into
github:mainfrom
tikalk:feat/events-context-injection

Conversation

@kanfil

@kanfil kanfil commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

🎯 Problem & Motivation

The agent-native runtime events layer (#3704) dispatched event scripts cleanly, but for several agents the script's stdout was either discarded or turned into user-facing noise instead of reaching model context:

  1. opencode: the generated TS plugin called runEvent with stdio: ['pipe', 'inherit', 'inherit']stdout was inherited by the TUI console, not captured. session_start mapped to session.created (a lifecycle event with no output channel), and user_prompt_submit was not mapped at all.
  2. JSON-protocol agents (gemini, tabnine, qwen, devin, copilot, cursor): these agents mandate JSON on stdout (hookSpecificOutput.additionalContext, additionalContext, or additional_context). The dispatcher's plain-text stdout either failed JSON parsing, was ignored, or (for Gemini/Tabnine) was rendered as user-facing systemMessage noise.

This PR adds first-class context injection across all 9 event-capable agent integrations.


✨ Key Changes

1. opencode Context Injection

  • session_start: remapped from session.created to experimental.chat.system.transform. Handlers' output is pushed into output.system (system-prompt injection, re-applied per LLM request so context survives compaction).
  • Per-sessionID caching: experimental.chat.system.transform fires per LLM turn, but session-start handlers should run once per session. The generated plugin caches handler output by sessionID and reuses it on subsequent turns; the cache is evicted on session.deleted.
  • Non-session guard: OpenCode fires experimental.chat.system.transform for non-session operations (e.g. agent generation) with no sessionID. The plugin guards with if (!input.sessionID) return; so session-start handlers don't inject into internal prompts.
  • user_prompt_submit: mapped to chat.message. Handlers' output is pushed into output.parts as a synthetic TextPart.
  • OpenCode Part ID Brand Compliance: Part.id must start with OpenCode's prt brand; an invalid ID fails user-part schema validation and crashes the session. The generated plugin derives base from output.parts[last].id (inheriting prt even if OpenCode changes prefixes), falling back to "prt_" + ts36 + rand.
  • runEvent Output Capture: stdio updated to ['pipe', 'pipe', 'inherit'] with encoding: 'utf-8' so stdout is captured and returned while stderr remains inherited for error visibility.

2. JSON-Envelope Dispatcher Wrapping

Adds events_context_envelope mapping to IntegrationBase and per-integration classes. The native hook command passes the target envelope as a 5th argument to .specify/events.py:

Integration session_start user_prompt_submit Other events Envelope Shape
claude, codex plain plain plain Passthrough (already inject plain stdout)
gemini, tabnine hookSpecificOutput hookSpecificOutput suppress {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}} (suppresses non-injectable events to avoid systemMessage noise)
qwen, devin hookSpecificOutput hookSpecificOutput suppress same
copilot additionalContext additionalContext plain {"additionalContext": ...} (top-level)
cursor additional_context suppress suppress {"additional_context": ...} (top-level, beforeSubmitPrompt has no context field)
opencode TS plugin TS plugin TS plugin Plugin handles injection directly

The dispatcher template (_EVENTS_DISPATCHER_TEMPLATE) and resolve_and_run_event_command parse the 5th argument and wrap non-empty stdout in the requested JSON envelope before writing.

3. hookEventName in hookSpecificOutput

Qwen's hooks spec marks hookEventName as mandatory inside hookSpecificOutput. The native event name (e.g. "SessionStart", "UserPromptSubmit") is threaded from the integration's CANONICAL_TO_NATIVE through _dispatcher_command (6th arg) to the dispatcher and included in the JSON output. Applies to all hookSpecificOutput agents (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)
  • Smoke-tested generated dispatcher script template end-to-end with all envelope options (plain, hookSpecificOutput, additionalContext, additional_context, suppress).
  • Smoke-tested generated opencode TS plugin code output.

…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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread src/specify_cli/integrations/qwen/__init__.py
Comment thread src/specify_cli/integrations/copilot/__init__.py
Comment thread src/specify_cli/events.py

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review details

Suppressed comments (3)

src/specify_cli/integrations/opencode/init.py:31

  • session_start is 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 per sessionID, cache their stdout, reuse it in each system transform, and evict it on session.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_seconds is omitted this appends it as argv[3]. The dispatcher then treats hookSpecificOutput as 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_submit remains plain (unprocessed), while this mapping and the new tests wrap it as additionalContext. 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)
@mnriem
mnriem requested a balanced review from Copilot August 4, 2026 11:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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_end handler is configured, so a session-start-only plugin never listens for session.deleted. It also reads event.sessionID, while OpenCode events expose the ID as event.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, using event.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 AfterTool contract supports hookSpecificOutput.additionalContext and appends it to the tool result. Because post_tool_use falls through to "suppress" here, every after-tool handler's stdout is discarded instead of reaching the model. Add an explicit post_tool_use envelope.
    events_context_envelope = {
        "*": "suppress",
        "session_start": "hookSpecificOutput",
        "user_prompt_submit": "hookSpecificOutput",
    }

src/specify_cli/integrations/qwen/init.py:38

  • Qwen documents hookSpecificOutput.additionalContext for PostToolUse, but the wildcard sends canonical post_tool_use output to the suppress path. This silently drops valid after-tool context. Add an explicit envelope for post_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 AfterTool output supports hookSpecificOutput.additionalContext. The wildcard currently suppresses canonical post_tool_use stdout, so valid after-tool context never reaches the model. Add an explicit post_tool_use envelope.
    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 sessionStartCache ensures 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
mnriem self-requested a review August 4, 2026 12:32
@mnriem
mnriem merged commit 9997056 into github:main Aug 4, 2026
14 checks passed
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.

3 participants