Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified plugins/marginal/runtime/marginal_runtime.pyz
Binary file not shown.
2 changes: 1 addition & 1 deletion plugins/marginal/runtime/provenance.json
Original file line number Diff line number Diff line change
@@ -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"}
10 changes: 9 additions & 1 deletion src/marginal/integrations/hookkit/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion tests/integrations/hookkit/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand All @@ -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,
)


Expand Down Expand Up @@ -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:
Expand Down
Loading