mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 13:27:11 +00:00
Keep task evidence separate from worker transcripts and reuse the composer for worker conversations.
327 lines
11 KiB
Python
327 lines
11 KiB
Python
"""Team View contracts: status ownership, quiet delivery, projections and timing."""
|
|
|
|
import asyncio
|
|
import json
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from coworker.teams import Actor, AuthorityError, BoardError, Role, TeamStore
|
|
from coworker.teams.store import ITEM_STATUS, WORKER_WAITING
|
|
from coworker.teams.summary import (
|
|
all_events,
|
|
breakdown,
|
|
waiting_items,
|
|
tokens,
|
|
make_summary,
|
|
)
|
|
from coworker.teams.tools import board_tools, with_mention
|
|
from coworker.teams.registry import TeamRegistry, TeamWorker
|
|
from coworker.inbox import InboxStore
|
|
from coworker.sessions import SessionRecord
|
|
from coworker.server.manager import SessionManager
|
|
|
|
LEAD = Actor(id="lead", role=Role.LEAD)
|
|
WORKER = Actor(id="sam", role=Role.WORKER)
|
|
OTHER = Actor(id="maya", role=Role.WORKER)
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
store = TeamStore(tmp_path / "teams.db")
|
|
yield store
|
|
store.close()
|
|
|
|
|
|
def assigned(store):
|
|
item = store.create_item(
|
|
"acme", LEAD, title="Invoice PDF", criteria="Download verified"
|
|
)
|
|
return store.assign("acme", LEAD, item["id"], WORKER.id)
|
|
|
|
|
|
def test_status_is_owned_and_does_not_change_work_state(store):
|
|
item = assigned(store)
|
|
updated = store.set_status("acme", WORKER, item["id"], "Testing 18 of 24 cases")
|
|
assert updated["state"] == item["state"]
|
|
assert updated["updated_seq"] == item["updated_seq"]
|
|
assert updated["status"] == "Testing 18 of 24 cases"
|
|
with pytest.raises(BoardError):
|
|
store.set_status("acme", OTHER, item["id"], "Wrong item")
|
|
with pytest.raises(AuthorityError):
|
|
store.set_status("acme", LEAD, item["id"], "Wrong role")
|
|
|
|
|
|
def test_reassignment_revokes_status_and_clears_old_line(store):
|
|
item = assigned(store)
|
|
store.set_status("acme", WORKER, item["id"], "Testing")
|
|
moved = store.assign("acme", LEAD, item["id"], OTHER.id)
|
|
assert moved["status"] == ""
|
|
with pytest.raises(BoardError):
|
|
store.set_status("acme", WORKER, item["id"], "Still testing")
|
|
assert store.set_status("acme", OTHER, item["id"], "Reading hand-off")["status"]
|
|
|
|
|
|
@pytest.mark.parametrize("text", ["x" * 81, "two\nlines", "two\rlines", None])
|
|
def test_status_validation(store, text):
|
|
item = assigned(store)
|
|
with pytest.raises(BoardError):
|
|
store.set_status("acme", WORKER, item["id"], text)
|
|
|
|
|
|
def test_status_boundary_empty_and_replay(store):
|
|
item = assigned(store)
|
|
store.set_status("acme", WORKER, item["id"], "x" * 80)
|
|
store.rebuild("acme")
|
|
assert store.get_item("acme", item["id"], actor=LEAD)["status"] == "x" * 80
|
|
assert store.set_status("acme", WORKER, item["id"], "")["status"] == ""
|
|
|
|
|
|
def test_status_is_excluded_before_feed_pagination(store):
|
|
item = assigned(store)
|
|
store.consume_feed("acme", LEAD.id, 2)
|
|
store.consume_subscription("acme", LEAD.id, 2)
|
|
for n in range(205):
|
|
store.set_status("acme", WORKER, item["id"], f"Testing step {n}")
|
|
assert not store.feed_for("acme", LEAD.id)
|
|
assert not store.subscribed_events("acme", LEAD.id)
|
|
store.transition("acme", WORKER, item["id"], "in_progress")
|
|
store.transition("acme", WORKER, item["id"], "review")
|
|
assert store.subscribed_events("acme", LEAD.id)[0]["payload"]["to"] == "review"
|
|
assert all(e["kind"] != ITEM_STATUS for e in store.feed_for("acme", "observer"))
|
|
|
|
|
|
def test_worker_with_multiple_items_sets_the_explicit_one(store):
|
|
first, second = assigned(store), assigned(store)
|
|
store.set_status("acme", WORKER, second["id"], "Testing the second task")
|
|
assert store.get_item("acme", first["id"], actor=LEAD)["status"] == ""
|
|
|
|
|
|
def test_mention_escapes_markdown_and_does_not_mutate():
|
|
item = {"id": 2, "title": "[x](https://example.com) *bold*\nnext"}
|
|
mention = with_mention(item)
|
|
assert mention["mention"].endswith("](task:2)")
|
|
assert "\\[x\\]" in mention["mention"]
|
|
assert "mention" not in item
|
|
|
|
|
|
def test_role_filtered_tool_surface(store):
|
|
assert "set_status" in {
|
|
t.__name__ for t in board_tools(store, space="acme", actor=WORKER)
|
|
}
|
|
assert "set_status" not in {
|
|
t.__name__ for t in board_tools(store, space="acme", actor=LEAD)
|
|
}
|
|
|
|
|
|
def test_waiting_requires_a_current_pending_prompt():
|
|
inbox = InboxStore()
|
|
p = inbox.add_approval("worker-session", "Run tests")
|
|
events = [
|
|
{
|
|
"kind": WORKER_WAITING,
|
|
"item_id": 2,
|
|
"actor": "sam",
|
|
"payload": {
|
|
"prompt_id": p.id,
|
|
"session_id": "worker-session",
|
|
"tool": "run_shell",
|
|
},
|
|
}
|
|
]
|
|
items = [{"id": 2, "assignee": "sam", "state": "in_progress"}]
|
|
assert 2 in waiting_items(events, inbox, items)
|
|
inbox.resolve(p.id, "allow")
|
|
assert not waiting_items(events, inbox, items)
|
|
|
|
|
|
def test_parallel_time_is_wall_clock_union():
|
|
result = breakdown(
|
|
[(0, 5, "tool_ms"), (2, 8, "tool_ms"), (8, 9, "model_ms")], 0, 10
|
|
)
|
|
assert result == dict(model_ms=1000, tool_ms=8000, waited_ms=0, queued_ms=1000)
|
|
assert sum(result.values()) == 10000
|
|
|
|
|
|
def test_waiting_has_priority_and_intervals_are_clipped():
|
|
result = breakdown(
|
|
[(-5, 20, "model_ms"), (3, 6, "waited_ms"), (2, 7, "tool_ms")], 0, 10
|
|
)
|
|
assert result == dict(model_ms=5000, tool_ms=2000, waited_ms=3000, queued_ms=0)
|
|
|
|
|
|
def test_usage_is_normalized_defensively():
|
|
assert tokens(
|
|
dict(input=-1, output="12", cache_read=None, cache_write="bad")
|
|
) == dict(input=0, output=12, cache_read=0, cache_write=0)
|
|
|
|
|
|
def test_event_reader_paginates(store):
|
|
for _ in range(2005):
|
|
store.append_event("acme", ITEM_STATUS, WORKER, payload={"text": ""})
|
|
assert len(list(all_events(store, "acme"))) == 2005
|
|
|
|
|
|
def test_real_manager_summary_and_current_wait(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
|
|
manager = SessionManager(workspace=tmp_path / "repo", data_dir=tmp_path / "data")
|
|
manager.session_store.save(
|
|
SessionRecord(
|
|
session_id="lead-session",
|
|
workspace=str(tmp_path / "repo"),
|
|
model="sample",
|
|
mode="interactive",
|
|
agent="swe-lead",
|
|
)
|
|
)
|
|
team = manager.teams.create(
|
|
space=manager._board_space("lead-session"),
|
|
lead_session="lead-session",
|
|
lead_actor="lead",
|
|
workers=[
|
|
TeamWorker(actor="sam", persona="swe-worker", session_id="worker-session")
|
|
],
|
|
)
|
|
item = manager.team_store.create_item(
|
|
team.space, LEAD, title="Invoice PDF", criteria="Verified"
|
|
)
|
|
manager.team_store.assign(team.space, LEAD, item["id"], WORKER.id)
|
|
manager.team_store.transition(team.space, WORKER, item["id"], "in_progress")
|
|
manager.team_store.set_status(team.space, WORKER, item["id"], "Running tests")
|
|
manager.session_store.save(
|
|
SessionRecord(
|
|
session_id="worker-session",
|
|
workspace=str(tmp_path / "repo"),
|
|
model="sample",
|
|
mode="interactive",
|
|
agent="swe-worker",
|
|
messages=[
|
|
{
|
|
"role": "assistant",
|
|
"content": "Testing",
|
|
"ts": 10,
|
|
"usage": {
|
|
"input": 10,
|
|
"cache_read": 20,
|
|
"output": 5,
|
|
"cache_write": 2,
|
|
},
|
|
}
|
|
],
|
|
)
|
|
)
|
|
summary = manager.team_summary(team.team_id)
|
|
assert summary["items"][0]["status"] == "Running tests"
|
|
assert summary["totals"]["tokens"] == 37
|
|
assert summary["counts"]["working"] == 1
|
|
assert summary["items"][0]["timing"] is None
|
|
assert manager.team_summary("missing") == {"error": "team not found"}
|
|
asyncio.run(manager.aclose())
|
|
|
|
|
|
def test_engine_records_and_strips_timing(tmp_path):
|
|
from test_engine import _engine, _tool_turn, _text_turn, _collect
|
|
|
|
(tmp_path / "sample.txt").write_text("sample")
|
|
engine, _ = _engine(
|
|
tmp_path, [_tool_turn("read_file", {"path": "sample.txt"}), _text_turn("Read")]
|
|
)
|
|
_collect(engine, "read")
|
|
model = next(m for m in engine.messages if m["role"] == "assistant")
|
|
tool = next(m for m in engine.messages if m["role"] == "tool")
|
|
assert model["timing"]["model_ms"] >= 0
|
|
assert tool["timing"]["tool_ms"] >= 0
|
|
assert all("timing" not in m for m in engine._outbound_messages())
|
|
|
|
|
|
def test_call_after_review_is_not_attributed_to_the_previous_item():
|
|
events = [
|
|
{"seq": 1, "ts": 1, "kind": "item_created", "item_id": 1, "payload": {}},
|
|
{
|
|
"seq": 2,
|
|
"ts": 10,
|
|
"kind": "item_assigned",
|
|
"item_id": 1,
|
|
"payload": {"assignee": "sam"},
|
|
},
|
|
{
|
|
"seq": 3,
|
|
"ts": 20,
|
|
"kind": "item_transitioned",
|
|
"item_id": 1,
|
|
"payload": {"to": "review"},
|
|
},
|
|
{
|
|
"seq": 4,
|
|
"ts": 30,
|
|
"kind": "item_transitioned",
|
|
"item_id": 1,
|
|
"payload": {"to": "done"},
|
|
},
|
|
]
|
|
item = {
|
|
"id": 1,
|
|
"title": "Task",
|
|
"created_ts": 1,
|
|
"creator": "lead",
|
|
"assignee": "sam",
|
|
"state": "done",
|
|
"refs": [],
|
|
"links": [],
|
|
}
|
|
worker = SessionRecord(
|
|
session_id="worker",
|
|
workspace="acme",
|
|
model="sample",
|
|
mode="interactive",
|
|
messages=[
|
|
{
|
|
"role": "assistant",
|
|
"ts": 29,
|
|
"usage": {"input": 100},
|
|
"timing": {"model_started": 25, "model_ms": 10000},
|
|
}
|
|
],
|
|
)
|
|
manager = SimpleNamespace(
|
|
session_board=lambda sid: {"items": [item]},
|
|
team_store=SimpleNamespace(
|
|
events=lambda space, since_seq, limit: [
|
|
e for e in events if e["seq"] > since_seq
|
|
]
|
|
),
|
|
inbox=InboxStore(),
|
|
_engines={},
|
|
is_running=lambda sid: False,
|
|
session_store=SimpleNamespace(
|
|
load=lambda sid: worker if sid == "worker" else None
|
|
),
|
|
)
|
|
team = SimpleNamespace(
|
|
lead_session="lead",
|
|
lead_actor="lead",
|
|
team_id="t",
|
|
space="acme",
|
|
created_at=1,
|
|
workers=[TeamWorker(actor="sam", persona="swe-worker", session_id="worker")],
|
|
)
|
|
result = make_summary(manager, team, 40)
|
|
assert result["items"][0]["timing"] is None
|
|
assert sum(result["items"][0]["tokens"].values()) == 0
|
|
assert result["totals"]["tokens"] == 100
|
|
# Worker accounting covers the session, not whichever task the UI opens.
|
|
assert sum(result["workers"][0]["tokens"].values()) == 100
|
|
assert result["workers"][0]["usage_partial"] is False
|
|
|
|
|
|
def test_chart_bucketing_preserves_all_tokens_by_role():
|
|
from coworker.teams.summary import compact_usage_points
|
|
|
|
points = [
|
|
{"ts": n, "role": "lead" if n % 2 else "swe-worker", "tokens": 3}
|
|
for n in range(10000)
|
|
]
|
|
result = compact_usage_points(points)
|
|
assert len(result) <= 240
|
|
assert sum(p["tokens"] for p in result) == 30000
|
|
assert sum(p["tokens"] for p in result if p["role"] == "lead") == 15000
|