Files
cowork-local/tests/unit/test_conversation_turn_guards.py
T
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-08-31 05:15:13 +00:00

211 lines
7.7 KiB
Python

"""R04-T03 (b) — the turn loop: guards, permission gate, compaction, cleanup.
Split out of ``test_conversation_application_service.py`` to keep each file
inside the 400-LOC limit. Same fakes, same service; this half pins the ORDER of
the safety steps (guard before model, guard before execute, gate before execute)
and the promise that the output sandbox is tidied on the way out.
"""
from __future__ import annotations
from typing import Any, Dict, List
import pytest
from cowork_local.application.conversations.conversation_application_service import (
ConversationApplicationService,
)
from cowork_local.domain.agents.agent_event import (
ErrorEvent,
OutputsAddedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
)
from cowork_local.tests.fakes.turn_runtime_fakes import (
FakeModelCall,
FakeReply,
FakeToolRuntime,
events_of_type,
make_request,
run_turn,
tool_turn,
)
def _service(model, tools, **overrides) -> ConversationApplicationService:
return ConversationApplicationService(model, tools, **overrides)
# --------------------------------------------------------------------------- #
# Guards and the permission gate.
# --------------------------------------------------------------------------- #
def test_the_prompt_guard_runs_before_the_model_is_ever_called() -> None:
order: List[str] = []
model = FakeModelCall([FakeReply(content="ok")])
model_call = model.call
def call(*a, **kw):
order.append("model")
return model_call(*a, **kw)
model.call = call
run_turn(_service(model, FakeToolRuntime(), prompt_guard=lambda messages: order.append("guard")))
assert order == ["guard", "model"]
def test_a_blocked_prompt_propagates_before_the_output_folder_is_touched() -> None:
model = FakeModelCall([FakeReply(content="never")])
tools = FakeToolRuntime()
events: List[Any] = []
def guard(messages) -> None:
raise RuntimeError("SecurityBlocked: nope")
service = _service(model, tools, prompt_guard=guard)
with pytest.raises(RuntimeError, match="SecurityBlocked"):
service.execute(make_request(), events.append)
assert model.calls == []
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="SecurityBlocked: nope")]
# Cleanup is NOT a read-only operation (it deletes a stale .scratch and every
# empty sub-folder), so a turn rejected before it started must not run it.
assert tools.finalize_calls == []
def test_output_cleanup_still_runs_when_the_turn_fails_mid_loop() -> None:
# Once the turn has started producing files, the sandbox must be tidied on
# the way out no matter how the turn ends.
model = FakeModelCall([RuntimeError("gateway exploded")])
tools = FakeToolRuntime()
events: List[Any] = []
with pytest.raises(RuntimeError, match="gateway exploded"):
_service(model, tools).execute(make_request(), events.append)
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]
assert events_of_type(events, ErrorEvent) == [ErrorEvent(message="gateway exploded")]
def test_the_command_guard_runs_before_the_tool_executes() -> None:
order: List[str] = []
model, tools = tool_turn("run_command", {"command": "ls"})
original = tools.execute
def execute(name, args, on_output=None, cancel=None):
order.append("execute")
return original(name, args, on_output=on_output, cancel=cancel)
tools.execute = execute
run_turn(_service(model, tools,
command_guard=lambda name, args: order.append(f"guard:{name}")))
assert order == ["guard:run_command", "execute"]
def test_disabling_rule_enforcement_skips_both_guards() -> None:
# Co4E flow steps run inside the workspace sandbox and opt out on purpose.
calls: List[str] = []
model, tools = tool_turn("run_command", {"command": "ls"})
run_turn(_service(model, tools,
prompt_guard=lambda messages: calls.append("prompt"),
command_guard=lambda name, args: calls.append("command")),
make_request(enforce_rules=False))
assert calls == []
def test_the_permission_gate_is_asked_only_for_command_tools() -> None:
asked: List[str] = []
model, tools = tool_turn("save_file", {"filename": "a.md"})
run_turn(_service(model, tools,
permission_request=lambda action: asked.append(action["name"]) or True),
make_request(gate_mode="confirm"))
assert asked == [] # save_file writes into the sandbox: never gated
def test_a_command_tool_in_confirm_mode_asks_before_running() -> None:
asked: List[Dict[str, Any]] = []
model, tools = tool_turn("run_command", {"command": "ls"})
def approve(action: Dict[str, Any]) -> bool:
asked.append(action)
return True
run_turn(_service(model, tools, permission_request=approve), make_request(gate_mode="confirm"))
assert [a["name"] for a in asked] == ["run_command"]
assert tools.executed == [("run_command", {"command": "ls"})]
def test_a_rejected_command_is_reported_as_a_failed_tool_and_never_runs() -> None:
model, tools = tool_turn("run_command", {"command": "rm -rf /"})
result, events = run_turn(_service(model, tools, permission_request=lambda action: False),
make_request(gate_mode="confirm"))
assert tools.executed == []
assert events_of_type(events, ToolCallFinishedEvent) == [ToolCallFinishedEvent(
call_id="c1", name="run_command", ok=False, output="Rejected by user.")]
assert result.messages[-2]["content"] == "Rejected by user."
def test_auto_mode_never_asks_even_for_a_command() -> None:
model, tools = tool_turn("run_command", {"command": "ls"})
def refuse(action): # would block the turn if it were consulted
raise AssertionError("the gate must not be consulted in auto mode")
run_turn(_service(model, tools, permission_request=refuse), make_request(gate_mode="auto"))
assert tools.executed == [("run_command", {"command": "ls"})]
# --------------------------------------------------------------------------- #
# Context compaction, reasoning, output cleanup.
# --------------------------------------------------------------------------- #
def test_the_conversation_is_offered_for_compaction_before_every_call() -> None:
compactions: List[int] = []
model, tools = tool_turn()
run_turn(_service(model, tools,
compact=lambda messages, cancel: compactions.append(len(messages))))
assert len(compactions) == 2 # once per provider call
def test_reasoning_is_streamed_as_its_own_event() -> None:
model = FakeModelCall([FakeReply(content="42", reasoning="thinking...")])
_, events = run_turn(_service(model, FakeToolRuntime()))
assert events_of_type(events, ReasoningChunkEvent) == [ReasoningChunkEvent(delta="thinking...")]
def test_a_reasoning_only_reply_gets_a_visible_note_in_the_transcript() -> None:
# Otherwise a Schedule Task run reads back an empty answer and writes
# "(no output)" into its report.
model = FakeModelCall([FakeReply(content="", reasoning="thought hard")])
result, events = run_turn(_service(model, FakeToolRuntime()))
assert "only its reasoning" in events_of_type(events, TextChunkEvent)[-1].delta
assert "only its reasoning" in result.final_text
def test_promoted_and_discarded_output_files_are_reported_at_the_end() -> None:
model = FakeModelCall([FakeReply(content="ok")])
tools = FakeToolRuntime(added=("out/report.pptx",))
_, events = run_turn(_service(model, tools))
assert events_of_type(events, OutputsAddedEvent) == [
OutputsAddedEvent(paths=("out/report.pptx",))]
assert tools.finalize_calls == [{"before": "before", "cancelled": False}]