From 21d3897f9ad0523cccfb6c8adff7e8287eedb0af Mon Sep 17 00:00:00 2001 From: Aditya Datta Date: Sun, 20 Sep 2026 09:19:03 +0530 Subject: [PATCH 1/2] fix(claude-code): preserve pending action on identity mismatch --- src/marginal/integrations/hookkit/session.py | 10 ++++++- tests/integrations/hookkit/test_session.py | 30 +++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) 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: From fd89dadabec1aaf2668452b9f691ae663241e9c1 Mon Sep 17 00:00:00 2001 From: Aditya Datta Date: Thu, 24 Sep 2026 20:28:37 +0530 Subject: [PATCH 2/2] fix(plugin): refresh Codex runtime artifact Signed-off-by: Aditya Datta --- plugins/marginal/runtime/marginal_runtime.pyz | Bin 750023 -> 750497 bytes plugins/marginal/runtime/provenance.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/marginal/runtime/marginal_runtime.pyz b/plugins/marginal/runtime/marginal_runtime.pyz index c8c17fc3b907d0c214e53dabe9d6ce5cd810f152..1433d811cab6edaf6c3203a378fd7ab0c123c624 100644 GIT binary patch delta 966 zcmYk2TSydP6vwx3b#&*`6tcW@;aej}*?cL5iZwB1g(4#|$-Ip_-|mdg&Wtm&!d8?I z3lWw{w?iaW(nAPcDGYiE3L-FyAfgB@ilQDOs2(cR&NLQMuZK1Y=Qd`q3%@YME2p;Y^$N$$pmMP z!kK9qreRAOGjuJ*q?p4k#{0OHVzwi7X`HDt*B#YKF(ibHpArHv!?AbtvPZVXXcf^Od*bOMAs0idTxx5UW?@B0h8VgEwMCXlAfsgk{ed<6&27Ieo76)RP8dXK zA{Bfgd`J^}iIB4CLnIO8gm4A*5JX{7gPtnf@f&0Q;PB&o?)QApxraNNu9^E> z6N?+;EN+Sy#0#hR+6K}3VB&67rNK~DIlZZYKQF>Y4=*sEjdbqtc~upCRR#9MLH^f> zp+2+aO1h0xte`McXt|h9w@FWVo)20Ssjz4VRtKa5v18nZH;Sxgg^Et@%ySFMySPL} zhqR&5FW0fRVg0PzFB_TRW}YzRmpfTcm&=zwbD<24$0V{YkSS?2Y;Z5wD78 zXJk1Bf;8-+f_p)d{kV>FQ0`@QMr;d7mnkVMn6+BgAQqw~))TlNBJ)?1SPID|_HPok zs%$ZRdt|}lV^R&Oq;h@2j4EAhEREkPdCp|8Crq9fR-!jd`JZBVqp}1GVOh^Em*~l? zLvU<}&~d_^BX+!t&`uDc2{%{dFvrV?bh6fJow&CP2Wrnz=R{qWxP%$udQpa94YxMu zaH;P2!Y%T*{GmPTeUz^I-7QvOceiGDB!?Xs)=1`*PEy^pg;}qjBz>Ytw>_jRv-NfA z0#%_&hR##{lwT+Apvd0s_Q0}|hgUU0DV_|7Vr>W;4n*nlBZC_Du)14FL}~H;BlsDm X6}y+=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"}