Skip to content

Commit 9997056

Browse files
authored
feat(events): context injection for opencode and JSON-envelope agent hooks (#3934)
* feat(events): context injection for opencode and JSON-envelope agent 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. * fix(events): address code review on #3934 - 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) * fix(events): address second Copilot review round on #3934 - 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)
1 parent e57a86c commit 9997056

10 files changed

Lines changed: 437 additions & 24 deletions

File tree

src/specify_cli/events.py

Lines changed: 230 additions & 21 deletions
Large diffs are not rendered by default.

src/specify_cli/integrations/base.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,20 @@ def supports_events(self) -> bool:
957957
"""Return True if this integration supports agent-native events."""
958958
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
959959

960+
# Context-injection envelope for hook stdout, keyed by canonical event
961+
# (with "*" as the fallback). Not every agent injects a hook's plain-text
962+
# stdout as model context: Gemini/Tabnine/Qwen/Devin are JSON-only
963+
# protocols (plain text becomes user-facing noise), Copilot discards
964+
# non-JSON stdout, and Cursor parses stdout as JSON. Values:
965+
# "hookSpecificOutput" → {"hookSpecificOutput": {"additionalContext": ...}}
966+
# "additionalContext" → {"additionalContext": ...} (top-level, Copilot)
967+
# "additional_context" → {"additional_context": ...} (top-level, Cursor)
968+
# "suppress" → emit nothing (strict-JSON agents on events whose
969+
# output can't be used)
970+
# Absent (no matching key and no "*") → plain stdout passthrough
971+
# (Claude/Codex inject plain stdout; opencode injects via its TS plugin).
972+
events_context_envelope: dict[str, str] = {}
973+
960974
# -- Convenience helpers for subclasses -------------------------------
961975

962976
def install(

src/specify_cli/integrations/copilot/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ class CopilotIntegration(IntegrationBase):
152152
}
153153
events_config_file = ".github/hooks/speckit.json"
154154
events_format = "copilot-json"
155+
# Copilot sessionStart and userPromptSubmitted inject a top-level
156+
# additionalContext field into the model-facing prompt (C13). Non-JSON
157+
# stdout is discarded harmlessly by Copilot on other events, so no other
158+
# event needs an envelope.
159+
events_context_envelope = {
160+
"session_start": "additionalContext",
161+
"user_prompt_submit": "additionalContext",
162+
}
155163

156164
# Mutable flag set by setup() — indicates the active scaffolding mode.
157165
_skills_mode: bool = True

src/specify_cli/integrations/cursor_agent/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ class CursorAgentIntegration(SkillsIntegration):
4848
}
4949
events_config_file = ".cursor/hooks.json"
5050
events_format = "json-flat"
51+
# Cursor sessionStart injects a top-level additional_context (snake_case)
52+
# field (C13). beforeSubmitPrompt has no context output field (block/allow
53+
# only), and plain text on any hook fails Cursor's JSON parse — suppress
54+
# everything else.
55+
events_context_envelope = {
56+
"*": "suppress",
57+
"session_start": "additional_context",
58+
}
5159

5260
def build_exec_args(
5361
self,

src/specify_cli/integrations/devin/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ class DevinIntegration(SkillsIntegration):
4444
# top-level "hooks" wrapper (U2), unlike the settings.json formats. The
4545
# json-root-nested writer/remover operate directly on the root event keys.
4646
events_format = "json-root-nested"
47+
# Devin's hooks protocol is JSON-stdout; additionalContext is the
48+
# documented injection field for SessionStart/UserPromptSubmit (C13).
49+
events_context_envelope = {
50+
"*": "suppress",
51+
"session_start": "hookSpecificOutput",
52+
"user_prompt_submit": "hookSpecificOutput",
53+
}
4754

4855
def build_exec_args(
4956
self,

src/specify_cli/integrations/gemini/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ class GeminiIntegration(TomlIntegration):
3333
}
3434
events_config_file = ".gemini/settings.json"
3535
events_format = "json-nested"
36+
# Gemini mandates JSON-only hook stdout ("silence is mandatory"): plain
37+
# text becomes a user-facing systemMessage, never context. Inject via
38+
# hookSpecificOutput.additionalContext on the two context events and
39+
# suppress stdout everywhere else (C13).
40+
events_context_envelope = {
41+
"*": "suppress",
42+
"session_start": "hookSpecificOutput",
43+
"user_prompt_submit": "hookSpecificOutput",
44+
}
3645
# Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
3746
# which use seconds. The shared formatter converts via _native_timeout (#7)
3847
# so the default 60s becomes 60000ms instead of terminating the dispatcher

src/specify_cli/integrations/opencode/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,15 @@ class OpencodeIntegration(MarkdownIntegration):
2323
CANONICAL_TO_NATIVE = {
2424
"pre_tool_use": "tool.execute.before",
2525
"post_tool_use": "tool.execute.after",
26-
"session_start": "session.created",
26+
# session_start maps to the system-prompt transform hook (not the
27+
# session.created event) so the handler's stdout is injected into the
28+
# system prompt — session.created has no output channel. The hook
29+
# fires per LLM request, which keeps the context present across
30+
# compaction at the cost of running the handler per turn.
31+
"session_start": "experimental.chat.system.transform",
32+
# user_prompt_submit maps to chat.message so handler stdout is
33+
# injected as a synthetic text part on the user's message.
34+
"user_prompt_submit": "chat.message",
2735
"session_end": "session.deleted",
2836
}
2937
events_config_file = "opencode.json"

src/specify_cli/integrations/qwen/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ class QwenIntegration(MarkdownIntegration):
3030
}
3131
events_config_file = ".qwen/settings.json"
3232
events_format = "json-nested"
33+
# Qwen hooks are a JSON stdin/stdout protocol (Gemini-derived) (C13).
34+
events_context_envelope = {
35+
"*": "suppress",
36+
"session_start": "hookSpecificOutput",
37+
"user_prompt_submit": "hookSpecificOutput",
38+
}
3339
# Qwen Code's command hooks measure timeout in milliseconds (default
3440
# 60000), per the Qwen Code hooks documentation. Declaring the unit makes
3541
# the shared formatter convert the 60s default to 60000ms instead of

src/specify_cli/integrations/tabnine/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ class TabnineIntegration(TomlIntegration):
3333
}
3434
events_config_file = ".tabnine/agent/settings.json"
3535
events_format = "json-nested"
36+
# Tabnine is Gemini-hooks-compatible (JSON-only stdout) (C13).
37+
events_context_envelope = {
38+
"*": "suppress",
39+
"session_start": "hookSpecificOutput",
40+
"user_prompt_submit": "hookSpecificOutput",
41+
}
3642
# Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
3743
# Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
3844
# the shared formatter convert the 60s default to 60000ms instead of

tests/integrations/test_events.py

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ def test_opencode_limited(self):
251251
integration = OpencodeIntegration()
252252
assert integration.supports_events() is True
253253
assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "tool.execute.before"
254+
assert integration.CANONICAL_TO_NATIVE["session_start"] == "experimental.chat.system.transform"
255+
assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "chat.message"
254256
assert "stop" not in integration.CANONICAL_TO_NATIVE
255257

256258
def test_copilot_mapping(self):
@@ -671,6 +673,104 @@ def test_copilot_stop_mapping(self):
671673

672674
# -- Shell quoting & matcher escaping (R2, R4) -------------------------------
673675

676+
class TestContextInjectionEnvelopes:
677+
"""C13: context-injection envelope resolution and emission."""
678+
679+
def test_emit_event_stdout_wrapping(self, capsys):
680+
from specify_cli.events import _emit_event_stdout
681+
682+
_emit_event_stdout("hello ctx", "plain")
683+
assert capsys.readouterr().out == "hello ctx"
684+
685+
# hookSpecificOutput without native_event: no hookEventName (backward
686+
# compat for callers that don't pass it).
687+
_emit_event_stdout("hello ctx", "hookSpecificOutput")
688+
assert json.loads(capsys.readouterr().out.strip()) == {
689+
"hookSpecificOutput": {"additionalContext": "hello ctx"}
690+
}
691+
692+
# hookSpecificOutput with native_event: hookEventName included
693+
# (required by Qwen's hooks spec; derived from Claude Code's).
694+
_emit_event_stdout("hello ctx", "hookSpecificOutput", "SessionStart")
695+
assert json.loads(capsys.readouterr().out.strip()) == {
696+
"hookSpecificOutput": {
697+
"additionalContext": "hello ctx",
698+
"hookEventName": "SessionStart",
699+
}
700+
}
701+
702+
_emit_event_stdout("hello ctx", "additionalContext")
703+
assert json.loads(capsys.readouterr().out.strip()) == {
704+
"additionalContext": "hello ctx"
705+
}
706+
707+
_emit_event_stdout("hello ctx", "additional_context")
708+
assert json.loads(capsys.readouterr().out.strip()) == {
709+
"additional_context": "hello ctx"
710+
}
711+
712+
_emit_event_stdout("hello ctx", "suppress")
713+
assert capsys.readouterr().out == ""
714+
715+
# Empty output emits nothing under any envelope.
716+
_emit_event_stdout("", "additionalContext")
717+
assert capsys.readouterr().out == ""
718+
719+
def test_envelope_resolution_and_command_formatting(self):
720+
from specify_cli.events import _dispatcher_command, _context_envelope_for
721+
from specify_cli.integrations.gemini import GeminiIntegration
722+
from specify_cli.integrations.qwen import QwenIntegration
723+
from specify_cli.integrations.copilot import CopilotIntegration
724+
from specify_cli.integrations.cursor_agent import CursorAgentIntegration
725+
from specify_cli.integrations.claude import ClaudeIntegration
726+
from specify_cli.integrations.codex import CodexIntegration
727+
728+
gemini = GeminiIntegration()
729+
assert _context_envelope_for(gemini, "session_start") == "hookSpecificOutput"
730+
assert _context_envelope_for(gemini, "user_prompt_submit") == "hookSpecificOutput"
731+
assert _context_envelope_for(gemini, "pre_tool_use") == "suppress"
732+
733+
# hookSpecificOutput appends the native event name as a 6th dispatcher
734+
# argument so the dispatcher can populate hookEventName. The default
735+
# timeout (60s) is always emitted as the 4th arg to keep positional
736+
# alignment (R3).
737+
cmd_gemini_start = _dispatcher_command(gemini, Path("/proj"), "speckit.boot", "session_start")
738+
assert cmd_gemini_start.endswith(" 60 hookSpecificOutput SessionStart")
739+
740+
cmd_gemini_prompt = _dispatcher_command(gemini, Path("/proj"), "speckit.prompt", "user_prompt_submit")
741+
assert cmd_gemini_prompt.endswith(" 60 hookSpecificOutput BeforeAgent")
742+
743+
cmd_gemini_tool = _dispatcher_command(gemini, Path("/proj"), "speckit.guard", "pre_tool_use")
744+
assert cmd_gemini_tool.endswith(" 60 suppress")
745+
746+
# Qwen uses the same hookSpecificOutput protocol with its own native
747+
# event names; verify hookEventName threading for Qwen's CamelCase names.
748+
qwen = QwenIntegration()
749+
cmd_qwen_start = _dispatcher_command(qwen, Path("/proj"), "speckit.boot", "session_start")
750+
assert cmd_qwen_start.endswith(" 60 hookSpecificOutput SessionStart")
751+
cmd_qwen_prompt = _dispatcher_command(qwen, Path("/proj"), "speckit.prompt", "user_prompt_submit")
752+
assert cmd_qwen_prompt.endswith(" 60 hookSpecificOutput UserPromptSubmit")
753+
754+
copilot = CopilotIntegration()
755+
assert _context_envelope_for(copilot, "session_start") == "additionalContext"
756+
assert _context_envelope_for(copilot, "user_prompt_submit") == "additionalContext"
757+
cmd_copilot_start = _dispatcher_command(copilot, Path("/proj"), "speckit.boot", "session_start")
758+
assert cmd_copilot_start.endswith(" 60 additionalContext")
759+
cmd_copilot_prompt = _dispatcher_command(copilot, Path("/proj"), "speckit.prompt", "user_prompt_submit")
760+
assert cmd_copilot_prompt.endswith(" 60 additionalContext")
761+
762+
cursor = CursorAgentIntegration()
763+
assert _context_envelope_for(cursor, "session_start") == "additional_context"
764+
assert _context_envelope_for(cursor, "user_prompt_submit") == "suppress"
765+
cmd_cursor_start = _dispatcher_command(cursor, Path("/proj"), "speckit.boot", "session_start")
766+
assert cmd_cursor_start.endswith(" 60 additional_context")
767+
768+
claude = ClaudeIntegration()
769+
codex = CodexIntegration()
770+
assert _context_envelope_for(claude, "session_start") is None
771+
assert _context_envelope_for(codex, "session_start") is None
772+
773+
674774
class TestDispatcherCommandQuoting:
675775
"""R2: dispatcher command components are shell-quoted so spaces and shell
676776
metacharacters are passed as single arguments, not reinterpreted."""
@@ -783,6 +883,7 @@ def test_opencode_ts_plugin_generation(self, tmp_path):
783883
events = {
784884
"pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}],
785885
"session_start": [{"command": "speckit.agent-context.update"}],
886+
"session_end": [{"command": "speckit.agent-context.teardown"}],
786887
}
787888
install_integration_events(integration, tmp_path, manifest, events)
788889

@@ -791,13 +892,50 @@ def test_opencode_ts_plugin_generation(self, tmp_path):
791892
content = plugin_path.read_text()
792893
assert "runEvent" in content
793894
assert "tool.execute.before" in content
794-
assert "session.created" in content
895+
assert "experimental.chat.system.transform" in content
795896
assert "speckit.tdd.validate" in content
796897
assert "speckit.agent-context.update" in content
797898
# #13: failures must propagate via throw, not process.exit(2) which
798899
# would kill the OpenCode host process.
799900
assert "process.exit(2)" not in content
800901
assert "throw new Error" in content
902+
# session_start (experimental.chat.system.transform) must be guarded
903+
# so canonical session-start handlers only run when a session is
904+
# present — OpenCode fires this hook for non-session operations
905+
# (e.g. agent generation) with no sessionID.
906+
assert "if (!input.sessionID) return;" in content
907+
# session_start handler output is cached per sessionID so non-idempotent
908+
# handlers run once per session instead of on every LLM request.
909+
assert "sessionStartCache" in content
910+
assert "sessionStartCache.get(input.sessionID)" in content
911+
assert "sessionStartCache.set(input.sessionID" in content
912+
# Cache is evicted on session.deleted (session_end).
913+
assert "sessionStartCache.delete(event.sessionID)" in content
914+
915+
def test_opencode_ts_plugin_chat_message_part_injection(self, tmp_path):
916+
"""user_prompt_submit emits chat.message pushing a synthetic TextPart.
917+
The part ID derives from output.parts[last].id (prt_ brand preserved)
918+
with a prt_ fallback to prevent OpenCode session schema crashes."""
919+
integration = OpencodeIntegration()
920+
manifest = MagicMock(spec=IntegrationManifest)
921+
manifest.files = {}
922+
manifest.record_file = MagicMock()
923+
manifest.record_existing = MagicMock()
924+
925+
events = {
926+
"user_prompt_submit": [{"command": "speckit.discover"}],
927+
}
928+
install_integration_events(integration, tmp_path, manifest, events)
929+
930+
plugin_path = tmp_path / ".opencode/plugin/speckit-events.ts"
931+
assert plugin_path.is_file()
932+
content = plugin_path.read_text()
933+
assert "chat.message" in content
934+
assert "output.parts.push" in content
935+
assert "synthetic: true" in content
936+
assert 'type: "text"' in content
937+
assert "output.parts[output.parts.length - 1]?.id" in content
938+
assert '?? "prt_"' in content
801939

802940
def test_opencode_ts_plugin_resolves_interpreter_and_directory_at_load(self, tmp_path):
803941
"""C8/C9: the dispatcher + interpreter are resolved per-project at
@@ -1126,7 +1264,7 @@ def test_dispatcher_is_self_contained(self, tmp_path):
11261264
content = (tmp_path / EVENTS_DISPATCHER_REL).read_text()
11271265
# Delegates to specify_cli when importable.
11281266
assert "from specify_cli.events import resolve_and_run_event_command" in content
1129-
assert "except ImportError" in content
1267+
assert "except (ImportError, TypeError):" in content
11301268
# Inline stdlib fallback resolver for one-time/temporary installs.
11311269
assert "_run_inline" in content
11321270
assert "_find_command_template" in content

0 commit comments

Comments
 (0)