CI / test (push) Canceled after 0s
## 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>
211 lines
7.7 KiB
Python
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}]
|