feat(conversations,api,ui): rewind takes the last turns of a thread back, the cut session stays in the store
This commit is contained in:
1 parent
b0e5aaf599
commit
2a47c6771d
14 files changed
+583
-9
No files matched your search
+47
-1
@@ -16,7 +16,7 @@ from claude_agent_sdk import (
|
||||
UserMessage,
|
||||
)
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from test_conversations import ScriptedClient, World
|
||||
from test_conversations import ScriptedClient, World, _three_turns
|
||||
|
||||
from beaver_gateway.frontends.markdown.history import rewrite_messages
|
||||
from beaver_gateway.security.auth import TokenStore
|
||||
@@ -444,6 +444,52 @@ async def test_sessions_show_their_processes_and_take_orders(world: World) -> No
|
||||
assert kinds == ["session_interrupt", "session_close"]
|
||||
|
||||
|
||||
async def test_rewind_lists_turns_and_needs_admin_scope(world: World) -> None:
|
||||
api = Api(world)
|
||||
api.store.grant("api-only", "weak", scope="api")
|
||||
weak = {"Authorization": "Bearer weak"}
|
||||
sid, entries = await _three_turns(world)
|
||||
conv = await world.conversations.create(
|
||||
kind="master", agent="a", origin="test", session_id=sid
|
||||
)
|
||||
path = f"/conversations/{conv.external_id}"
|
||||
|
||||
listed = (await api.http.get(f"{path}/turns", headers=weak)).json()
|
||||
assert [t["text"] for t in listed["turns"]] == ["two", "three"]
|
||||
assert (await api.get(f"{path}/turns", {"limit": 1}))["turns"] == [
|
||||
listed["turns"][1]
|
||||
]
|
||||
body = {"turn": listed["turns"][0]["id"]}
|
||||
denied = await api.http.post(f"{path}/rewind", json=body, headers=weak)
|
||||
assert denied.status_code == 403
|
||||
missing = await api.http.post(
|
||||
f"{path}/rewind", json={"turn": entries[0]["uuid"]}, headers=HEADERS
|
||||
)
|
||||
assert missing.status_code == 404
|
||||
|
||||
done = await api.http.post(f"{path}/rewind", json=body, headers=HEADERS)
|
||||
assert done.status_code == 200, done.text
|
||||
out = done.json()
|
||||
assert out["turns"] == 2 and out["previous_session"] == sid
|
||||
assert (await api.get(path))["session_id"] == out["session_id"] != sid
|
||||
assert (await api.get(f"{path}/turns"))["turns"] == []
|
||||
messages = (await api.get(f"{path}/history"))["messages"]
|
||||
assert [m["content"] for m in messages] == [
|
||||
"one",
|
||||
[{"type": "text", "text": "done one"}],
|
||||
]
|
||||
(record,) = (await api.get("/audit"))["records"]
|
||||
assert record["kind"] == "api_rewind"
|
||||
assert record["detail"]["previous_session"] == sid
|
||||
|
||||
ScriptedClient.hold = asyncio.Event()
|
||||
await world.conversations.post(conv, "busy")
|
||||
await asyncio.sleep(0.2)
|
||||
busy = await api.http.post(f"{path}/rewind", json=body, headers=HEADERS)
|
||||
assert busy.status_code == 409
|
||||
ScriptedClient.hold.set()
|
||||
|
||||
|
||||
async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
|
||||
api = Api(world)
|
||||
api.store.grant("api-only", "weak", scope="api")
|
||||
|
||||
@@ -504,6 +504,105 @@ async def test_copy_seed_forks_parent_with_window(world: World) -> None:
|
||||
assert (await world.conversations.get(child.external_id)).flags["seed"] is None
|
||||
|
||||
|
||||
async def _three_turns(world: World) -> tuple[str, list[dict[str, Any]]]:
|
||||
sid = str(uuid.uuid4())
|
||||
history = [
|
||||
{"role": "user", "content": "one"},
|
||||
{"role": "assistant", "content": "done one"},
|
||||
{"role": "user", "content": "two"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "tool_use", "id": "t1", "name": "Read", "input": {}}],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "x"}],
|
||||
},
|
||||
{"role": "assistant", "content": "done two"},
|
||||
{"role": "user", "content": "three"},
|
||||
{"role": "assistant", "content": "refused"},
|
||||
]
|
||||
entries = build_entries(history, session_id=sid, cwd=str(world.root), model="m")
|
||||
entries[-1]["isApiErrorMessage"] = True
|
||||
note = {
|
||||
**entries[2],
|
||||
"uuid": str(uuid.uuid4()),
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "[Request interrupted by user]"}],
|
||||
},
|
||||
}
|
||||
del note["promptSource"]
|
||||
entries.insert(6, note)
|
||||
await world.store.append(world.key(sid), entries)
|
||||
return sid, entries
|
||||
|
||||
|
||||
async def test_rewind_moves_the_thread_to_a_fork_that_ends_before_the_turn(
|
||||
world: World,
|
||||
) -> None:
|
||||
sid, entries = await _three_turns(world)
|
||||
conv = await world.conversations.create(
|
||||
kind="master", agent="a", origin="test", session_id=sid
|
||||
)
|
||||
await world.conversations.post(conv, "live")
|
||||
await world.settle(conv, 1)
|
||||
assert world.pool.get(conv.external_id) is not None
|
||||
|
||||
turns = await world.conversations.turns(conv)
|
||||
assert [(t["text"], t["reply"], t["failed"]) for t in turns] == [
|
||||
("two", "done two", False),
|
||||
("three", "refused", True),
|
||||
]
|
||||
assert [t["id"] for t in turns] == [entries[2]["uuid"], entries[7]["uuid"]]
|
||||
|
||||
result = await world.conversations.rewind(conv, turns[1]["id"])
|
||||
|
||||
assert result.turns == 1 and result.previous_session == sid
|
||||
moved = result.conversation.session_id
|
||||
assert moved != sid
|
||||
assert (await world.conversations.get(conv.external_id)).session_id == moved
|
||||
assert await world.store.load(world.key(sid)) == entries
|
||||
kept = [
|
||||
e["message"]["content"]
|
||||
for e in await world.store.load(world.key(moved))
|
||||
if e["type"] in ("user", "assistant")
|
||||
]
|
||||
assert len(kept) == 7 and kept[0] == "one"
|
||||
assert kept[-1] == [{"type": "text", "text": "[Request interrupted by user]"}]
|
||||
assert world.pool.get(conv.external_id) is None
|
||||
|
||||
await world.conversations.run_text_turn(conv, "after", origin="user")
|
||||
assert conv.session_id == sid
|
||||
assert ScriptedClient.instances[-1].options.resume == moved
|
||||
|
||||
(two,) = await world.conversations.turns(result.conversation)
|
||||
assert two["text"] == "two"
|
||||
again = await world.conversations.rewind(conv, two["id"])
|
||||
assert again.turns == 1 and again.previous_session == moved
|
||||
assert await world.conversations.turns(again.conversation) == []
|
||||
first = (await world.store.load(world.key(again.conversation.session_id)))[0]
|
||||
with pytest.raises(LookupError):
|
||||
await world.conversations.rewind(conv, first["uuid"])
|
||||
with pytest.raises(LookupError):
|
||||
await world.conversations.rewind(conv, str(uuid.uuid4()))
|
||||
|
||||
|
||||
async def test_rewind_waits_for_no_turn(world: World) -> None:
|
||||
sid, entries = await _three_turns(world)
|
||||
conv = await world.conversations.create(
|
||||
kind="master", agent="a", origin="test", session_id=sid
|
||||
)
|
||||
ScriptedClient.hold = asyncio.Event()
|
||||
await world.conversations.post(conv, "busy")
|
||||
await asyncio.sleep(0.2)
|
||||
with pytest.raises(RuntimeError, match="mid-turn"):
|
||||
await world.conversations.rewind(conv, entries[7]["uuid"])
|
||||
ScriptedClient.hold.set()
|
||||
await world.settle(conv, 1)
|
||||
assert (await world.conversations.get(conv.external_id)).session_id == sid
|
||||
|
||||
|
||||
async def test_merge_injects_summary_into_parent(world: World) -> None:
|
||||
sid = str(uuid.uuid4())
|
||||
await world.store.append(
|
||||
|
||||
Reference in new issue
Block a user