diff --git a/plugins/marginal/runtime/marginal_runtime.pyz b/plugins/marginal/runtime/marginal_runtime.pyz index c8c17fc..1433d81 100644 Binary files a/plugins/marginal/runtime/marginal_runtime.pyz and b/plugins/marginal/runtime/marginal_runtime.pyz differ diff --git a/plugins/marginal/runtime/provenance.json b/plugins/marginal/runtime/provenance.json index 3e53404..312d4ed 100644 --- a/plugins/marginal/runtime/provenance.json +++ b/plugins/marginal/runtime/provenance.json @@ -1 +1 @@ -{"builder":"scripts/build_codex_plugin.py","python_requires":">=3.10","schema_version":1,"sha256":"b39f7844b1284e2d7dde4223e1fd5f54d194afc39d8ff85c2b7c1c3f2ac8c0b2","source_hash":"033cdfa97274975b3814519313f82c3aee356dcd510ad3cf717b9fe4063ade22"} +{"builder":"scripts/build_codex_plugin.py","python_requires":">=3.10","schema_version":1,"sha256":"8522010f98080fa7b5e476e055941107893436ee79d58d36a16512189792722d","source_hash":"3d9a6c5e865f3f23f430344ab440359c3c32eeaa166319f9d9d327c11bfa4f7f"} diff --git a/src/marginal/integrations/hookkit/session.py b/src/marginal/integrations/hookkit/session.py index fa10189..2e7ae2c 100644 --- a/src/marginal/integrations/hookkit/session.py +++ b/src/marginal/integrations/hookkit/session.py @@ -102,7 +102,7 @@ def tool_call_end(self, end: ToolCallEnd) -> ActionOutcomeStatus: self._ensure_open() self._validate_session(end.session_id) - action = self._pending.pop(end.call_id, None) + action = self._pending.get(end.call_id) if action is None: # A completion without a recorded proposal means hook coverage was # incomplete for this call. Report it instead of settling an action @@ -113,6 +113,14 @@ def tool_call_end(self, end: ToolCallEnd) -> ActionOutcomeStatus: raise HookIntegrationError( f"completion tool identity does not match the proposal: {end.call_id}" ) + if str(action.metadata.get("turn_id", "")) != end.turn_id: + raise HookIntegrationError( + f"completion turn identity does not match the proposal: {end.call_id}" + ) + # Consume the proposal only after every stable identity dimension + # matches. Engines may enrich tool_input on completion, so call, tool, + # turn, and session identities are the compatible correlation boundary. + self._pending.pop(end.call_id) actual_cost = self._actual_cost(end) if end.outcome is ActionOutcomeStatus.SUCCESS: diff --git a/tests/integrations/hookkit/test_session.py b/tests/integrations/hookkit/test_session.py index a1c3a24..691b5f4 100644 --- a/tests/integrations/hookkit/test_session.py +++ b/tests/integrations/hookkit/test_session.py @@ -26,12 +26,15 @@ def _session(workspace: Path) -> HookSessionRuntime: return HookSessionRuntime(_runtime(), workspace=workspace) -def _start(call_id: str, tool: str = "Read", **arguments: object) -> ToolCallStart: +def _start( + call_id: str, tool: str = "Read", *, turn_id: str = "", **arguments: object +) -> ToolCallStart: return ToolCallStart( session_id=SESSION, call_id=call_id, tool_name=tool, tool_input=arguments or {"file_path": "/workspace/example.txt"}, + turn_id=turn_id, ) @@ -42,14 +45,18 @@ def _end( outcome: ActionOutcomeStatus = ActionOutcomeStatus.SUCCESS, evidence: object = None, duration_ms: float | None = None, + turn_id: str = "", + **arguments: object, ) -> ToolCallEnd: return ToolCallEnd( session_id=SESSION, call_id=call_id, tool_name=tool, outcome=outcome, + tool_input=arguments or {"file_path": "/workspace/example.txt"}, evidence=evidence if evidence is not None else {"content": "hello"}, duration_ms=duration_ms, + turn_id=turn_id, ) @@ -113,6 +120,27 @@ def test_a_completion_for_a_different_tool_is_rejected(tmp_path: Path) -> None: session.tool_call_start(_start("call-1", tool="Read")) with pytest.raises(HookIntegrationError): session.tool_call_end(_end("call-1", tool="Bash")) + assert session.pending_action_ids() == ("call-1",) + + +def test_a_cross_wired_completion_cannot_consume_another_action(tmp_path: Path) -> None: + session = _session(tmp_path) + session.tool_call_start(_start("call-1", turn_id="turn-a", file_path="a.txt")) + session.tool_call_start(_start("call-2", turn_id="turn-b", file_path="b.txt")) + + with pytest.raises(HookIntegrationError, match="turn identity"): + session.tool_call_end(_end("call-1", turn_id="turn-b", file_path="a.txt")) + + assert session.pending_action_ids() == ("call-1", "call-2") + assert ( + session.tool_call_end(_end("call-2", turn_id="turn-b", file_path="b.txt")) + is ActionOutcomeStatus.SUCCESS + ) + assert ( + session.tool_call_end(_end("call-1", turn_id="turn-a", file_path="a.txt")) + is ActionOutcomeStatus.SUCCESS + ) + assert session.summary()["successful_observations"] == 2 def test_a_completion_without_a_proposal_is_reported_not_settled(tmp_path: Path) -> None: