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
@@ -20,13 +20,14 @@ except ImportError: # pragma: no cover
|
|||||||
_cli_version = "2.1.248"
|
_cli_version = "2.1.248"
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable, Sequence
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CLI_VERSION",
|
"CLI_VERSION",
|
||||||
"build_entries",
|
"build_entries",
|
||||||
"close_open_tool_uses",
|
"close_open_tool_uses",
|
||||||
"fingerprint",
|
"fingerprint",
|
||||||
|
"last_message_before",
|
||||||
"messages_from_entries",
|
"messages_from_entries",
|
||||||
"open_tool_uses",
|
"open_tool_uses",
|
||||||
"prompt_count",
|
"prompt_count",
|
||||||
@@ -35,6 +36,7 @@ __all__ = [
|
|||||||
"spoken_text",
|
"spoken_text",
|
||||||
"strip_tool_entries",
|
"strip_tool_entries",
|
||||||
"text_of",
|
"text_of",
|
||||||
|
"turn_starts",
|
||||||
"window_entries",
|
"window_entries",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -438,6 +440,33 @@ def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int:
|
|||||||
return sum(1 for e in entries if _is_prompt_entry(e))
|
return sum(1 for e in entries if _is_prompt_entry(e))
|
||||||
|
|
||||||
|
|
||||||
|
def turn_starts(entries: Iterable[Mapping[str, Any]]) -> list[int]:
|
||||||
|
"""Where each turn opens: the index of every prompt the SDK was handed.
|
||||||
|
|
||||||
|
The CLI stamps only those with ``promptSource``; what it writes as a
|
||||||
|
user on its own (an interruption note, a skill body) stays inside the
|
||||||
|
turn it belongs to.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
index
|
||||||
|
for index, entry in enumerate(entries)
|
||||||
|
if entry.get("promptSource") and _is_prompt_entry(entry)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def last_message_before(entries: Sequence[Mapping[str, Any]], index: int) -> str | None:
|
||||||
|
"""Uuid of the last ``user``/``assistant`` entry ahead of ``index``."""
|
||||||
|
for entry in reversed(entries[:index]):
|
||||||
|
uuid = entry.get("uuid")
|
||||||
|
if (
|
||||||
|
entry.get("type") in _PROMPT_TYPES
|
||||||
|
and isinstance(uuid, str)
|
||||||
|
and not entry.get("isSidechain")
|
||||||
|
):
|
||||||
|
return uuid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def spoken_text(block: Any, *, thinking_is_progress: bool) -> str:
|
def spoken_text(block: Any, *, thinking_is_progress: bool) -> str:
|
||||||
"""What a content block (dict or model) says to the human.
|
"""What a content block (dict or model) says to the human.
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import contextlib
|
|||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from beaver_gateway.conversations.spawning import Spawning
|
from beaver_gateway.conversations.rewinding import Rewinding
|
||||||
from beaver_gateway.conversations.state import Question
|
from beaver_gateway.conversations.state import Question
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
|||||||
__all__ = ["Questions"]
|
__all__ = ["Questions"]
|
||||||
|
|
||||||
|
|
||||||
class Questions(Spawning):
|
class Questions(Rewinding):
|
||||||
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
|
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
|
||||||
"""Show the question and wait; ``None`` when nobody answered in time."""
|
"""Show the question and wait; ``None`` when nobody answered in time."""
|
||||||
conv = await self.get(key)
|
conv = await self.get(key)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Taking turns back: the thread moves to a fork that ends before them."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
|
from claude_agent_sdk import fork_session_via_store
|
||||||
|
|
||||||
|
from beaver_gateway.backends.transcript import (
|
||||||
|
last_message_before,
|
||||||
|
messages_from_entries,
|
||||||
|
reply_text,
|
||||||
|
text_of,
|
||||||
|
turn_starts,
|
||||||
|
)
|
||||||
|
from beaver_gateway.conversations.spawning import Spawning
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from beaver_gateway.storage.models import Conversation
|
||||||
|
|
||||||
|
__all__ = ["Rewinding", "Rewound"]
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PREVIEW = 280
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Rewound:
|
||||||
|
conversation: Conversation
|
||||||
|
turns: int
|
||||||
|
previous_session: str
|
||||||
|
|
||||||
|
|
||||||
|
class Rewinding(Spawning):
|
||||||
|
async def turns(
|
||||||
|
self, conv: Conversation, *, limit: int = 10
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""The latest turns that can be taken back, oldest first; the first stays."""
|
||||||
|
entries = await self.entries(conv)
|
||||||
|
starts = turn_starts(entries)
|
||||||
|
ends = [*starts[1:], len(entries)]
|
||||||
|
spans = list(zip(starts, ends, strict=True))[1:]
|
||||||
|
agent = self._agents.get(conv.agent_name)
|
||||||
|
progress = agent is not None and agent.thinking_is_progress
|
||||||
|
return [
|
||||||
|
self._turn(entries[start:end], thinking_is_progress=progress)
|
||||||
|
for start, end in spans[-limit:]
|
||||||
|
]
|
||||||
|
|
||||||
|
def _turn(
|
||||||
|
self, entries: Sequence[Any], *, thinking_is_progress: bool
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
prompt, *rest = messages_from_entries(entries)
|
||||||
|
replies = (
|
||||||
|
reply_text(m["content"], thinking_is_progress=thinking_is_progress)
|
||||||
|
for m in reversed(rest)
|
||||||
|
if m["role"] == "assistant"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": entries[0].get("uuid"),
|
||||||
|
"ts": entries[0].get("timestamp"),
|
||||||
|
"text": text_of(self._unwrapped(prompt)["content"])[:PREVIEW],
|
||||||
|
"reply": next((text for text in replies if text), "")[:PREVIEW],
|
||||||
|
"failed": any(e.get("isApiErrorMessage") for e in entries),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def rewind(self, conv: Conversation, turn: str) -> Rewound:
|
||||||
|
"""Take ``turn`` and every turn after it out of what the model sees.
|
||||||
|
|
||||||
|
Nothing is deleted: the conversation moves to a fork of its session
|
||||||
|
that ends on the message before ``turn``, the cut session stays in
|
||||||
|
the store under its old id.
|
||||||
|
"""
|
||||||
|
row_id = cast("int", conv.id)
|
||||||
|
runner = self._runner(row_id)
|
||||||
|
if runner.lock.locked():
|
||||||
|
msg = f"conversation {conv.external_id} is mid-turn; interrupt it first"
|
||||||
|
raise RuntimeError(msg)
|
||||||
|
async with runner.lock:
|
||||||
|
row = await self.get_row(row_id) or conv
|
||||||
|
entries = await self.entries(row)
|
||||||
|
starts = turn_starts(entries)
|
||||||
|
cut = next((i for i in starts[1:] if entries[i].get("uuid") == turn), None)
|
||||||
|
keep = last_message_before(entries, cut) if cut is not None else None
|
||||||
|
if cut is None or keep is None:
|
||||||
|
msg = f"conversation {row.external_id} has no turn {turn} to take back"
|
||||||
|
raise LookupError(msg)
|
||||||
|
previous = cast("str", row.session_id)
|
||||||
|
forked = await fork_session_via_store(
|
||||||
|
self._store,
|
||||||
|
previous,
|
||||||
|
directory=str(self._claude_agent(row.agent_name).cwd),
|
||||||
|
up_to_message_id=keep,
|
||||||
|
)
|
||||||
|
await self._backend(row.agent_name).close(row.external_id)
|
||||||
|
|
||||||
|
async def apply(moved: Conversation) -> None:
|
||||||
|
moved.session_id = forked.session_id
|
||||||
|
|
||||||
|
row = await self._update(row, apply)
|
||||||
|
turns = sum(1 for start in starts if start >= cut)
|
||||||
|
_log.info(
|
||||||
|
"rewound %s by %d turns: session %s -> %s",
|
||||||
|
row.external_id,
|
||||||
|
turns,
|
||||||
|
previous,
|
||||||
|
forked.session_id,
|
||||||
|
)
|
||||||
|
return Rewound(conversation=row, turns=turns, previous_session=previous)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""``Conversations`` - the service every frontend, job and gateway tool talks to.
|
"""``Conversations`` - the service every frontend, job and gateway tool talks to.
|
||||||
|
|
||||||
Built as layers, one file each: rows → seeds → turns → messaging →
|
Built as layers, one file each: rows → seeds → turns → messaging →
|
||||||
spawning → questions → closing; this file adds start, stop and restart
|
spawning → rewinding → questions → closing; this file adds start, stop and restart
|
||||||
recovery. A turn started by a user message streams back to whoever asked;
|
recovery. A turn started by a user message streams back to whoever asked;
|
||||||
a turn started by an inject streams nowhere and can only speak via ``say``.
|
a turn started by an inject streams nowhere and can only speak via ``say``.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -68,14 +68,14 @@ class Turns(Seeds):
|
|||||||
backend = self._backend(conv.agent_name)
|
backend = self._backend(conv.agent_name)
|
||||||
turn_id = turn_id or f"turn_{uuid4().hex[:12]}"
|
turn_id = turn_id or f"turn_{uuid4().hex[:12]}"
|
||||||
capture = capture or TurnCapture()
|
capture = capture or TurnCapture()
|
||||||
resume = session_id if session_id is not None else conv.session_id
|
|
||||||
async with runner.lock:
|
async with runner.lock:
|
||||||
runner.turn_id = turn_id
|
runner.turn_id = turn_id
|
||||||
runner.origin = origin
|
runner.origin = origin
|
||||||
runner.text = shown if shown is not None else _prompt_preview(messages)
|
runner.text = shown if shown is not None else _prompt_preview(messages)
|
||||||
runner.started_at = datetime.now(UTC)
|
runner.started_at = datetime.now(UTC)
|
||||||
runner.tools = {}
|
runner.tools = {}
|
||||||
await self._mark_running(conv, turn_id)
|
row = await self._mark_running(conv, turn_id)
|
||||||
|
resume = session_id if session_id is not None else row.session_id
|
||||||
before = await self.before_turn(conv)
|
before = await self.before_turn(conv)
|
||||||
self._bus.publish(
|
self._bus.publish(
|
||||||
"turn.start",
|
"turn.start",
|
||||||
@@ -463,12 +463,12 @@ class Turns(Seeds):
|
|||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
_log.exception("rate limit write failed")
|
_log.exception("rate limit write failed")
|
||||||
|
|
||||||
async def _mark_running(self, conv: Conversation, turn_id: str) -> None:
|
async def _mark_running(self, conv: Conversation, turn_id: str) -> Conversation:
|
||||||
async def apply(row: Conversation) -> None:
|
async def apply(row: Conversation) -> None:
|
||||||
row.running_turn = turn_id
|
row.running_turn = turn_id
|
||||||
row.last_activity_at = datetime.now(UTC)
|
row.last_activity_at = datetime.now(UTC)
|
||||||
|
|
||||||
await self._update(conv, apply)
|
return await self._update(conv, apply)
|
||||||
|
|
||||||
async def _mark_done(
|
async def _mark_done(
|
||||||
self, conv: Conversation, capture: TurnCapture, *, cut: bool = False
|
self, conv: Conversation, capture: TurnCapture, *, cut: bool = False
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ MEMORY_MAX_ENTRIES = 5000
|
|||||||
SEARCH_MIN = 2
|
SEARCH_MIN = 2
|
||||||
GRAPH_LIMIT = 120
|
GRAPH_LIMIT = 120
|
||||||
SEARCH_LIMIT = 20
|
SEARCH_LIMIT = 20
|
||||||
|
TURNS_LIMIT = 10
|
||||||
|
TURNS_MAX = 50
|
||||||
SNIPPET_MAX = 160
|
SNIPPET_MAX = 160
|
||||||
|
|
||||||
|
|
||||||
@@ -578,6 +580,42 @@ def build_app( # noqa: PLR0915
|
|||||||
"text": result.text,
|
"text": result.text,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.get("/conversations/{public_id}/turns")
|
||||||
|
async def get_turns(public_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
await require_token(request, runtime, scope=SCOPE)
|
||||||
|
conv = await conv_of(public_id)
|
||||||
|
limit = min(query_int(request, "limit", TURNS_LIMIT), TURNS_MAX)
|
||||||
|
return {
|
||||||
|
"id": conv.external_id,
|
||||||
|
"turns": await conversations.turns(conv, limit=limit),
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/conversations/{public_id}/rewind")
|
||||||
|
async def post_rewind(public_id: str, request: Request) -> dict[str, Any]:
|
||||||
|
token = await require_token(request, runtime, scope=ADMIN_SCOPE)
|
||||||
|
conv = await conv_of(public_id)
|
||||||
|
turn = text_of(await body_of(request), "turn")
|
||||||
|
try:
|
||||||
|
result = await conversations.rewind(conv, turn)
|
||||||
|
except LookupError as exc:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||||
|
out = {
|
||||||
|
"turns": result.turns,
|
||||||
|
"session_id": result.conversation.session_id,
|
||||||
|
"previous_session": result.previous_session,
|
||||||
|
}
|
||||||
|
await audit.log(
|
||||||
|
runtime,
|
||||||
|
actor=f"token:{token}",
|
||||||
|
kind="api_rewind",
|
||||||
|
agent_name=conv.agent_name,
|
||||||
|
conversation=conv.external_id,
|
||||||
|
**out,
|
||||||
|
)
|
||||||
|
return {"id": conv.external_id, **out}
|
||||||
|
|
||||||
@app.post("/conversations/{public_id}/bind")
|
@app.post("/conversations/{public_id}/bind")
|
||||||
async def post_bind(public_id: str, request: Request) -> dict[str, Any]:
|
async def post_bind(public_id: str, request: Request) -> dict[str, Any]:
|
||||||
await require_token(request, runtime, scope=SCOPE)
|
await require_token(request, runtime, scope=SCOPE)
|
||||||
|
|||||||
+47
-1
@@ -16,7 +16,7 @@ from claude_agent_sdk import (
|
|||||||
UserMessage,
|
UserMessage,
|
||||||
)
|
)
|
||||||
from httpx import ASGITransport, AsyncClient
|
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.frontends.markdown.history import rewrite_messages
|
||||||
from beaver_gateway.security.auth import TokenStore
|
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"]
|
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:
|
async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
|
||||||
api = Api(world)
|
api = Api(world)
|
||||||
api.store.grant("api-only", "weak", scope="api")
|
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
|
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:
|
async def test_merge_injects_summary_into_parent(world: World) -> None:
|
||||||
sid = str(uuid.uuid4())
|
sid = str(uuid.uuid4())
|
||||||
await world.store.append(
|
await world.store.append(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
SearchResponse,
|
SearchResponse,
|
||||||
SessionsResponse,
|
SessionsResponse,
|
||||||
TokenRow,
|
TokenRow,
|
||||||
|
TurnSummary,
|
||||||
UsageGroup,
|
UsageGroup,
|
||||||
UsageResponse,
|
UsageResponse,
|
||||||
VaultGraph,
|
VaultGraph,
|
||||||
@@ -187,6 +188,14 @@ export class ApiClient {
|
|||||||
return this.get(`/api/conversations/${id}/history`);
|
return this.get(`/api/conversations/${id}/history`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
turns(id: string, limit: number): Promise<{ turns: TurnSummary[] }> {
|
||||||
|
return this.get(`/api/conversations/${id}/turns`, { limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
rewind(id: string, turn: string): Promise<{ turns: number }> {
|
||||||
|
return this.post(`/api/conversations/${id}/rewind`, { turn });
|
||||||
|
}
|
||||||
|
|
||||||
context(id: string, prompt = false): Promise<ContextResponse> {
|
context(id: string, prompt = false): Promise<ContextResponse> {
|
||||||
return this.get(`/api/conversations/${id}/context`, {
|
return this.get(`/api/conversations/${id}/context`, {
|
||||||
prompt: prompt ? 1 : undefined,
|
prompt: prompt ? 1 : undefined,
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export interface HistoryMessage {
|
|||||||
ts?: string | null;
|
ts?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TurnSummary {
|
||||||
|
failed: boolean;
|
||||||
|
id: string;
|
||||||
|
reply: string;
|
||||||
|
text: string;
|
||||||
|
ts: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntriesPage {
|
export interface EntriesPage {
|
||||||
entries: Record<string, unknown>[];
|
entries: Record<string, unknown>[];
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ export function fmtTime(iso: string | null | undefined): string {
|
|||||||
: "–";
|
: "–";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fmtMinute(iso: string | null | undefined): string {
|
||||||
|
const date = parseDate(iso);
|
||||||
|
return date
|
||||||
|
? date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
|
||||||
|
: "–";
|
||||||
|
}
|
||||||
|
|
||||||
export function fmtDateTime(iso: string | null | undefined): string {
|
export function fmtDateTime(iso: string | null | undefined): string {
|
||||||
const date = parseDate(iso);
|
const date = parseDate(iso);
|
||||||
return date
|
return date
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import FileTextIcon from "@lucide/svelte/icons/file-text";
|
|||||||
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
|
import GitBranchIcon from "@lucide/svelte/icons/git-branch";
|
||||||
import GitMergeIcon from "@lucide/svelte/icons/git-merge";
|
import GitMergeIcon from "@lucide/svelte/icons/git-merge";
|
||||||
import PencilIcon from "@lucide/svelte/icons/pencil";
|
import PencilIcon from "@lucide/svelte/icons/pencil";
|
||||||
|
import RewindIcon from "@lucide/svelte/icons/rewind";
|
||||||
import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
|
import RotateCcwIcon from "@lucide/svelte/icons/rotate-ccw";
|
||||||
import SendIcon from "@lucide/svelte/icons/send";
|
import SendIcon from "@lucide/svelte/icons/send";
|
||||||
import type { Component } from "svelte";
|
import type { Component } from "svelte";
|
||||||
@@ -38,6 +39,7 @@ export interface ActionScope {
|
|||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onOpen: (id: string) => void;
|
onOpen: (id: string) => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
|
onRewind: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TELEGRAM = "telegram";
|
const TELEGRAM = "telegram";
|
||||||
@@ -103,6 +105,12 @@ export function conversationActions(
|
|||||||
label: "Branch…",
|
label: "Branch…",
|
||||||
run: scope.onBranch,
|
run: scope.onBranch,
|
||||||
});
|
});
|
||||||
|
actions.push({
|
||||||
|
disabled: !info.session_id,
|
||||||
|
icon: RewindIcon,
|
||||||
|
label: "Rewind…",
|
||||||
|
run: scope.onRewind,
|
||||||
|
});
|
||||||
actions.push({
|
actions.push({
|
||||||
checked: remembers,
|
checked: remembers,
|
||||||
gap: true,
|
gap: true,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { usePanelHost } from "./host";
|
import { usePanelHost } from "./host";
|
||||||
import MenuItems, { type MenuKit } from "./menu-items.svelte";
|
import MenuItems, { type MenuKit } from "./menu-items.svelte";
|
||||||
import RenameDialog from "./rename-dialog.svelte";
|
import RenameDialog from "./rename-dialog.svelte";
|
||||||
|
import RewindDialog from "./rewind-dialog.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
client,
|
client,
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
let failure = $state<string | null>(null);
|
let failure = $state<string | null>(null);
|
||||||
let branchOpen = $state(false);
|
let branchOpen = $state(false);
|
||||||
let renameOpen = $state(false);
|
let renameOpen = $state(false);
|
||||||
|
let rewindOpen = $state(false);
|
||||||
|
|
||||||
const loaded = $derived(info ?? fetched);
|
const loaded = $derived(info ?? fetched);
|
||||||
|
|
||||||
@@ -77,6 +79,9 @@
|
|||||||
onRename: () => {
|
onRename: () => {
|
||||||
renameOpen = true;
|
renameOpen = true;
|
||||||
},
|
},
|
||||||
|
onRewind: () => {
|
||||||
|
rewindOpen = true;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const actions = $derived(loaded ? conversationActions(loaded, scope) : []);
|
const actions = $derived(loaded ? conversationActions(loaded, scope) : []);
|
||||||
</script>
|
</script>
|
||||||
@@ -141,4 +146,5 @@
|
|||||||
bind:open={branchOpen}
|
bind:open={branchOpen}
|
||||||
/>
|
/>
|
||||||
<RenameDialog {client} info={loaded} {onChanged} bind:open={renameOpen} />
|
<RenameDialog {client} info={loaded} {onChanged} bind:open={renameOpen} />
|
||||||
|
<RewindDialog {client} info={loaded} {onChanged} bind:open={rewindOpen} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { tick, untrack } from "svelte";
|
||||||
|
import { toast } from "svelte-sonner";
|
||||||
|
import type { ApiClient } from "$lib/api/client";
|
||||||
|
import type { ConversationSummary, TurnSummary } from "$lib/api/types";
|
||||||
|
import EmptyState from "$lib/components/empty-state.svelte";
|
||||||
|
import ErrorNote from "$lib/components/error-note.svelte";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import * as Dialog from "$lib/components/ui/dialog";
|
||||||
|
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||||
|
import { clip, fmtDateTime, fmtMinute, shortId } from "$lib/format";
|
||||||
|
import { cn } from "$lib/utils";
|
||||||
|
|
||||||
|
let {
|
||||||
|
client,
|
||||||
|
info,
|
||||||
|
open = $bindable(false),
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
client: ApiClient;
|
||||||
|
info: Pick<ConversationSummary, "id" | "kind" | "running_turn" | "title">;
|
||||||
|
open?: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const LIMIT = 30;
|
||||||
|
const LINE = 160;
|
||||||
|
|
||||||
|
let turns = $state<TurnSummary[] | null>(null);
|
||||||
|
let failure = $state<string | null>(null);
|
||||||
|
let picked = $state<number | null>(null);
|
||||||
|
let busy = $state(false);
|
||||||
|
let list = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const name = $derived(info.title || `${info.kind} ${shortId(info.id)}`);
|
||||||
|
const count = $derived(
|
||||||
|
turns === null || picked === null ? 0 : turns.length - picked
|
||||||
|
);
|
||||||
|
|
||||||
|
function plural(n: number): string {
|
||||||
|
return n === 1 ? "1 turn" : `${n} turns`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function brokenTail(rows: TurnSummary[]): number | null {
|
||||||
|
let at = rows.length;
|
||||||
|
while (at > 0 && rows[at - 1].failed) {
|
||||||
|
at -= 1;
|
||||||
|
}
|
||||||
|
return at < rows.length ? at : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
turns = null;
|
||||||
|
failure = null;
|
||||||
|
picked = null;
|
||||||
|
try {
|
||||||
|
({ turns } = await client.turns(info.id, LIMIT));
|
||||||
|
picked = brokenTail(turns);
|
||||||
|
await tick();
|
||||||
|
if (list) {
|
||||||
|
list.scrollTop = list.scrollHeight;
|
||||||
|
list
|
||||||
|
.querySelector(":checked")
|
||||||
|
?.closest("label")
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failure = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rewind() {
|
||||||
|
const turn = turns?.[picked ?? -1];
|
||||||
|
if (!turn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const result = await client.rewind(info.id, turn.id);
|
||||||
|
open = false;
|
||||||
|
toast.success(`Cut ${plural(result.turns)}`);
|
||||||
|
onChanged();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
untrack(() => load());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>Rewind {name}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Pick the turn to cut from. The model forgets it and every turn after it.
|
||||||
|
The old session stays in the database.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
|
{#if failure}
|
||||||
|
<ErrorNote message={failure} retry={load} />
|
||||||
|
{:else if turns === null}
|
||||||
|
<div class="flex flex-col gap-3 py-2">
|
||||||
|
{#each [0, 1, 2] as row (row)}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<Skeleton class="h-3.5 w-3/5" />
|
||||||
|
<Skeleton class="h-3 w-4/5" />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if turns.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
hint="The first turn of a thread stays, and this thread has no other."
|
||||||
|
title="Nothing to cut"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
aria-label="Turn to cut from"
|
||||||
|
class="-mx-2 grid max-h-[min(22rem,50dvh)] grid-cols-[auto_minmax(0,1fr)] gap-x-3 overflow-y-auto rounded-md pt-2"
|
||||||
|
role="radiogroup"
|
||||||
|
bind:this={list}
|
||||||
|
>
|
||||||
|
{#each turns as turn, index (turn.id)}
|
||||||
|
{@const gone = picked !== null && index >= picked}
|
||||||
|
<label
|
||||||
|
class={cn(
|
||||||
|
"relative col-span-2 grid cursor-pointer grid-cols-subgrid items-baseline border-t px-2 py-2 first:border-t-transparent has-focus-visible:ring-1 has-focus-visible:ring-ring has-focus-visible:ring-inset",
|
||||||
|
gone ? "bg-destructive/5" : "row-hover",
|
||||||
|
index === picked &&
|
||||||
|
"border-t-destructive/40 first:border-t-destructive/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
checked={index === picked}
|
||||||
|
class="sr-only"
|
||||||
|
name="rewind-turn"
|
||||||
|
onchange={() => {
|
||||||
|
picked = index;
|
||||||
|
}}
|
||||||
|
type="radio"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="tabular text-muted-foreground text-xs"
|
||||||
|
title={fmtDateTime(turn.ts)}
|
||||||
|
>
|
||||||
|
{fmtMinute(turn.ts)}
|
||||||
|
</span>
|
||||||
|
<span class="flex min-w-0 flex-col">
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"truncate font-medium",
|
||||||
|
gone && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{clip(turn.text, LINE) || "empty prompt"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"truncate text-xs",
|
||||||
|
turn.failed ? "text-destructive" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{clip(turn.reply, LINE) ||
|
||||||
|
(turn.failed ? "failed" : "no reply")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{#if index === picked}
|
||||||
|
<span
|
||||||
|
class="-top-2 absolute right-2 rounded-md bg-[color-mix(in_oklab,var(--destructive)_12%,var(--popover))] px-1.5 font-medium text-destructive text-xs leading-4"
|
||||||
|
>
|
||||||
|
cut from here
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if info.running_turn}
|
||||||
|
<p class="text-warn text-xs">A turn is running. Interrupt it first.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
// biome-ignore lint/suspicious/noGlobalAssign: bindable prop, not window.open
|
||||||
|
open = false;
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{#if turns?.length}
|
||||||
|
<Button
|
||||||
|
disabled={busy || count === 0 || Boolean(info.running_turn)}
|
||||||
|
onclick={rewind}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{count === 0 ? "Cut" : `Cut ${plural(count)}`}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
Reference in new issue
Block a user