mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-05 00:48:32 +00:00
fix(webui): reconcile threads after browser resume
This commit is contained in:
parent
78cf68c291
commit
ae089aa3ae
@ -32,6 +32,7 @@ from nanobot.bus.outbound_events import (
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.command.builtin import builtin_command_starts_agent_turn
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_INPUT_META,
|
||||
@ -43,7 +44,14 @@ from nanobot.security.workspace_access import (
|
||||
WorkspaceScopeError,
|
||||
)
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.webui_turns import websocket_turn_wall_started_at
|
||||
from nanobot.session.webui_turns import (
|
||||
clear_websocket_turn_if_current,
|
||||
mark_websocket_turn_transcript_persistence_failed,
|
||||
register_queued_websocket_turn_if_idle,
|
||||
websocket_turn_id,
|
||||
websocket_turn_transcript_persistence_failed,
|
||||
websocket_turn_wall_started_at,
|
||||
)
|
||||
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
|
||||
from nanobot.webui.forking import handle_webui_fork_chat
|
||||
from nanobot.webui.gateway_services import GatewayServices
|
||||
@ -57,6 +65,11 @@ from nanobot.webui.http_utils import (
|
||||
query_first as _query_first,
|
||||
)
|
||||
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
||||
from nanobot.webui.metadata import (
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
||||
|
||||
@ -317,7 +330,12 @@ class WebSocketChannel(BaseChannel):
|
||||
t0 = websocket_turn_wall_started_at(chat_id)
|
||||
if t0 is None:
|
||||
return
|
||||
await self.send_goal_status(chat_id, "running", started_at=t0)
|
||||
await self.send_goal_status(
|
||||
chat_id,
|
||||
"running",
|
||||
started_at=t0,
|
||||
turn_id=websocket_turn_id(chat_id),
|
||||
)
|
||||
|
||||
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
|
||||
"""Replay persisted or actively running per-chat state after subscribe."""
|
||||
@ -633,17 +651,40 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
raw_turn_id = envelope.get("turn_id")
|
||||
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
|
||||
rejection_fields = {
|
||||
"chat_id": cid,
|
||||
**({"turn_id": turn_id} if turn_id else {}),
|
||||
}
|
||||
# The allowlist can change while an authenticated websocket stays
|
||||
# open. Reject the exact application turn before hydration,
|
||||
# transcript persistence, or an acceptance ACK; BaseChannel's
|
||||
# silent authorization return must not look like successful ingress.
|
||||
if not self.is_allowed(client_id):
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="access_denied",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
if not isinstance(content, str):
|
||||
await self._send_event(connection, "error", detail="missing content")
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="missing content",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
message_rejection = self._ingress.validate_text(content)
|
||||
if message_rejection is not None:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
chat_id=cid,
|
||||
detail="message_rejected",
|
||||
reason=message_rejection,
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
|
||||
@ -656,6 +697,7 @@ class WebSocketChannel(BaseChannel):
|
||||
"error",
|
||||
detail="attachment_rejected",
|
||||
reason="malformed",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
media_paths, reason = self._media.store_inbound_attachments(raw_media)
|
||||
@ -665,12 +707,18 @@ class WebSocketChannel(BaseChannel):
|
||||
"error",
|
||||
detail="attachment_rejected",
|
||||
reason=reason,
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
|
||||
# Allow media-only turns (content may be empty when attachments are present).
|
||||
if not content.strip() and not media_paths:
|
||||
await self._send_event(connection, "error", detail="missing content")
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="missing content",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||
self._attach(connection, cid)
|
||||
@ -686,10 +734,23 @@ class WebSocketChannel(BaseChannel):
|
||||
controls_available=self._workspace_controls_available(connection),
|
||||
),
|
||||
chat_id=cid,
|
||||
turn_id=turn_id,
|
||||
)
|
||||
if scope is None:
|
||||
return
|
||||
|
||||
# Hydration and scope resolution can yield. Re-check immediately
|
||||
# before transcript/bus mutation so a mid-flight revocation cannot
|
||||
# fall through BaseChannel's silent deny and still receive an ACK.
|
||||
if not self.is_allowed(client_id):
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail="access_denied",
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
|
||||
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
||||
if envelope.get("webui") is True:
|
||||
metadata["webui"] = True
|
||||
@ -702,29 +763,48 @@ class WebSocketChannel(BaseChannel):
|
||||
metadata["mcp_presets"] = mcp_presets
|
||||
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
|
||||
self._workspaces.persist_scope(cid, scope)
|
||||
if metadata.get("webui") is True and self.is_allowed(client_id):
|
||||
self._transcripts.append_user_message(
|
||||
cid,
|
||||
content,
|
||||
is_webui = metadata.get("webui") is True
|
||||
queued_owner = None
|
||||
if is_webui and builtin_command_starts_agent_turn(content):
|
||||
queued_owner = register_queued_websocket_turn_if_idle(cid, turn_id)
|
||||
if queued_owner is not None:
|
||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||
accepted = False
|
||||
try:
|
||||
if is_webui:
|
||||
self._transcripts.append_user_message(
|
||||
cid,
|
||||
content,
|
||||
metadata=metadata,
|
||||
media_paths=media_paths or None,
|
||||
cli_apps=cli_apps or None,
|
||||
mcp_presets=mcp_presets or None,
|
||||
)
|
||||
if is_webui and connection in self._webui_connections:
|
||||
quote = webui_quote_runtime_context({
|
||||
WEBUI_QUOTE_METADATA: envelope.get("quoted_context"),
|
||||
})
|
||||
if quote is not None:
|
||||
metadata[RUNTIME_CONTEXT_INPUT_META] = [quote]
|
||||
await self._handle_message(
|
||||
sender_id=client_id,
|
||||
chat_id=cid,
|
||||
content=content,
|
||||
media=media_paths or None,
|
||||
metadata=metadata,
|
||||
media_paths=media_paths or None,
|
||||
cli_apps=cli_apps or None,
|
||||
mcp_presets=mcp_presets or None,
|
||||
is_dm=False,
|
||||
)
|
||||
accepted = True
|
||||
finally:
|
||||
if not accepted and queued_owner is not None:
|
||||
clear_websocket_turn_if_current(cid, queued_owner)
|
||||
if is_webui and turn_id:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"message_accepted",
|
||||
chat_id=cid,
|
||||
turn_id=turn_id,
|
||||
)
|
||||
if metadata.get("webui") is True and connection in self._webui_connections:
|
||||
quote = webui_quote_runtime_context({
|
||||
WEBUI_QUOTE_METADATA: envelope.get("quoted_context"),
|
||||
})
|
||||
if quote is not None:
|
||||
metadata[RUNTIME_CONTEXT_INPUT_META] = [quote]
|
||||
await self._handle_message(
|
||||
sender_id=client_id,
|
||||
chat_id=cid,
|
||||
content=content,
|
||||
media=media_paths or None,
|
||||
metadata=metadata,
|
||||
is_dm=False,
|
||||
)
|
||||
return
|
||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||
|
||||
@ -734,6 +814,7 @@ class WebSocketChannel(BaseChannel):
|
||||
resolver: Callable[[], Any],
|
||||
*,
|
||||
chat_id: str | None = None,
|
||||
turn_id: str | None = None,
|
||||
) -> Any | None:
|
||||
try:
|
||||
return resolver()
|
||||
@ -744,6 +825,7 @@ class WebSocketChannel(BaseChannel):
|
||||
detail="workspace_scope_rejected",
|
||||
reason=exc.message,
|
||||
**({"chat_id": chat_id} if chat_id else {}),
|
||||
**({"turn_id": turn_id} if turn_id else {}),
|
||||
)
|
||||
return None
|
||||
|
||||
@ -782,6 +864,37 @@ class WebSocketChannel(BaseChannel):
|
||||
self.logger.exception("send failed{}", label)
|
||||
raise
|
||||
|
||||
def _persist_turn_transcript_event(
|
||||
self,
|
||||
chat_id: str,
|
||||
event: dict[str, Any],
|
||||
*,
|
||||
metadata: dict[str, Any] | None,
|
||||
phase: str,
|
||||
include_source: bool = False,
|
||||
transcript_overrides: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
||||
persisted = self._transcripts.prepare_and_append(
|
||||
chat_id,
|
||||
event,
|
||||
metadata=metadata,
|
||||
phase=phase,
|
||||
include_source=include_source,
|
||||
transcript_overrides=transcript_overrides,
|
||||
)
|
||||
if (
|
||||
not persisted
|
||||
and phase in {"answer", "complete"}
|
||||
and (metadata or {}).get("webui") is True
|
||||
):
|
||||
owner = (metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
mark_websocket_turn_transcript_persistence_failed(
|
||||
chat_id,
|
||||
owner if isinstance(owner, str) else None,
|
||||
)
|
||||
return persisted
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
event = outbound_event_from_message(msg)
|
||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
||||
@ -818,21 +931,38 @@ class WebSocketChannel(BaseChannel):
|
||||
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
||||
return
|
||||
if isinstance(event, GoalStatusEvent):
|
||||
if conns:
|
||||
if event.status in ("running", "idle"):
|
||||
turn_id = (msg.metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
||||
current_turn_id = turn_id if isinstance(turn_id, str) else None
|
||||
turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
current_turn_owner = turn_owner if isinstance(turn_owner, str) else None
|
||||
try:
|
||||
if conns and event.status in ("running", "idle"):
|
||||
await self.send_goal_status(
|
||||
msg.chat_id,
|
||||
event.status,
|
||||
started_at=event.started_at,
|
||||
turn_id=current_turn_id,
|
||||
)
|
||||
finally:
|
||||
if event.status == "idle":
|
||||
# Cancellation/direct runs may have no turn_end, so idle is
|
||||
# still terminal. A failed canonical completion write is
|
||||
# the one case that must remain pending for safe resume.
|
||||
clear_websocket_turn_if_current(
|
||||
msg.chat_id,
|
||||
current_turn_owner,
|
||||
preserve_persistence_failure=True,
|
||||
)
|
||||
return
|
||||
# Signal that the agent has fully finished processing the current turn.
|
||||
if isinstance(event, TurnEndEvent):
|
||||
turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
await self.send_turn_end(
|
||||
msg.chat_id,
|
||||
latency_ms=event.latency_ms,
|
||||
goal_state=event.goal_state,
|
||||
metadata=msg.metadata,
|
||||
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
||||
)
|
||||
await self.send_session_updated(msg.chat_id, scope="thread")
|
||||
return
|
||||
@ -884,7 +1014,7 @@ class WebSocketChannel(BaseChannel):
|
||||
elif progress_event:
|
||||
payload["kind"] = "progress"
|
||||
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
|
||||
self._transcripts.prepare_and_append(
|
||||
self._persist_turn_transcript_event(
|
||||
msg.chat_id,
|
||||
payload,
|
||||
metadata=msg.metadata,
|
||||
@ -922,7 +1052,7 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._transcripts.prepare_and_append(
|
||||
self._persist_turn_transcript_event(
|
||||
chat_id,
|
||||
body,
|
||||
metadata=meta,
|
||||
@ -950,7 +1080,7 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if stream_id is not None:
|
||||
body["stream_id"] = stream_id
|
||||
self._transcripts.prepare_and_append(
|
||||
self._persist_turn_transcript_event(
|
||||
chat_id,
|
||||
body,
|
||||
metadata=meta,
|
||||
@ -974,7 +1104,7 @@ class WebSocketChannel(BaseChannel):
|
||||
"chat_id": chat_id,
|
||||
"edits": edits,
|
||||
}
|
||||
self._transcripts.prepare_and_append(
|
||||
self._persist_turn_transcript_event(
|
||||
chat_id,
|
||||
payload,
|
||||
metadata=metadata,
|
||||
@ -1026,7 +1156,7 @@ class WebSocketChannel(BaseChannel):
|
||||
body["resuming"] = True
|
||||
if stream_end and merge_next:
|
||||
body["merge_next"] = True
|
||||
self._transcripts.prepare_and_append(
|
||||
self._persist_turn_transcript_event(
|
||||
chat_id,
|
||||
body,
|
||||
metadata=meta,
|
||||
@ -1045,6 +1175,7 @@ class WebSocketChannel(BaseChannel):
|
||||
*,
|
||||
goal_state: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
turn_owner: str | None = None,
|
||||
) -> None:
|
||||
"""Signal that the agent has fully finished processing the current turn."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
@ -1053,12 +1184,27 @@ class WebSocketChannel(BaseChannel):
|
||||
body["latency_ms"] = int(latency_ms)
|
||||
if goal_state is not None:
|
||||
body["goal_state"] = goal_state
|
||||
self._transcripts.prepare_and_append(
|
||||
canonical_webui_turn = (metadata or {}).get("webui") is True
|
||||
prior_persistence_failure = (
|
||||
canonical_webui_turn
|
||||
and websocket_turn_transcript_persistence_failed(chat_id, turn_owner)
|
||||
)
|
||||
persisted = self._persist_turn_transcript_event(
|
||||
chat_id,
|
||||
body,
|
||||
metadata=metadata,
|
||||
phase="complete",
|
||||
transcript_overrides=(
|
||||
{WEBUI_TRANSCRIPT_INCOMPLETE_KEY: True}
|
||||
if prior_persistence_failure
|
||||
else None
|
||||
),
|
||||
)
|
||||
if persisted:
|
||||
# A successful completion either has a complete transcript or now
|
||||
# carries a durable incomplete marker. The HTTP replay path can
|
||||
# recover the latter from session history after a gateway restart.
|
||||
clear_websocket_turn_if_current(chat_id, turn_owner)
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
if not conns:
|
||||
return
|
||||
@ -1081,6 +1227,7 @@ class WebSocketChannel(BaseChannel):
|
||||
status: str,
|
||||
*,
|
||||
started_at: float | None = None,
|
||||
turn_id: str | None = None,
|
||||
) -> None:
|
||||
"""Notify subscribed clients that a turn started or finished (wall-clock hint)."""
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
@ -1093,6 +1240,8 @@ class WebSocketChannel(BaseChannel):
|
||||
}
|
||||
if status == "running" and started_at is not None:
|
||||
body["started_at"] = started_at
|
||||
if turn_id:
|
||||
body["turn_id"] = turn_id
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" goal_status ")
|
||||
|
||||
@ -49,8 +49,13 @@ from nanobot.webui.http_utils import (
|
||||
from nanobot.webui.http_utils import (
|
||||
parse_request_path as _parse_request_path,
|
||||
)
|
||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
||||
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
||||
from nanobot.webui.transcript import append_transcript_object, read_transcript_lines
|
||||
from nanobot.webui.transcript import (
|
||||
append_transcript_object,
|
||||
build_webui_thread_response,
|
||||
read_transcript_lines,
|
||||
)
|
||||
|
||||
from .ws_test_client import http_get as _http_get
|
||||
|
||||
@ -164,11 +169,20 @@ async def test_start_extends_http_open_timeout_for_slow_settings_routes(
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.workspaces.get_webui_dir",
|
||||
lambda: tmp_path / "webui",
|
||||
)
|
||||
yield
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -743,6 +757,7 @@ async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path
|
||||
"chat_id": "chat-running",
|
||||
"content": "hello",
|
||||
"webui": True,
|
||||
"turn_id": "turn-scope-rejected",
|
||||
"workspace_scope": {
|
||||
"project_path": str(other),
|
||||
"access_mode": "full",
|
||||
@ -757,6 +772,7 @@ async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path
|
||||
assert payload["detail"] == "workspace_scope_rejected"
|
||||
assert payload["reason"] == "chat_running"
|
||||
assert payload["chat_id"] == "chat-running"
|
||||
assert payload["turn_id"] == "turn-scope-rejected"
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@ -1602,6 +1618,434 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("active_owner", "event_owner", "expected_cleared"),
|
||||
[
|
||||
("owner-current", "owner-current", True),
|
||||
("owner-new", "owner-old", False),
|
||||
],
|
||||
)
|
||||
async def test_turn_end_persists_and_conditionally_clears_when_fanout_fails(
|
||||
active_owner: str,
|
||||
event_owner: str,
|
||||
expected_cleared: bool,
|
||||
) -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send.side_effect = RuntimeError("fanout failed")
|
||||
chat_id = f"turn-end-failure-{expected_cleared}"
|
||||
channel._attach(mock_ws, chat_id)
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT[chat_id] = 1234.5
|
||||
wth._WEBSOCKET_TURN_OWNERS[chat_id] = active_owner
|
||||
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="fanout failed"):
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: event_owner},
|
||||
event=TurnEndEvent(),
|
||||
))
|
||||
|
||||
assert read_transcript_lines(f"websocket:{chat_id}")[-1]["event"] == "turn_end"
|
||||
assert (wth.websocket_turn_wall_started_at(chat_id) is None) is expected_cleared
|
||||
if not expected_cleared:
|
||||
assert wth._WEBSOCKET_TURN_OWNERS[chat_id] == active_owner
|
||||
finally:
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None)
|
||||
wth._WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
||||
wth._WEBSOCKET_TURN_OWNERS.pop(chat_id, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_end_keeps_registry_when_transcript_persistence_fails(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
chat_id = "turn-end-persistence-failure"
|
||||
owner = "owner-persist"
|
||||
turn_id = "turn-persist"
|
||||
inbound = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id=chat_id,
|
||||
content="hi",
|
||||
metadata={
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY: owner,
|
||||
"webui_turn_id": turn_id,
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5)
|
||||
append = MagicMock(side_effect=OSError("disk full"))
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", append)
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata={
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY: owner,
|
||||
"webui_turn_id": turn_id,
|
||||
"webui": True,
|
||||
},
|
||||
event=TurnEndEvent(),
|
||||
))
|
||||
|
||||
append.assert_called_once()
|
||||
assert wth.websocket_turn_wall_started_at(chat_id) == 1234.5
|
||||
assert wth.websocket_turn_id(chat_id) == turn_id
|
||||
assert wth._WEBSOCKET_TURN_OWNERS[chat_id] == owner
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata=dict(inbound.metadata),
|
||||
event=GoalStatusEvent(status="idle"),
|
||||
))
|
||||
|
||||
# The normal WebUI idle event follows turn_end. It must not convert a
|
||||
# failed canonical completion write into an apparently settled HTTP
|
||||
# snapshot.
|
||||
assert wth.websocket_turn_wall_started_at(chat_id) == 1234.5
|
||||
assert wth.websocket_turn_id(chat_id) == turn_id
|
||||
assert wth._WEBSOCKET_TURN_OWNERS[chat_id] == owner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_durable_incomplete_marker_stays_pending_without_safe_session_recovery(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.webui.transcript import build_webui_thread_response
|
||||
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
chat_id = "answer-persistence-failure"
|
||||
key = f"websocket:{chat_id}"
|
||||
owner = "owner-answer"
|
||||
turn_id = "turn-answer"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"event": "user", "chat_id": chat_id, "text": "question", "turn_id": turn_id},
|
||||
)
|
||||
inbound = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id=chat_id,
|
||||
content="question",
|
||||
metadata={
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY: owner,
|
||||
"webui_turn_id": turn_id,
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5)
|
||||
|
||||
original_append = append_transcript_object
|
||||
|
||||
def fail_answer(session_key: str, event: dict[str, Any]) -> None:
|
||||
if event.get("event") == "message":
|
||||
raise OSError("transient disk failure")
|
||||
original_append(session_key, event)
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", fail_answer)
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="answer",
|
||||
metadata=dict(inbound.metadata),
|
||||
))
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata=dict(inbound.metadata),
|
||||
event=TurnEndEvent(),
|
||||
))
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata=dict(inbound.metadata),
|
||||
event=GoalStatusEvent(status="idle"),
|
||||
))
|
||||
|
||||
# Simulate a gateway restart: no process-local owner survives, so the
|
||||
# persisted marker must be sufficient to reject canonical completion.
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
body = build_webui_thread_response(
|
||||
key,
|
||||
active_turn_started_at=wth.websocket_turn_wall_started_at(chat_id),
|
||||
active_turn_id=wth.websocket_turn_id(chat_id),
|
||||
active_turn_transcript_persistence_failed=(
|
||||
wth.websocket_turn_transcript_persistence_failed(chat_id)
|
||||
),
|
||||
)
|
||||
assert body is not None
|
||||
assert read_transcript_lines(key)[-1]["transcript_incomplete"] is True
|
||||
assert body["completed_turn_ids"] == []
|
||||
assert [(message["role"], message["content"]) for message in body["messages"]] == [
|
||||
("user", "question"),
|
||||
]
|
||||
assert body["has_pending_tool_calls"] is True
|
||||
assert chat_id not in wth._WEBSOCKET_TURN_OWNERS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_replay_recovers_marked_answer_from_session_after_gateway_restart(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
chat_id = "answer-recovery-after-restart"
|
||||
key = f"websocket:{chat_id}"
|
||||
owner = "owner-answer-recovery"
|
||||
turn_id = "turn-answer-recovery"
|
||||
sessions_path = tmp_path / "sessions"
|
||||
sessions = SessionManager(sessions_path)
|
||||
session = sessions.get_or_create(key)
|
||||
session.add_message("user", "question")
|
||||
session.add_message("assistant", "durable answer")
|
||||
sessions.save(session)
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": chat_id,
|
||||
"text": "question",
|
||||
"turn_id": turn_id,
|
||||
},
|
||||
)
|
||||
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions),
|
||||
)
|
||||
inbound = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id=chat_id,
|
||||
content="question",
|
||||
metadata={
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY: owner,
|
||||
"webui_turn_id": turn_id,
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5)
|
||||
|
||||
original_append = append_transcript_object
|
||||
|
||||
def fail_answer(session_key: str, event: dict[str, Any]) -> None:
|
||||
if event.get("event") == "message":
|
||||
raise OSError("transient disk failure")
|
||||
original_append(session_key, event)
|
||||
|
||||
monkeypatch.setattr("nanobot.webui.transcript.append_transcript_object", fail_answer)
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="durable answer",
|
||||
metadata=dict(inbound.metadata),
|
||||
))
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata=dict(inbound.metadata),
|
||||
event=TurnEndEvent(),
|
||||
))
|
||||
|
||||
persisted_lines = read_transcript_lines(key)
|
||||
assert persisted_lines[-1]["event"] == "turn_end"
|
||||
assert persisted_lines[-1]["transcript_incomplete"] is True
|
||||
|
||||
# Drop all process-local state and construct a fresh HTTP/session layer.
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
restarted_channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=SessionManager(sessions_path),
|
||||
),
|
||||
)
|
||||
restarted_channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
encoded_key = quote(key, safe="")
|
||||
request = Request(
|
||||
f"/api/sessions/{encoded_key}/webui-thread",
|
||||
Headers([("Authorization", "Bearer tok")]),
|
||||
)
|
||||
|
||||
response = restarted_channel.gateway.http._handle_webui_thread_get(
|
||||
request,
|
||||
encoded_key,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = json.loads(response.body.decode())
|
||||
assert [(message["role"], message["content"]) for message in body["messages"]] == [
|
||||
("user", "question"),
|
||||
("assistant", "durable answer"),
|
||||
]
|
||||
assert body["completed_turn_ids"] == [turn_id]
|
||||
assert body["has_pending_tool_calls"] is False
|
||||
assert body["active_turn_id"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_idle_clears_owner_when_no_completion_write_failed() -> None:
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
chat_id = "cancelled-webui-turn"
|
||||
owner = "owner-cancelled"
|
||||
inbound = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id=chat_id,
|
||||
content="hi",
|
||||
metadata={
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY: owner,
|
||||
"webui_turn_id": "turn-cancelled",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5)
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata=dict(inbound.metadata),
|
||||
event=GoalStatusEvent(status="idle"),
|
||||
))
|
||||
|
||||
assert wth.websocket_turn_wall_started_at(chat_id) is None
|
||||
assert wth.websocket_turn_id(chat_id) is None
|
||||
assert chat_id not in wth._WEBSOCKET_ACTIVE_TURNS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_webui_transcript_failure_does_not_block_idle_cleanup(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
chat_id = "direct-non-webui-failure"
|
||||
owner = "owner-direct"
|
||||
inbound = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="runtime",
|
||||
chat_id=chat_id,
|
||||
content="direct",
|
||||
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: owner},
|
||||
)
|
||||
await wth.publish_turn_run_status(bus, inbound, "running", started_at=1234.5)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.transcript.append_transcript_object",
|
||||
MagicMock(side_effect=OSError("disk full")),
|
||||
)
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="direct answer",
|
||||
metadata=dict(inbound.metadata),
|
||||
))
|
||||
|
||||
assert wth.websocket_turn_transcript_persistence_failed(chat_id, owner) is False
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata=dict(inbound.metadata),
|
||||
event=GoalStatusEvent(status="idle"),
|
||||
))
|
||||
assert wth.websocket_turn_wall_started_at(chat_id) is None
|
||||
assert chat_id not in wth._WEBSOCKET_ACTIVE_TURNS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_clears_matching_owner_when_fanout_fails() -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus),
|
||||
)
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send.side_effect = RuntimeError("fanout failed")
|
||||
chat_id = "idle-failure"
|
||||
owner = "owner-idle"
|
||||
channel._attach(mock_ws, chat_id)
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT[chat_id] = 1234.5
|
||||
wth._WEBSOCKET_TURN_OWNERS[chat_id] = owner
|
||||
|
||||
with pytest.raises(RuntimeError, match="fanout failed"):
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: owner},
|
||||
event=GoalStatusEvent(status="idle"),
|
||||
))
|
||||
|
||||
assert wth.websocket_turn_wall_started_at(chat_id) is None
|
||||
assert chat_id not in wth._WEBSOCKET_TURN_OWNERS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_turn_end_includes_latency_ms_when_present() -> None:
|
||||
bus = MagicMock()
|
||||
@ -1654,6 +2098,7 @@ async def test_send_goal_status_running_emits_event_with_started_at() -> None:
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
metadata={"webui_turn_id": "turn-running"},
|
||||
event=GoalStatusEvent(status="running", started_at=1_700_000_000.5),
|
||||
))
|
||||
|
||||
@ -1664,6 +2109,7 @@ async def test_send_goal_status_running_emits_event_with_started_at() -> None:
|
||||
"chat_id": "chat-1",
|
||||
"status": "running",
|
||||
"started_at": 1_700_000_000.5,
|
||||
"turn_id": "turn-running",
|
||||
}
|
||||
|
||||
|
||||
@ -1678,12 +2124,18 @@ async def test_send_goal_status_idle_omits_started_at() -> None:
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content="",
|
||||
metadata={"webui_turn_id": "turn-idle"},
|
||||
event=GoalStatusEvent(status="idle", started_at=99.0),
|
||||
))
|
||||
|
||||
mock_ws.send.assert_awaited_once()
|
||||
body = json.loads(mock_ws.send.await_args.args[0])
|
||||
assert body == {"event": "goal_status", "chat_id": "chat-1", "status": "idle"}
|
||||
assert body == {
|
||||
"event": "goal_status",
|
||||
"chat_id": "chat-1",
|
||||
"status": "idle",
|
||||
"turn_id": "turn-idle",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -2725,6 +3177,147 @@ async def test_allow_from_rejects_unauthorized_client_id(bus: MagicMock) -> None
|
||||
await server_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_connection_rejects_revoked_webui_turn_without_acceptance_ack(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
channel = _ch(bus, allowFrom=["alice"])
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"revoked-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "chat-revoked",
|
||||
"content": "must not enter the bus",
|
||||
"webui": True,
|
||||
"turn_id": "turn-revoked",
|
||||
},
|
||||
)
|
||||
|
||||
payloads = [json.loads(call.args[0]) for call in conn.send.await_args_list]
|
||||
assert payloads == [
|
||||
{
|
||||
"event": "error",
|
||||
"detail": "access_denied",
|
||||
"chat_id": "chat-revoked",
|
||||
"turn_id": "turn-revoked",
|
||||
}
|
||||
]
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_midflight_allowlist_revocation_rejects_turn_without_ack(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
channel = _ch(bus)
|
||||
channel.is_allowed = MagicMock(side_effect=[True, False])
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "chat-midflight-revoked",
|
||||
"content": "must not be acknowledged",
|
||||
"webui": True,
|
||||
"turn_id": "turn-midflight-revoked",
|
||||
},
|
||||
)
|
||||
|
||||
payloads = [json.loads(call.args[0]) for call in conn.send.await_args_list]
|
||||
assert payloads[-1] == {
|
||||
"event": "error",
|
||||
"detail": "access_denied",
|
||||
"chat_id": "chat-midflight-revoked",
|
||||
"turn_id": "turn-midflight-revoked",
|
||||
}
|
||||
assert all(payload["event"] != "message_accepted" for payload in payloads)
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorized_webui_turn_is_acked_after_bus_acceptance(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "chat-accepted",
|
||||
"content": "accepted",
|
||||
"webui": True,
|
||||
"turn_id": "turn-accepted",
|
||||
},
|
||||
)
|
||||
|
||||
bus.publish_inbound.assert_awaited_once()
|
||||
inbound = bus.publish_inbound.await_args.args[0]
|
||||
owner = inbound.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
assert wth.websocket_turn_id("chat-accepted") == "turn-accepted"
|
||||
assert wth.websocket_turn_wall_started_at("chat-accepted") is not None
|
||||
assert wth.websocket_turn_owner_is_registered(
|
||||
"chat-accepted",
|
||||
owner,
|
||||
"turn-accepted",
|
||||
)
|
||||
thread = build_webui_thread_response(
|
||||
"websocket:chat-accepted",
|
||||
active_turn_started_at=wth.websocket_turn_wall_started_at("chat-accepted"),
|
||||
active_turn_id=wth.websocket_turn_id("chat-accepted"),
|
||||
)
|
||||
assert thread is not None
|
||||
assert thread["active_turn_id"] == "turn-accepted"
|
||||
assert thread["has_pending_tool_calls"] is True
|
||||
payloads = [json.loads(call.args[0]) for call in conn.send.await_args_list]
|
||||
assert payloads[-1] == {
|
||||
"event": "message_accepted",
|
||||
"chat_id": "chat-accepted",
|
||||
"turn_id": "turn-accepted",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_side_channel_command_does_not_register_queued_turn(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
channel = _ch(bus)
|
||||
conn = AsyncMock()
|
||||
conn.remote_address = ("127.0.0.1", 50123)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
conn,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "chat-status",
|
||||
"content": "/status",
|
||||
"webui": True,
|
||||
"turn_id": "turn-status",
|
||||
},
|
||||
)
|
||||
|
||||
inbound = bus.publish_inbound.await_args.args[0]
|
||||
assert WEBSOCKET_TURN_OWNER_METADATA_KEY not in inbound.metadata
|
||||
assert wth.websocket_turn_wall_started_at("chat-status") is None
|
||||
payloads = [json.loads(call.args[0]) for call in conn.send.await_args_list]
|
||||
assert payloads[-1] == {
|
||||
"event": "message_accepted",
|
||||
"chat_id": "chat-status",
|
||||
"turn_id": "turn-status",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_id_truncation(bus: MagicMock) -> None:
|
||||
port = 29883
|
||||
@ -3238,6 +3831,255 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
|
||||
assert len(body["messages"]) == 1
|
||||
assert body["messages"][0]["role"] == "user"
|
||||
assert body["messages"][0]["content"] == "hi"
|
||||
assert body["has_pending_tool_calls"] is False
|
||||
|
||||
|
||||
def test_handle_webui_thread_get_reports_registered_turn_as_pending(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
from nanobot.webui.transcript import append_transcript_object
|
||||
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.webui_turns.websocket_turn_wall_started_at",
|
||||
lambda chat_id: 1_700_000_000.0 if chat_id == "running" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.webui_turns.websocket_turn_id",
|
||||
lambda chat_id: "turn-running" if chat_id == "running" else None,
|
||||
)
|
||||
key = "websocket:running"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "running",
|
||||
"text": "hi",
|
||||
"turn_id": "turn-running",
|
||||
},
|
||||
)
|
||||
bus = MagicMock()
|
||||
channel = _ch(bus)
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
enc = quote(key, safe="")
|
||||
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
|
||||
|
||||
resp = channel.gateway.http._handle_webui_thread_get(req, enc)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body.decode())
|
||||
assert body["messages"][0]["content"] == "hi"
|
||||
assert body["has_pending_tool_calls"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_registry_stays_pending_until_turn_end_is_persisted(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.webui.transcript import append_transcript_object
|
||||
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:idle-order"
|
||||
turn_id = "turn-idle-order"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "idle-order",
|
||||
"text": "hi",
|
||||
"turn_id": turn_id,
|
||||
},
|
||||
)
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
inbound = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id="idle-order",
|
||||
content="hi",
|
||||
metadata={"webui_turn_id": turn_id},
|
||||
)
|
||||
channel = _ch(bus)
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
enc = quote(key, safe="")
|
||||
request = Request(
|
||||
f"/api/sessions/{enc}/webui-thread",
|
||||
Headers([("Authorization", "Bearer tok")]),
|
||||
)
|
||||
|
||||
try:
|
||||
await wth.publish_turn_run_status(bus, inbound, "running")
|
||||
await wth.publish_turn_run_status(bus, inbound, "idle")
|
||||
|
||||
before_delivery = channel.gateway.http._handle_webui_thread_get(request, enc)
|
||||
assert json.loads(before_delivery.body.decode())["has_pending_tool_calls"] is True
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="idle-order",
|
||||
content="",
|
||||
metadata=dict(inbound.metadata),
|
||||
event=TurnEndEvent(),
|
||||
))
|
||||
|
||||
after_delivery = channel.gateway.http._handle_webui_thread_get(request, enc)
|
||||
assert json.loads(after_delivery.body.decode())["has_pending_tool_calls"] is False
|
||||
assert wth.websocket_turn_wall_started_at("idle-order") is None
|
||||
assert wth.websocket_turn_id("idle-order") is None
|
||||
finally:
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop("idle-order", None)
|
||||
wth._WEBSOCKET_TURN_IDS.pop("idle-order", None)
|
||||
wth._WEBSOCKET_TURN_OWNERS.pop("idle-order", None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_thread_api_restores_older_owner_after_latest_completes() -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
chat_id = "concurrent-projection"
|
||||
key = f"websocket:{chat_id}"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": chat_id,
|
||||
"text": "first",
|
||||
"turn_id": "turn-first",
|
||||
},
|
||||
)
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
first = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id=chat_id,
|
||||
content="first",
|
||||
metadata={
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY: "owner-first",
|
||||
"webui_turn_id": "turn-first",
|
||||
},
|
||||
session_key_override="websocket:session-first",
|
||||
)
|
||||
second = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id=chat_id,
|
||||
content="second",
|
||||
metadata={
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY: "owner-second",
|
||||
"webui_turn_id": "turn-second",
|
||||
},
|
||||
session_key_override="websocket:session-second",
|
||||
)
|
||||
await wth.publish_turn_run_status(bus, first, "running", started_at=100.0)
|
||||
await wth.publish_turn_run_status(bus, second, "running", started_at=200.0)
|
||||
assert wth.clear_websocket_turn_if_current(chat_id, "owner-second") is True
|
||||
|
||||
channel = _ch(bus)
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
enc = quote(key, safe="")
|
||||
request = Request(
|
||||
f"/api/sessions/{enc}/webui-thread",
|
||||
Headers([("Authorization", "Bearer tok")]),
|
||||
)
|
||||
response = channel.gateway.http._handle_webui_thread_get(request, enc)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.body.decode())
|
||||
assert payload["has_pending_tool_calls"] is True
|
||||
assert wth.websocket_turn_wall_started_at(chat_id) == 100.0
|
||||
assert wth.websocket_turn_id(chat_id) == "turn-first"
|
||||
assert wth._WEBSOCKET_TURN_OWNERS[chat_id] == "owner-first"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("active_turn_id", "expected_pending"),
|
||||
[
|
||||
("turn-complete", False),
|
||||
("turn-next", True),
|
||||
],
|
||||
)
|
||||
def test_handle_webui_thread_get_reconciles_registered_turn_with_turn_end(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
active_turn_id: str,
|
||||
expected_pending: bool,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
from nanobot.webui.transcript import append_transcript_object
|
||||
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.webui_turns.websocket_turn_wall_started_at",
|
||||
lambda chat_id: 1_700_000_000.0 if chat_id == "running" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.session.webui_turns.websocket_turn_id",
|
||||
lambda chat_id: active_turn_id if chat_id == "running" else None,
|
||||
)
|
||||
key = "websocket:running"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "running",
|
||||
"text": "hi",
|
||||
"turn_id": "turn-complete",
|
||||
},
|
||||
)
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "running",
|
||||
"text": "done",
|
||||
"turn_id": "turn-complete",
|
||||
},
|
||||
)
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "running",
|
||||
"turn_id": "turn-complete",
|
||||
},
|
||||
)
|
||||
bus = MagicMock()
|
||||
channel = _ch(bus)
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
enc = quote(key, safe="")
|
||||
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
|
||||
|
||||
resp = channel.gateway.http._handle_webui_thread_get(req, enc)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body.decode())
|
||||
assert body["messages"][-1]["content"] == "done"
|
||||
assert body["has_pending_tool_calls"] is expected_pending
|
||||
assert body["active_turn_id"] == active_turn_id
|
||||
|
||||
|
||||
def test_handle_webui_thread_get_accepts_pagination_query(tmp_path, monkeypatch) -> None:
|
||||
|
||||
@ -19,6 +19,7 @@ from nanobot.channels.websocket.runtime import (
|
||||
WebSocketChannel,
|
||||
WebSocketConfig,
|
||||
)
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.webui.gateway_services import build_gateway_services
|
||||
|
||||
|
||||
@ -59,6 +60,19 @@ def _make_channel() -> WebSocketChannel:
|
||||
return channel
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_websocket_turn_state() -> None:
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
yield
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
# -- max_message_bytes bump ----------------------------------------------------
|
||||
|
||||
|
||||
@ -94,6 +108,28 @@ async def test_message_without_media_backward_compatible() -> None:
|
||||
assert call.kwargs["media"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_message_acceptance_echoes_turn_id() -> None:
|
||||
channel = _make_channel()
|
||||
mock_conn = AsyncMock()
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "hello",
|
||||
"webui": True,
|
||||
"turn_id": "turn-accepted",
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
|
||||
channel._handle_message.assert_awaited_once()
|
||||
assert json.loads(mock_conn.send.await_args.args[0]) == {
|
||||
"event": "message_accepted",
|
||||
"chat_id": "abc123",
|
||||
"turn_id": "turn-accepted",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_text_policy_is_independent_from_transport_limit() -> None:
|
||||
channel = _make_channel()
|
||||
@ -102,6 +138,7 @@ async def test_message_text_policy_is_independent_from_transport_limit() -> None
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "你" * 22_000,
|
||||
"turn_id": "turn-text-policy",
|
||||
}
|
||||
|
||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
||||
@ -113,6 +150,7 @@ async def test_message_text_policy_is_independent_from_transport_limit() -> None
|
||||
"chat_id": "abc123",
|
||||
"detail": "message_rejected",
|
||||
"reason": "text_too_large",
|
||||
"turn_id": "turn-text-policy",
|
||||
}
|
||||
|
||||
|
||||
@ -235,6 +273,7 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None:
|
||||
"chat_id": "abc123",
|
||||
"content": "hi",
|
||||
"media": [{"data_url": _tiny_png_data_url()}] * 5,
|
||||
"turn_id": "turn-attachments",
|
||||
}
|
||||
|
||||
with patch(
|
||||
@ -246,8 +285,10 @@ async def test_message_rejected_when_more_than_four_images(tmp_path) -> None:
|
||||
mock_conn.send.assert_awaited_once()
|
||||
err = json.loads(mock_conn.send.call_args[0][0])
|
||||
assert err["event"] == "error"
|
||||
assert err["chat_id"] == "abc123"
|
||||
assert err["detail"] == "attachment_rejected"
|
||||
assert err["reason"] == "too_many_images"
|
||||
assert err["turn_id"] == "turn-attachments"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -53,9 +53,19 @@ async def test_hydrate_after_subscribe_pushes_running_when_turn_active():
|
||||
channel.send_goal_state = mock_send_goal_state
|
||||
channel.send_goal_status = mock_send_goal_status
|
||||
|
||||
with patch("nanobot.channels.websocket.runtime.websocket_turn_wall_started_at", return_value=1234567890.0):
|
||||
with (
|
||||
patch(
|
||||
"nanobot.channels.websocket.runtime.websocket_turn_wall_started_at",
|
||||
return_value=1234567890.0,
|
||||
),
|
||||
patch(
|
||||
"nanobot.channels.websocket.runtime.websocket_turn_id",
|
||||
return_value="turn-active",
|
||||
),
|
||||
):
|
||||
await channel._hydrate_after_subscribe("test-chat")
|
||||
|
||||
running_events = [e for e in sent_events if e[0] == "goal_status" and e[2] == "running"]
|
||||
assert len(running_events) == 1
|
||||
assert running_events[0][3]["started_at"] == 1234567890.0
|
||||
assert running_events[0][3]["turn_id"] == "turn-active"
|
||||
|
||||
@ -14,7 +14,7 @@ from typing import Literal
|
||||
from nanobot import __version__
|
||||
from nanobot.agent.goal_permission import goal_mutation_permission
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
from nanobot.command.router import CommandContext, CommandRouter, normalize_command_text
|
||||
from nanobot.utils.helpers import build_status_content
|
||||
from nanobot.utils.restart import set_restart_notice_to_env
|
||||
from nanobot.utils.workspace_prompts import initialize_workspace_prompt
|
||||
@ -180,6 +180,21 @@ def builtin_command_palette() -> list[dict[str, str | bool]]:
|
||||
return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS]
|
||||
|
||||
|
||||
def builtin_command_starts_agent_turn(text: str) -> bool:
|
||||
"""Return whether WebUI ingress should expect a normal agent lifecycle."""
|
||||
normalized = normalize_command_text(text)
|
||||
command, separator, args = normalized.partition(" ")
|
||||
spec = next(
|
||||
(item for item in BUILTIN_COMMAND_SPECS if item.command == command.lower()),
|
||||
None,
|
||||
)
|
||||
if spec is None or (separator and not spec.accepts_args):
|
||||
return True
|
||||
if spec.lifecycle == "agent_turn":
|
||||
return True
|
||||
return spec.lifecycle == "agent_turn_with_args" and bool(args.strip())
|
||||
|
||||
|
||||
async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||
"""Cancel all active tasks and subagents for the session."""
|
||||
loop = ctx.loop
|
||||
|
||||
@ -42,7 +42,10 @@ from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.helpers import strip_think, truncate_text
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
|
||||
from nanobot.webui.metadata import (
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
|
||||
WEBUI_SESSION_METADATA_KEY = "webui"
|
||||
WEBUI_TITLE_METADATA_KEY = "title"
|
||||
@ -51,9 +54,42 @@ TITLE_MAX_CHARS = 60
|
||||
TITLE_GENERATION_MAX_TOKENS = 96
|
||||
TITLE_GENERATION_REASONING_EFFORT = "none"
|
||||
|
||||
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
|
||||
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
|
||||
# Latest active turn projection per ``chat_id`` (websocket only). It survives browser refresh
|
||||
# while the gateway process stays up and is implicitly dropped on restart.
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
|
||||
_WEBSOCKET_TURN_IDS: dict[str, str] = {}
|
||||
_WEBSOCKET_TURN_OWNERS: dict[str, str] = {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _WebsocketTurn:
|
||||
started_at: float
|
||||
turn_id: str | None
|
||||
transcript_persistence_failed: bool = False
|
||||
|
||||
|
||||
# All in-flight lifecycle owners per chat, in admission order. The three maps
|
||||
# above remain the latest-owner projection consumed by the HTTP API.
|
||||
_WEBSOCKET_ACTIVE_TURNS: dict[str, dict[str, _WebsocketTurn]] = {}
|
||||
|
||||
|
||||
def _sync_websocket_turn_projection(chat_id: str) -> None:
|
||||
turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id)
|
||||
if not turns:
|
||||
_WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None)
|
||||
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
||||
_WEBSOCKET_TURN_OWNERS.pop(chat_id, None)
|
||||
return
|
||||
|
||||
owner = next(reversed(turns))
|
||||
turn = turns[owner]
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT[chat_id] = turn.started_at
|
||||
_WEBSOCKET_TURN_OWNERS[chat_id] = owner
|
||||
if turn.turn_id is None:
|
||||
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
||||
else:
|
||||
_WEBSOCKET_TURN_IDS[chat_id] = turn.turn_id
|
||||
|
||||
|
||||
def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
|
||||
@ -203,6 +239,96 @@ def websocket_turn_wall_started_at(chat_id: str) -> float | None:
|
||||
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
|
||||
|
||||
|
||||
def websocket_turn_id(chat_id: str) -> str | None:
|
||||
"""Return the WebUI identity of the active turn, when one was provided."""
|
||||
return _WEBSOCKET_TURN_IDS.get(chat_id)
|
||||
|
||||
|
||||
def register_queued_websocket_turn_if_idle(
|
||||
chat_id: str,
|
||||
turn_id: str | None,
|
||||
) -> str | None:
|
||||
"""Track an accepted WebUI turn while it waits for AgentLoop admission."""
|
||||
if websocket_turn_wall_started_at(chat_id) is not None:
|
||||
return None
|
||||
owner = uuid4().hex
|
||||
_WEBSOCKET_ACTIVE_TURNS.setdefault(chat_id, {})[owner] = _WebsocketTurn(
|
||||
started_at=time.time(),
|
||||
turn_id=turn_id,
|
||||
)
|
||||
_sync_websocket_turn_projection(chat_id)
|
||||
return owner
|
||||
|
||||
|
||||
def websocket_turn_owner_is_registered(
|
||||
chat_id: str,
|
||||
owner: str,
|
||||
turn_id: str | None,
|
||||
) -> bool:
|
||||
"""Return whether websocket ingress registered this owner for the turn."""
|
||||
turn = _WEBSOCKET_ACTIVE_TURNS.get(chat_id, {}).get(owner)
|
||||
return turn is not None and turn.turn_id == turn_id
|
||||
|
||||
|
||||
def websocket_turn_transcript_persistence_failed(
|
||||
chat_id: str,
|
||||
owner: str | None = None,
|
||||
) -> bool:
|
||||
"""Return whether one active owner has an incomplete canonical transcript."""
|
||||
turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id)
|
||||
if not turns:
|
||||
return False
|
||||
selected_owner = owner or next(reversed(turns))
|
||||
turn = turns.get(selected_owner)
|
||||
return turn.transcript_persistence_failed if turn is not None else False
|
||||
|
||||
|
||||
def mark_websocket_turn_transcript_persistence_failed(
|
||||
chat_id: str,
|
||||
owner: str | None,
|
||||
) -> bool:
|
||||
"""Keep a turn active when any canonical display event could not be written."""
|
||||
if not owner:
|
||||
return False
|
||||
turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id)
|
||||
if turns is None or owner not in turns:
|
||||
return False
|
||||
turns[owner] = replace(turns[owner], transcript_persistence_failed=True)
|
||||
return True
|
||||
|
||||
|
||||
def clear_websocket_turn_if_current(
|
||||
chat_id: str,
|
||||
owner: str | None,
|
||||
*,
|
||||
preserve_persistence_failure: bool = False,
|
||||
) -> bool:
|
||||
"""Clear one lifecycle owner without disturbing concurrent turns for the chat."""
|
||||
if not owner:
|
||||
return False
|
||||
turns = _WEBSOCKET_ACTIVE_TURNS.get(chat_id)
|
||||
if turns is not None:
|
||||
if owner not in turns:
|
||||
return False
|
||||
if preserve_persistence_failure and turns[owner].transcript_persistence_failed:
|
||||
return False
|
||||
turns.pop(owner)
|
||||
_sync_websocket_turn_projection(chat_id)
|
||||
return True
|
||||
|
||||
# Compatibility for callers/tests that populated the legacy projection
|
||||
# directly before the multi-owner registry existed.
|
||||
if (
|
||||
chat_id in _WEBSOCKET_TURN_WALL_STARTED_AT
|
||||
and _WEBSOCKET_TURN_OWNERS.get(chat_id) == owner
|
||||
):
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None)
|
||||
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
||||
_WEBSOCKET_TURN_OWNERS.pop(chat_id, None)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_bus_progress_callback(
|
||||
bus: MessageBus,
|
||||
msg: InboundMessage,
|
||||
@ -229,9 +355,17 @@ async def publish_turn_run_status(
|
||||
else:
|
||||
t0 = time.time()
|
||||
started_at_event = t0
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
|
||||
else:
|
||||
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
|
||||
owner = msg.metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
if not isinstance(owner, str) or not owner:
|
||||
owner = uuid4().hex
|
||||
msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = owner
|
||||
turn_id = msg.metadata.get(WEBUI_TURN_METADATA_KEY)
|
||||
current_turn_id = turn_id if isinstance(turn_id, str) and turn_id else None
|
||||
turns = _WEBSOCKET_ACTIVE_TURNS.setdefault(cid, {})
|
||||
# Re-registration makes this owner the latest projection.
|
||||
turns.pop(owner, None)
|
||||
turns[owner] = _WebsocketTurn(started_at=t0, turn_id=current_turn_id)
|
||||
_sync_websocket_turn_projection(cid)
|
||||
await bus.publish_outbound(
|
||||
outbound_message_for_event(
|
||||
channel=msg.channel,
|
||||
@ -254,25 +388,50 @@ class WebuiTurnRoutePolicy:
|
||||
route: TurnRoute,
|
||||
) -> TurnRoute:
|
||||
"""Make an independently dispatched late subagent result visible in WebUI."""
|
||||
routed = route
|
||||
if (
|
||||
msg.channel != "system"
|
||||
or msg.sender_id != "subagent"
|
||||
or msg.metadata.get("injected_event") != "subagent_result"
|
||||
or route.channel != "websocket"
|
||||
msg.channel == "system"
|
||||
and msg.sender_id == "subagent"
|
||||
and msg.metadata.get("injected_event") == "subagent_result"
|
||||
and route.channel == "websocket"
|
||||
):
|
||||
return route
|
||||
session = self.sessions.get_or_create(session_key)
|
||||
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is True:
|
||||
metadata = dict(route.metadata)
|
||||
metadata.update({
|
||||
WEBUI_SESSION_METADATA_KEY: True,
|
||||
"_wants_stream": True,
|
||||
WEBUI_TURN_METADATA_KEY: f"subagent:{uuid4().hex}",
|
||||
})
|
||||
routed = replace(route, metadata=metadata, publish_lifecycle=True)
|
||||
|
||||
session = self.sessions.get_or_create(session_key)
|
||||
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
||||
return route
|
||||
if routed.channel == "websocket" and routed.publish_lifecycle:
|
||||
metadata = dict(routed.metadata)
|
||||
turn_id = metadata.get(WEBUI_TURN_METADATA_KEY)
|
||||
current_turn_id = turn_id if isinstance(turn_id, str) and turn_id else None
|
||||
queued_owner = metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
owner = (
|
||||
queued_owner
|
||||
if (
|
||||
msg.channel == "websocket"
|
||||
and isinstance(queued_owner, str)
|
||||
and websocket_turn_owner_is_registered(
|
||||
str(msg.chat_id),
|
||||
queued_owner,
|
||||
current_turn_id,
|
||||
)
|
||||
)
|
||||
else uuid4().hex
|
||||
)
|
||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = owner
|
||||
routed = replace(routed, metadata=metadata)
|
||||
# Direct websocket turns publish their final idle transition from
|
||||
# the original input message. Carry the same server-owned identity
|
||||
# there, overwriting any untrusted client-supplied value.
|
||||
if msg.channel == "websocket":
|
||||
msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = owner
|
||||
|
||||
metadata = dict(route.metadata)
|
||||
metadata.update({
|
||||
WEBUI_SESSION_METADATA_KEY: True,
|
||||
"_wants_stream": True,
|
||||
WEBUI_TURN_METADATA_KEY: f"subagent:{uuid4().hex}",
|
||||
})
|
||||
return replace(route, metadata=metadata, publish_lifecycle=True)
|
||||
return routed
|
||||
|
||||
|
||||
def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver:
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
"""Shared WebUI metadata keys."""
|
||||
|
||||
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY = "_websocket_turn_owner"
|
||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
||||
|
||||
@ -25,6 +25,7 @@ from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN
|
||||
|
||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
|
||||
WEBUI_FORK_MARKER_EVENT = "fork_marker"
|
||||
WEBUI_TRANSCRIPT_INCOMPLETE_KEY = "transcript_incomplete"
|
||||
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
|
||||
_TARGET_ACTIVE_TRANSCRIPT_BYTES = _MAX_TRANSCRIPT_FILE_BYTES // 2
|
||||
_TRANSCRIPT_SEGMENT_MANIFEST_VERSION = 2
|
||||
@ -151,6 +152,12 @@ class _TranscriptChunkRef(NamedTuple):
|
||||
user_count: int
|
||||
|
||||
|
||||
class _SessionBackfillTurn(NamedTuple):
|
||||
user_event: dict[str, Any]
|
||||
assistant_signature: tuple[str, ...]
|
||||
assistant_records: tuple[dict[str, Any], ...]
|
||||
|
||||
|
||||
def _record_json_line(record: dict[str, Any]) -> str:
|
||||
return json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
@ -665,7 +672,7 @@ class WebUITranscriptRecorder:
|
||||
phase: str | None = None,
|
||||
include_source: bool = False,
|
||||
transcript_overrides: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
self.prepare_event(
|
||||
chat_id,
|
||||
event,
|
||||
@ -676,7 +683,7 @@ class WebUITranscriptRecorder:
|
||||
record = dict(event)
|
||||
if transcript_overrides:
|
||||
record.update(transcript_overrides)
|
||||
self.append(chat_id, record)
|
||||
return self.append(chat_id, record)
|
||||
|
||||
def append_user_message(
|
||||
self,
|
||||
@ -687,9 +694,9 @@ class WebUITranscriptRecorder:
|
||||
media_paths: list[str] | None = None,
|
||||
cli_apps: list[dict[str, Any]] | None = None,
|
||||
mcp_presets: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
if text.strip() == "/stop" and not media_paths:
|
||||
return
|
||||
return False
|
||||
payload = build_user_transcript_event(
|
||||
chat_id,
|
||||
text,
|
||||
@ -698,15 +705,17 @@ class WebUITranscriptRecorder:
|
||||
mcp_presets=mcp_presets,
|
||||
)
|
||||
if payload is None:
|
||||
return
|
||||
self.prepare_and_append(chat_id, payload, metadata=metadata, phase="user")
|
||||
return False
|
||||
return self.prepare_and_append(chat_id, payload, metadata=metadata, phase="user")
|
||||
|
||||
def append(self, chat_id: str, event: dict[str, Any]) -> None:
|
||||
def append(self, chat_id: str, event: dict[str, Any]) -> bool:
|
||||
try:
|
||||
dup = json.loads(json.dumps(event, ensure_ascii=False))
|
||||
append_transcript_object(f"websocket:{chat_id}", dup)
|
||||
except (OSError, ValueError, TypeError) as e:
|
||||
self._log.warning("webui transcript append failed: {}", e)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _next_turn_seq(self, chat_id: str, turn_id: str) -> int:
|
||||
key = (chat_id, turn_id)
|
||||
@ -921,32 +930,69 @@ def _assistant_text_signature(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _session_assistant_event(
|
||||
session_key: str,
|
||||
message: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
if message.get("role") != "assistant" or is_hidden_history_message(message):
|
||||
return None
|
||||
message = public_history_message(message)
|
||||
content = message.get("content")
|
||||
text = content if isinstance(content, str) else ""
|
||||
media = message.get("media")
|
||||
media_paths = [str(path) for path in media] if isinstance(media, list) else []
|
||||
media_paths = [path for path in media_paths if path]
|
||||
if not text.strip() and not media_paths:
|
||||
return None
|
||||
chat_id = session_key.split(":", 1)[1] if ":" in session_key else session_key
|
||||
event: dict[str, Any] = {
|
||||
"event": "message",
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
}
|
||||
if media_paths:
|
||||
event["media"] = media_paths
|
||||
latency_ms = message.get("latency_ms")
|
||||
if isinstance(latency_ms, int | float) and latency_ms >= 0:
|
||||
event["latency_ms"] = int(latency_ms)
|
||||
return event
|
||||
|
||||
|
||||
def _session_backfill_turns(
|
||||
session_key: str,
|
||||
session_messages: list[dict[str, Any]],
|
||||
) -> list[tuple[dict[str, Any], tuple[str, ...]]]:
|
||||
turns: list[tuple[dict[str, Any], tuple[str, ...]]] = []
|
||||
) -> list[_SessionBackfillTurn]:
|
||||
turns: list[_SessionBackfillTurn] = []
|
||||
current_user: dict[str, Any] | None = None
|
||||
assistant_texts: list[str] = []
|
||||
assistant_records: list[dict[str, Any]] = []
|
||||
|
||||
def flush() -> None:
|
||||
if current_user is None:
|
||||
if current_user is None or not assistant_records:
|
||||
return
|
||||
signature = tuple(text for text in assistant_texts if text)
|
||||
if signature:
|
||||
turns.append((current_user, signature))
|
||||
signature = tuple(
|
||||
text
|
||||
for record in assistant_records
|
||||
if (text := _assistant_text_signature(record.get("text")))
|
||||
)
|
||||
turns.append(
|
||||
_SessionBackfillTurn(
|
||||
current_user,
|
||||
signature,
|
||||
tuple(dict(record) for record in assistant_records),
|
||||
)
|
||||
)
|
||||
|
||||
for message in session_messages:
|
||||
role = message.get("role")
|
||||
if role == "user":
|
||||
flush()
|
||||
current_user = _session_user_event(session_key, message)
|
||||
assistant_texts = []
|
||||
assistant_records = []
|
||||
continue
|
||||
if role == "assistant" and current_user is not None:
|
||||
text = _assistant_text_signature(message.get("content"))
|
||||
if text:
|
||||
assistant_texts.append(text)
|
||||
record = _session_assistant_event(session_key, message)
|
||||
if record is not None:
|
||||
assistant_records.append(record)
|
||||
flush()
|
||||
return turns
|
||||
|
||||
@ -976,7 +1022,7 @@ def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...]
|
||||
|
||||
|
||||
def _find_unique_session_turn(
|
||||
session_turns: list[tuple[dict[str, Any], tuple[str, ...]]],
|
||||
session_turns: list[_SessionBackfillTurn],
|
||||
signature: tuple[str, ...],
|
||||
start: int,
|
||||
) -> int | None:
|
||||
@ -984,7 +1030,7 @@ def _find_unique_session_turn(
|
||||
return None
|
||||
found: int | None = None
|
||||
for index in range(start, len(session_turns)):
|
||||
if session_turns[index][1] != signature:
|
||||
if session_turns[index].assistant_signature != signature:
|
||||
continue
|
||||
if found is not None:
|
||||
return None
|
||||
@ -992,6 +1038,101 @@ def _find_unique_session_turn(
|
||||
return found
|
||||
|
||||
|
||||
def _user_recovery_signature(event: dict[str, Any]) -> str:
|
||||
fields = {
|
||||
key: event[key]
|
||||
for key in ("text", "media_paths", "cli_apps", "mcp_presets")
|
||||
if key in event
|
||||
}
|
||||
return json.dumps(fields, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _find_unique_session_turn_by_user(
|
||||
session_turns: list[_SessionBackfillTurn],
|
||||
user_event: dict[str, Any],
|
||||
) -> _SessionBackfillTurn | None:
|
||||
signature = _user_recovery_signature(user_event)
|
||||
matches = [
|
||||
turn
|
||||
for turn in session_turns
|
||||
if _user_recovery_signature(turn.user_event) == signature
|
||||
]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _is_recoverable_answer_record(record: dict[str, Any]) -> bool:
|
||||
event = record.get("event")
|
||||
if event in {"delta", "stream_end"}:
|
||||
return True
|
||||
return event == "message" and record.get("kind") not in {
|
||||
"tool_hint",
|
||||
"progress",
|
||||
"reasoning",
|
||||
}
|
||||
|
||||
|
||||
def recover_incomplete_turns_from_session(
|
||||
lines: list[dict[str, Any]],
|
||||
session_messages: list[dict[str, Any]] | None,
|
||||
*,
|
||||
session_key: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Recover marked transcript answers only when one durable session turn matches."""
|
||||
if not lines or not session_messages:
|
||||
return lines
|
||||
session_turns = _session_backfill_turns(session_key, session_messages)
|
||||
if not session_turns:
|
||||
return lines
|
||||
|
||||
recovered: list[dict[str, Any]] = []
|
||||
for turn in _split_transcript_turns(lines):
|
||||
turn_end = turn[-1] if turn else None
|
||||
if (
|
||||
not isinstance(turn_end, dict)
|
||||
or turn_end.get("event") != "turn_end"
|
||||
or turn_end.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is not True
|
||||
):
|
||||
recovered.extend(turn)
|
||||
continue
|
||||
|
||||
user_events = [record for record in turn if record.get("event") == "user"]
|
||||
if len(user_events) != 1:
|
||||
recovered.extend(turn)
|
||||
continue
|
||||
session_turn = _find_unique_session_turn_by_user(session_turns, user_events[0])
|
||||
if session_turn is None or not session_turn.assistant_records:
|
||||
recovered.extend(turn)
|
||||
continue
|
||||
|
||||
stable_end_ms = _valid_created_at_ms(turn_end.get("created_at_ms"))
|
||||
turn_id = turn_end.get("turn_id")
|
||||
answer_records: list[dict[str, Any]] = []
|
||||
for index, source in enumerate(session_turn.assistant_records):
|
||||
answer = dict(source)
|
||||
if isinstance(turn_id, str) and turn_id:
|
||||
answer["turn_id"] = turn_id
|
||||
answer["turn_phase"] = "answer"
|
||||
if stable_end_ms is not None:
|
||||
answer["created_at_ms"] = max(
|
||||
0,
|
||||
stable_end_ms - len(session_turn.assistant_records) + index,
|
||||
)
|
||||
answer_records.append(answer)
|
||||
|
||||
# Session history is the durable source of the completed answer. Keep
|
||||
# traces/reasoning/file edits, but replace any partial answer fragments.
|
||||
recovered.extend(
|
||||
record
|
||||
for record in turn[:-1]
|
||||
if not _is_recoverable_answer_record(record)
|
||||
)
|
||||
recovered.extend(answer_records)
|
||||
completed_end = dict(turn_end)
|
||||
completed_end.pop(WEBUI_TRANSCRIPT_INCOMPLETE_KEY, None)
|
||||
recovered.append(completed_end)
|
||||
return recovered
|
||||
|
||||
|
||||
def _with_backfilled_user(
|
||||
records: list[dict[str, Any]],
|
||||
user_event: dict[str, Any],
|
||||
@ -1972,8 +2113,36 @@ def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool:
|
||||
def has_pending_tool_calls(
|
||||
lines: list[dict[str, Any]],
|
||||
*,
|
||||
active_turn_started_at: float | None = None,
|
||||
active_turn_id: str | None = None,
|
||||
active_turn_transcript_persistence_failed: bool = False,
|
||||
) -> bool:
|
||||
"""Return True when the selected transcript tail looks like an unfinished turn."""
|
||||
# An older canonical turn can remain unsafe even after a later turn
|
||||
# completes. Recovery removes this marker only after matching durable
|
||||
# session history, so no later turn_end may hide it.
|
||||
if any(
|
||||
rec.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is True
|
||||
for rec in lines
|
||||
):
|
||||
return True
|
||||
if active_turn_started_at is not None:
|
||||
if active_turn_transcript_persistence_failed:
|
||||
return True
|
||||
if active_turn_id is None:
|
||||
return True
|
||||
for rec in reversed(lines):
|
||||
transcript_turn_id = rec.get("turn_id")
|
||||
if not isinstance(transcript_turn_id, str) or not transcript_turn_id:
|
||||
continue
|
||||
if transcript_turn_id != active_turn_id:
|
||||
return True
|
||||
return rec.get("event") != "turn_end"
|
||||
return True
|
||||
|
||||
for rec in reversed(lines):
|
||||
ev = rec.get("event")
|
||||
if ev == "turn_end":
|
||||
@ -1995,6 +2164,24 @@ def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def completed_turn_ids(lines: list[dict[str, Any]]) -> list[str]:
|
||||
"""Return stable identities for turns with an explicitly persisted completion."""
|
||||
completed: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for rec in lines:
|
||||
if (
|
||||
rec.get("event") != "turn_end"
|
||||
or rec.get(WEBUI_TRANSCRIPT_INCOMPLETE_KEY) is True
|
||||
):
|
||||
continue
|
||||
turn_id = rec.get("turn_id")
|
||||
if not isinstance(turn_id, str) or not turn_id or turn_id in seen:
|
||||
continue
|
||||
seen.add(turn_id)
|
||||
completed.append(turn_id)
|
||||
return completed
|
||||
|
||||
|
||||
def build_webui_thread_response(
|
||||
session_key: str,
|
||||
*,
|
||||
@ -2002,6 +2189,9 @@ def build_webui_thread_response(
|
||||
augment_assistant_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
||||
augment_assistant_text: Callable[[str], str] | None = None,
|
||||
session_messages: list[dict[str, Any]] | None = None,
|
||||
active_turn_started_at: float | None = None,
|
||||
active_turn_id: str | None = None,
|
||||
active_turn_transcript_persistence_failed: bool = False,
|
||||
limit: int | None = None,
|
||||
direction: str | None = None,
|
||||
before: str | None = None,
|
||||
@ -2013,9 +2203,14 @@ def build_webui_thread_response(
|
||||
lines, page = _select_transcript_page(session_key, limit=limit, before=before)
|
||||
else:
|
||||
lines = read_transcript_lines(session_key)
|
||||
if not lines:
|
||||
if not lines and active_turn_started_at is None:
|
||||
return None
|
||||
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
||||
lines = recover_incomplete_turns_from_session(
|
||||
lines,
|
||||
session_messages,
|
||||
session_key=session_key,
|
||||
)
|
||||
fork_boundary = fork_boundary_message_count(lines)
|
||||
msgs = replay_transcript_to_ui_messages(
|
||||
lines,
|
||||
@ -2027,7 +2222,16 @@ def build_webui_thread_response(
|
||||
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||
"sessionKey": session_key,
|
||||
"messages": msgs,
|
||||
"has_pending_tool_calls": has_pending_tool_calls(lines),
|
||||
"completed_turn_ids": completed_turn_ids(lines),
|
||||
"has_pending_tool_calls": has_pending_tool_calls(
|
||||
lines,
|
||||
active_turn_started_at=active_turn_started_at,
|
||||
active_turn_id=active_turn_id,
|
||||
active_turn_transcript_persistence_failed=(
|
||||
active_turn_transcript_persistence_failed
|
||||
),
|
||||
),
|
||||
"active_turn_id": active_turn_id,
|
||||
}
|
||||
if page is not None:
|
||||
page["loaded_message_count"] = len(msgs)
|
||||
|
||||
@ -474,6 +474,18 @@ class GatewayHTTPHandler:
|
||||
if direction is not None and direction not in {"latest"}:
|
||||
return _http_error(400, "invalid direction")
|
||||
before = _query_first(query, "before")
|
||||
from nanobot.session.webui_turns import (
|
||||
websocket_turn_id,
|
||||
websocket_turn_transcript_persistence_failed,
|
||||
websocket_turn_wall_started_at,
|
||||
)
|
||||
|
||||
chat_id = decoded_key.split(":", 1)[1]
|
||||
active_turn_started_at = websocket_turn_wall_started_at(chat_id)
|
||||
active_turn_id = websocket_turn_id(chat_id)
|
||||
active_turn_transcript_persistence_failed = (
|
||||
websocket_turn_transcript_persistence_failed(chat_id)
|
||||
)
|
||||
data = build_webui_thread_response(
|
||||
decoded_key,
|
||||
augment_user_media=self.media.augment_transcript_media,
|
||||
@ -483,6 +495,11 @@ class GatewayHTTPHandler:
|
||||
workspace_path=scope.project_path,
|
||||
),
|
||||
session_messages=session_messages,
|
||||
active_turn_started_at=active_turn_started_at,
|
||||
active_turn_id=active_turn_id,
|
||||
active_turn_transcript_persistence_failed=(
|
||||
active_turn_transcript_persistence_failed
|
||||
),
|
||||
limit=limit,
|
||||
direction=direction,
|
||||
before=before,
|
||||
|
||||
@ -7,8 +7,11 @@ from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.outbound_events import GoalStatusEvent
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.websocket.runtime import WebSocketChannel
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.webui_turns import WebuiTurnCoordinator, WebuiTurnRoutePolicy
|
||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
||||
|
||||
|
||||
def _make_loop(tmp_path):
|
||||
@ -32,6 +35,7 @@ def _make_loop(tmp_path):
|
||||
sessions=loop.sessions,
|
||||
schedule_background=lambda coro: loop._schedule_background(coro),
|
||||
).subscribe(loop.runtime_events)
|
||||
loop.turn_delivery_factory.route_policy = WebuiTurnRoutePolicy(loop.sessions)
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
return loop
|
||||
|
||||
@ -39,29 +43,51 @@ def _make_loop(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
|
||||
loop = _make_loop(tmp_path)
|
||||
|
||||
response = await loop.process_direct(
|
||||
"deliver reminder",
|
||||
session_key="cron:reminder-1",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
gateway = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
loop.bus,
|
||||
gateway=gateway,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "done"
|
||||
try:
|
||||
response = await loop.process_direct(
|
||||
"deliver reminder",
|
||||
session_key="cron:reminder-1",
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
)
|
||||
|
||||
events = []
|
||||
while loop.bus.outbound_size:
|
||||
events.append(await loop.bus.consume_outbound())
|
||||
assert response is not None
|
||||
assert response.content == "done"
|
||||
|
||||
statuses = [
|
||||
event.event
|
||||
for event in events
|
||||
if isinstance(event.event, GoalStatusEvent)
|
||||
]
|
||||
assert [status.status for status in statuses] == ["running", "idle"]
|
||||
assert isinstance(statuses[0].started_at, float)
|
||||
assert statuses[1].started_at is None
|
||||
events = []
|
||||
while loop.bus.outbound_size:
|
||||
event = await loop.bus.consume_outbound()
|
||||
events.append(event)
|
||||
await channel.send(event)
|
||||
|
||||
status_messages = [
|
||||
event
|
||||
for event in events
|
||||
if isinstance(event.event, GoalStatusEvent)
|
||||
]
|
||||
statuses = [event.event for event in status_messages]
|
||||
assert [status.status for status in statuses] == ["running", "idle"]
|
||||
assert isinstance(statuses[0].started_at, float)
|
||||
assert statuses[1].started_at is None
|
||||
owners = {
|
||||
event.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
for event in status_messages
|
||||
}
|
||||
assert len(owners) == 1
|
||||
assert wth.websocket_turn_wall_started_at("chat-1") is None
|
||||
assert "chat-1" not in wth._WEBSOCKET_ACTIVE_TURNS
|
||||
finally:
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -28,7 +28,10 @@ from nanobot.utils.progress_events import (
|
||||
invoke_file_edit_progress,
|
||||
on_progress_accepts_file_edit_events,
|
||||
)
|
||||
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
|
||||
from nanobot.webui.metadata import (
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
|
||||
|
||||
def _make_loop(tmp_path: Path) -> AgentLoop:
|
||||
@ -903,6 +906,12 @@ class TestToolEventProgress:
|
||||
turn_id = turn_ids.pop()
|
||||
assert isinstance(turn_id, str)
|
||||
assert turn_id.startswith("subagent:")
|
||||
owners = {
|
||||
message.metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||
for message in visible_events
|
||||
}
|
||||
assert len(owners) == 1
|
||||
assert isinstance(owners.pop(), str)
|
||||
assert all(
|
||||
(message.channel, message.chat_id) == ("websocket", "chat-a")
|
||||
and message.metadata.get("webui") is True
|
||||
@ -910,6 +919,7 @@ class TestToolEventProgress:
|
||||
and set(message.metadata) <= {
|
||||
"webui",
|
||||
"_wants_stream",
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
"latency_ms",
|
||||
}
|
||||
|
||||
@ -1,12 +1,147 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
|
||||
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
|
||||
from nanobot.webui.metadata import (
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
)
|
||||
|
||||
|
||||
def test_websocket_lifecycles_get_distinct_internal_owners(tmp_path: Path) -> None:
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
|
||||
)
|
||||
first_msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-a",
|
||||
content="first",
|
||||
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: "attacker-reused-owner"},
|
||||
)
|
||||
second_msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-a",
|
||||
content="second",
|
||||
metadata={WEBSOCKET_TURN_OWNER_METADATA_KEY: "attacker-reused-owner"},
|
||||
)
|
||||
|
||||
first = factory.create(first_msg, first_msg.session_key)
|
||||
second = factory.create(second_msg, second_msg.session_key)
|
||||
first_owner = first.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
second_owner = second.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
|
||||
assert first_owner == first.delivery_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
assert first_owner != second_owner
|
||||
assert first_owner != "attacker-reused-owner"
|
||||
assert second_owner != "attacker-reused-owner"
|
||||
assert WEBUI_TURN_METADATA_KEY not in first.lifecycle_message.metadata
|
||||
assert first_msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == first_owner
|
||||
assert second_msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == second_owner
|
||||
|
||||
|
||||
def test_websocket_lifecycle_reuses_registered_ingress_owner(tmp_path: Path) -> None:
|
||||
from nanobot.session import webui_turns as wth
|
||||
|
||||
owner = wth.register_queued_websocket_turn_if_idle("chat-queued", "turn-queued")
|
||||
assert owner is not None
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="chat-queued",
|
||||
content="queued",
|
||||
metadata={
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY: owner,
|
||||
WEBUI_TURN_METADATA_KEY: "turn-queued",
|
||||
},
|
||||
)
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
|
||||
)
|
||||
|
||||
try:
|
||||
delivery = factory.create(msg, msg.session_key)
|
||||
|
||||
assert delivery.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == owner
|
||||
assert msg.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] == owner
|
||||
finally:
|
||||
wth.clear_websocket_turn_if_current("chat-queued", owner)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_chat_different_sessions_restore_previous_active_projection(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from nanobot.session import webui_turns as wth
|
||||
|
||||
factory = TurnDeliveryFactory(
|
||||
MessageBus(),
|
||||
RuntimeEventBus(),
|
||||
route_policy=WebuiTurnRoutePolicy(SessionManager(tmp_path / "sessions")),
|
||||
)
|
||||
first_msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="shared-chat",
|
||||
content="first",
|
||||
metadata={WEBUI_TURN_METADATA_KEY: "turn-first"},
|
||||
session_key_override="websocket:session-first",
|
||||
)
|
||||
second_msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id="shared-chat",
|
||||
content="second",
|
||||
metadata={WEBUI_TURN_METADATA_KEY: "turn-second"},
|
||||
session_key_override="websocket:session-second",
|
||||
)
|
||||
first = factory.create(first_msg, first_msg.session_key)
|
||||
second = factory.create(second_msg, second_msg.session_key)
|
||||
first_owner = first.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
second_owner = second.lifecycle_message.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
|
||||
try:
|
||||
await wth.publish_turn_run_status(
|
||||
bus,
|
||||
first.lifecycle_message,
|
||||
"running",
|
||||
started_at=100.0,
|
||||
)
|
||||
await wth.publish_turn_run_status(
|
||||
bus,
|
||||
second.lifecycle_message,
|
||||
"running",
|
||||
started_at=200.0,
|
||||
)
|
||||
|
||||
assert wth.websocket_turn_wall_started_at("shared-chat") == 200.0
|
||||
assert wth.websocket_turn_id("shared-chat") == "turn-second"
|
||||
assert wth.clear_websocket_turn_if_current("shared-chat", second_owner) is True
|
||||
assert wth.websocket_turn_wall_started_at("shared-chat") == 100.0
|
||||
assert wth.websocket_turn_id("shared-chat") == "turn-first"
|
||||
assert wth._WEBSOCKET_TURN_OWNERS["shared-chat"] == first_owner
|
||||
assert wth.clear_websocket_turn_if_current("shared-chat", first_owner) is True
|
||||
assert wth.websocket_turn_wall_started_at("shared-chat") is None
|
||||
finally:
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.pop("shared-chat", None)
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop("shared-chat", None)
|
||||
wth._WEBSOCKET_TURN_IDS.pop("shared-chat", None)
|
||||
wth._WEBSOCKET_TURN_OWNERS.pop("shared-chat", None)
|
||||
|
||||
|
||||
def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> None:
|
||||
@ -45,6 +180,7 @@ def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> Non
|
||||
assert set(first_visible_route.metadata) == {
|
||||
"webui",
|
||||
"_wants_stream",
|
||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
}
|
||||
assert first_visible_route.metadata["webui"] is True
|
||||
@ -54,6 +190,10 @@ def test_late_subagent_route_requires_webui_owned_session(tmp_path: Path) -> Non
|
||||
assert first_turn_id.startswith("subagent:")
|
||||
assert second_turn_id.startswith("subagent:")
|
||||
assert first_turn_id != second_turn_id
|
||||
assert (
|
||||
first_visible_route.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
!= second_visible_route.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
)
|
||||
assert msg.metadata == {
|
||||
"injected_event": "subagent_result",
|
||||
"subagent_task_id": "sub-1",
|
||||
|
||||
@ -6,7 +6,10 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.command.builtin import register_builtin_commands
|
||||
from nanobot.command.builtin import (
|
||||
builtin_command_starts_agent_turn,
|
||||
register_builtin_commands,
|
||||
)
|
||||
from nanobot.command.router import CommandContext, CommandRouter
|
||||
|
||||
|
||||
@ -64,6 +67,20 @@ class TestIsDispatchableCommand:
|
||||
assert not router.is_dispatchable_command("/foo bar")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "expected"),
|
||||
[
|
||||
("/status", False),
|
||||
("/history 5", False),
|
||||
("/goal", False),
|
||||
("/goal migrate the database", True),
|
||||
("regular prompt", True),
|
||||
],
|
||||
)
|
||||
def test_builtin_command_agent_turn_lifecycle(content: str, expected: bool) -> None:
|
||||
assert builtin_command_starts_agent_turn(content) is expected
|
||||
|
||||
|
||||
class TestMidTurnCommandDispatchedDirectly:
|
||||
"""Verify that commands matching is_dispatchable_command() are dispatched
|
||||
correctly when session=None (the mid-turn path)."""
|
||||
|
||||
@ -389,6 +389,7 @@ def test_thread_response_does_not_mark_completed_message_tool_tail_pending(
|
||||
|
||||
assert out is not None
|
||||
assert out["has_pending_tool_calls"] is False
|
||||
assert out["completed_turn_ids"] == [turn_id]
|
||||
assert out["messages"][-1]["kind"] == "trace"
|
||||
assert out["messages"][-2]["content"] == "Cron test"
|
||||
|
||||
@ -410,6 +411,144 @@ def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatc
|
||||
|
||||
assert out is not None
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
assert out["completed_turn_ids"] == []
|
||||
|
||||
|
||||
def test_thread_response_reports_active_registry_without_transcript(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
|
||||
out = build_webui_thread_response(
|
||||
"websocket:active-without-transcript",
|
||||
active_turn_started_at=1_700_000_000.0,
|
||||
active_turn_id="turn-active",
|
||||
)
|
||||
|
||||
assert out is not None
|
||||
assert out["messages"] == []
|
||||
assert out["completed_turn_ids"] == []
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
assert out["active_turn_id"] == "turn-active"
|
||||
|
||||
|
||||
def test_thread_response_reports_explicit_completion_without_assistant_row(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:empty-answer"
|
||||
turn_id = "turn-empty-answer"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"event": "user", "chat_id": "empty-answer", "text": "stop", "turn_id": turn_id},
|
||||
)
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"event": "turn_end", "chat_id": "empty-answer", "turn_id": turn_id},
|
||||
)
|
||||
|
||||
out = build_webui_thread_response(key)
|
||||
|
||||
assert out is not None
|
||||
assert out["messages"][-1]["role"] == "user"
|
||||
assert out["has_pending_tool_calls"] is False
|
||||
assert out["completed_turn_ids"] == [turn_id]
|
||||
|
||||
|
||||
def test_incomplete_turn_with_ambiguous_session_match_stays_pending(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:ambiguous-incomplete"
|
||||
turn_id = "turn-ambiguous"
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "user",
|
||||
"chat_id": "ambiguous-incomplete",
|
||||
"text": "repeat",
|
||||
"turn_id": turn_id,
|
||||
},
|
||||
)
|
||||
append_transcript_object(
|
||||
key,
|
||||
{
|
||||
"event": "turn_end",
|
||||
"chat_id": "ambiguous-incomplete",
|
||||
"turn_id": turn_id,
|
||||
"transcript_incomplete": True,
|
||||
},
|
||||
)
|
||||
|
||||
out = build_webui_thread_response(
|
||||
key,
|
||||
session_messages=[
|
||||
{"role": "user", "content": "repeat"},
|
||||
{"role": "assistant", "content": "first answer"},
|
||||
{"role": "user", "content": "repeat"},
|
||||
{"role": "assistant", "content": "second answer"},
|
||||
],
|
||||
)
|
||||
|
||||
assert out is not None
|
||||
assert [(message["role"], message["content"]) for message in out["messages"]] == [
|
||||
("user", "repeat"),
|
||||
]
|
||||
assert out["completed_turn_ids"] == []
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
|
||||
|
||||
def test_later_completion_does_not_hide_older_incomplete_turn(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:older-incomplete"
|
||||
for event in (
|
||||
{"event": "user", "text": "first", "turn_id": "turn-first"},
|
||||
{
|
||||
"event": "turn_end",
|
||||
"turn_id": "turn-first",
|
||||
"transcript_incomplete": True,
|
||||
},
|
||||
{"event": "user", "text": "second", "turn_id": "turn-second"},
|
||||
{"event": "message", "text": "second answer", "turn_id": "turn-second"},
|
||||
{"event": "turn_end", "turn_id": "turn-second"},
|
||||
):
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"chat_id": "older-incomplete", **event},
|
||||
)
|
||||
|
||||
out = build_webui_thread_response(key)
|
||||
|
||||
assert out is not None
|
||||
assert out["completed_turn_ids"] == ["turn-second"]
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
|
||||
|
||||
def test_active_registry_does_not_hide_a_newer_queued_turn(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:queued-tail"
|
||||
for event in (
|
||||
{"event": "user", "text": "first", "turn_id": "turn-old"},
|
||||
{"event": "message", "text": "done", "turn_id": "turn-old"},
|
||||
{"event": "turn_end", "turn_id": "turn-old"},
|
||||
{"event": "user", "text": "queued next", "turn_id": "turn-new"},
|
||||
):
|
||||
append_transcript_object(key, {"chat_id": "queued-tail", **event})
|
||||
|
||||
out = build_webui_thread_response(
|
||||
key,
|
||||
active_turn_started_at=1_700_000_000.0,
|
||||
active_turn_id="turn-old",
|
||||
)
|
||||
|
||||
assert out is not None
|
||||
assert out["has_pending_tool_calls"] is True
|
||||
|
||||
|
||||
def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
|
||||
|
||||
@ -8,26 +8,40 @@ from nanobot.agent.tools.context import RequestContext, request_context
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_turn_wall_clock() -> None:
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
yield
|
||||
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||
wth._WEBSOCKET_TURN_IDS.clear()
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_turn_run_status_running_records_wall_clock() -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-a", content="hi")
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id="chat-a",
|
||||
content="hi",
|
||||
metadata={"webui_turn_id": "turn-a"},
|
||||
)
|
||||
|
||||
await wth.publish_turn_run_status(bus, msg, "running")
|
||||
|
||||
assert "chat-a" in wth._WEBSOCKET_TURN_WALL_STARTED_AT
|
||||
t0 = wth.websocket_turn_wall_started_at("chat-a")
|
||||
assert isinstance(t0, float)
|
||||
assert wth.websocket_turn_id("chat-a") == "turn-a"
|
||||
call = bus.publish_outbound.await_args[0][0]
|
||||
assert call.chat_id == "chat-a"
|
||||
assert isinstance(call.event, GoalStatusEvent)
|
||||
@ -49,16 +63,67 @@ async def test_publish_turn_run_status_reuses_explicit_wall_clock() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_publish_turn_run_status_idle_clears_wall_clock() -> None:
|
||||
async def test_publish_turn_run_status_idle_retains_registry_until_delivery() -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-b", content="hi")
|
||||
msg = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id="chat-b",
|
||||
content="hi",
|
||||
metadata={"webui_turn_id": "turn-b"},
|
||||
)
|
||||
|
||||
await wth.publish_turn_run_status(bus, msg, "running")
|
||||
assert wth.websocket_turn_wall_started_at("chat-b") is not None
|
||||
assert wth.websocket_turn_id("chat-b") == "turn-b"
|
||||
|
||||
await wth.publish_turn_run_status(bus, msg, "idle")
|
||||
assert wth.websocket_turn_wall_started_at("chat-b") is not None
|
||||
assert wth.websocket_turn_id("chat-b") == "turn-b"
|
||||
|
||||
|
||||
def test_clear_websocket_turn_only_clears_matching_owner() -> None:
|
||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-b"] = 1234.5
|
||||
wth._WEBSOCKET_TURN_IDS["chat-b"] = "turn-new"
|
||||
wth._WEBSOCKET_TURN_OWNERS["chat-b"] = "owner-new"
|
||||
|
||||
assert wth.clear_websocket_turn_if_current("chat-b", "owner-old") is False
|
||||
assert wth.websocket_turn_wall_started_at("chat-b") == 1234.5
|
||||
assert wth.websocket_turn_id("chat-b") == "turn-new"
|
||||
|
||||
assert wth.clear_websocket_turn_if_current("chat-b", "owner-new") is True
|
||||
assert wth.websocket_turn_wall_started_at("chat-b") is None
|
||||
assert wth.websocket_turn_id("chat-b") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ownerless_turns_receive_distinct_internal_owners() -> None:
|
||||
bus = MagicMock()
|
||||
bus.publish_outbound = AsyncMock()
|
||||
first = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id="chat-ownerless",
|
||||
content="first",
|
||||
)
|
||||
second = InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="u",
|
||||
chat_id="chat-ownerless",
|
||||
content="second",
|
||||
)
|
||||
|
||||
await wth.publish_turn_run_status(bus, first, "running")
|
||||
first_owner = first.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
await wth.publish_turn_run_status(bus, second, "running")
|
||||
second_owner = second.metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY]
|
||||
|
||||
assert first_owner != second_owner
|
||||
assert wth.clear_websocket_turn_if_current("chat-ownerless", first_owner) is True
|
||||
assert wth._WEBSOCKET_TURN_OWNERS["chat-ownerless"] == second_owner
|
||||
assert wth.websocket_turn_wall_started_at("chat-ownerless") is not None
|
||||
assert wth.clear_websocket_turn_if_current("chat-ownerless", second_owner) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -70,6 +135,7 @@ async def test_publish_turn_run_status_non_websocket_noop_registry() -> None:
|
||||
await wth.publish_turn_run_status(bus, msg, "running")
|
||||
|
||||
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
|
||||
assert wth._WEBSOCKET_TURN_IDS == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -738,6 +738,7 @@ export default function App() {
|
||||
} else {
|
||||
client.updateUrl(url);
|
||||
}
|
||||
client.updateMaxFrameBytes(boot.limits?.transport.max_frame_bytes);
|
||||
setState((current) =>
|
||||
current.status === "ready" && current.client === client
|
||||
? {
|
||||
@ -769,6 +770,7 @@ export default function App() {
|
||||
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
|
||||
const client = new NanobotClient({
|
||||
url,
|
||||
maxFrameBytes: boot.limits?.transport.max_frame_bytes,
|
||||
socketFactory: runtimeHost.socketFactory,
|
||||
onReauth: async () => {
|
||||
try {
|
||||
@ -1206,6 +1208,7 @@ function Shell({
|
||||
useEffect(() => {
|
||||
return client.onError((error) => {
|
||||
if (error.kind !== "workspace_scope_rejected") return;
|
||||
if (error.chatId && error.chatId !== activeChatIdRef.current) return;
|
||||
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
||||
void refreshWorkspaces();
|
||||
});
|
||||
|
||||
@ -67,6 +67,11 @@ function resolveCopy(
|
||||
title: t("errors.workspaceScopeRejected.title"),
|
||||
body: t("errors.workspaceScopeRejected.body"),
|
||||
};
|
||||
case "turn_rejected":
|
||||
return {
|
||||
title: t("errors.turnRejected.title"),
|
||||
body: t("errors.turnRejected.body"),
|
||||
};
|
||||
default: {
|
||||
// Exhaustiveness guard: if a new StreamError kind is added, TS will
|
||||
// complain here until we add a corresponding i18n branch.
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
installedMcpPresetsFromPayload,
|
||||
isMcpPresetsPayload,
|
||||
} from "@/lib/mcp-preset-events";
|
||||
import type { CanonicalRunSnapshot } from "@/lib/nanobot-client";
|
||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import type {
|
||||
ChatSummary,
|
||||
@ -44,13 +45,65 @@ import type {
|
||||
import { projectWebuiThreadMessages } from "@/lib/thread-display-compat";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
|
||||
type MessageShape = Pick<UIMessage, "role" | "kind" | "content">;
|
||||
type MessageShape = Pick<UIMessage, "role" | "kind" | "content" | "isStreaming" | "turnId">;
|
||||
|
||||
interface PendingCanonicalHydrate {
|
||||
historyLineage: number;
|
||||
historyVersion: number;
|
||||
runGeneration: number;
|
||||
uiBaseline: MessageShape[];
|
||||
uiLineage: number | null;
|
||||
uiRevision: number;
|
||||
}
|
||||
|
||||
interface PendingHistoryLineageCommit {
|
||||
lineage: number;
|
||||
messages: UIMessage[];
|
||||
}
|
||||
|
||||
interface PendingCanonicalCommit {
|
||||
canonicalSnapshot: CanonicalRunSnapshot;
|
||||
completedTurnIds: string[];
|
||||
expectedUiRevision: number;
|
||||
historyLineage: number;
|
||||
historyVersion: number;
|
||||
hydrate: PendingCanonicalHydrate;
|
||||
messages: UIMessage[];
|
||||
previousMessages: UIMessage[];
|
||||
}
|
||||
|
||||
function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
|
||||
return (
|
||||
a.role === b.role
|
||||
&& (a.kind ?? "") === (b.kind ?? "")
|
||||
&& a.content === b.content
|
||||
&& (!a.turnId || !b.turnId || a.turnId === b.turnId)
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotPreservesMessage(
|
||||
current: MessageShape,
|
||||
candidate: MessageShape,
|
||||
allowCompletedTurnReplacement: boolean,
|
||||
): boolean {
|
||||
if (sameMessageShape(current, candidate)) return true;
|
||||
if (
|
||||
allowCompletedTurnReplacement
|
||||
&& current.role === "assistant"
|
||||
&& candidate.role === current.role
|
||||
&& (candidate.kind ?? "") === (current.kind ?? "")
|
||||
&& !!current.turnId
|
||||
&& candidate.turnId === current.turnId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
current.role === "assistant"
|
||||
&& current.isStreaming === true
|
||||
&& candidate.role === current.role
|
||||
&& (candidate.kind ?? "") === (current.kind ?? "")
|
||||
&& (!current.turnId || !candidate.turnId || candidate.turnId === current.turnId)
|
||||
&& candidate.content.startsWith(current.content)
|
||||
);
|
||||
}
|
||||
|
||||
@ -64,28 +117,44 @@ function durableMessageShape(message: UIMessage): MessageShape | null {
|
||||
role: message.role,
|
||||
kind: message.kind,
|
||||
content: message.content,
|
||||
isStreaming: message.isStreaming,
|
||||
turnId: message.turnId,
|
||||
};
|
||||
}
|
||||
|
||||
function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]): boolean {
|
||||
// Canonical history refreshes can race with live websocket messages after fork/send.
|
||||
// Never accept a refreshed snapshot that drops a user/assistant message already shown.
|
||||
const expected = current
|
||||
.map(durableMessageShape)
|
||||
.filter((message): message is MessageShape => message !== null);
|
||||
if (expected.length === 0) return true;
|
||||
const candidates = snapshot
|
||||
function durableMessageShapes(messages: UIMessage[]): MessageShape[] {
|
||||
return messages
|
||||
.map(durableMessageShape)
|
||||
.filter((message): message is MessageShape => message !== null);
|
||||
}
|
||||
|
||||
function preservesMessageShapes(
|
||||
expected: MessageShape[],
|
||||
candidates: MessageShape[],
|
||||
allowCompletedTurnReplacement: boolean,
|
||||
): boolean {
|
||||
let cursor = 0;
|
||||
let previousCandidate: MessageShape | null = null;
|
||||
for (const message of expected) {
|
||||
if (
|
||||
allowCompletedTurnReplacement
|
||||
&& previousCandidate?.role === "assistant"
|
||||
&& message.role === "assistant"
|
||||
&& !!message.turnId
|
||||
&& message.turnId === previousCandidate.turnId
|
||||
) {
|
||||
// A delayed websocket delta can briefly create a second bubble after an
|
||||
// HTTP completion snapshot. The completed replay is authoritative for
|
||||
// that turn, so both local fragments may map to its single assistant row.
|
||||
continue;
|
||||
}
|
||||
let found = false;
|
||||
while (cursor < candidates.length) {
|
||||
const candidate = candidates[cursor];
|
||||
cursor += 1;
|
||||
if (sameMessageShape(message, candidate)) {
|
||||
if (snapshotPreservesMessage(message, candidate, allowCompletedTurnReplacement)) {
|
||||
found = true;
|
||||
previousCandidate = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -94,11 +163,55 @@ function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]):
|
||||
return true;
|
||||
}
|
||||
|
||||
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
|
||||
function preservesDurableMessages(
|
||||
current: UIMessage[],
|
||||
snapshot: UIMessage[],
|
||||
allowCompletedTurnReplacement = false,
|
||||
): boolean {
|
||||
// Canonical history refreshes can race with live websocket messages after fork/send.
|
||||
// Never accept a refreshed snapshot that drops a user/assistant message already shown.
|
||||
const expected = durableMessageShapes(current);
|
||||
if (expected.length === 0) return true;
|
||||
return preservesMessageShapes(
|
||||
expected,
|
||||
durableMessageShapes(snapshot),
|
||||
allowCompletedTurnReplacement,
|
||||
);
|
||||
}
|
||||
|
||||
function resetDropsPostRequestDurableTail(
|
||||
baseline: MessageShape[],
|
||||
current: UIMessage[],
|
||||
snapshot: UIMessage[],
|
||||
): boolean {
|
||||
const currentDurable = durableMessageShapes(current);
|
||||
let stablePrefixLength = 0;
|
||||
while (
|
||||
stablePrefixLength < baseline.length
|
||||
&& stablePrefixLength < currentDurable.length
|
||||
&& sameMessageShape(baseline[stablePrefixLength], currentDurable[stablePrefixLength])
|
||||
) {
|
||||
stablePrefixLength += 1;
|
||||
}
|
||||
const postRequestTail = currentDurable.slice(stablePrefixLength);
|
||||
if (postRequestTail.length === 0) return false;
|
||||
return !preservesMessageShapes(
|
||||
postRequestTail,
|
||||
durableMessageShapes(snapshot),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function isStaleThreadSnapshot(
|
||||
current: UIMessage[],
|
||||
snapshot: UIMessage[],
|
||||
allowCompletedTurnReplacement = false,
|
||||
): boolean {
|
||||
if (current.length === 0) return false;
|
||||
if (snapshot.length === 0) return true;
|
||||
if (!preservesDurableMessages(current, snapshot)) return true;
|
||||
if (!preservesDurableMessages(current, snapshot, allowCompletedTurnReplacement)) return true;
|
||||
if (snapshot.length >= current.length) return false;
|
||||
if (allowCompletedTurnReplacement) return false;
|
||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
||||
}
|
||||
|
||||
@ -114,6 +227,30 @@ function latestActiveTurnId(messages: UIMessage[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function completedAssistantTurnIds(messages: UIMessage[]): string[] {
|
||||
return Array.from(new Set(
|
||||
messages
|
||||
.filter((message) => message.role === "assistant" && !!message.turnId)
|
||||
.map((message) => message.turnId as string),
|
||||
));
|
||||
}
|
||||
|
||||
function canonicalRunSnapshot(
|
||||
messages: UIMessage[],
|
||||
hasPendingToolCalls: boolean,
|
||||
activeTurnId: string | null,
|
||||
): CanonicalRunSnapshot {
|
||||
return {
|
||||
observedTurnIds: Array.from(new Set(
|
||||
messages
|
||||
.filter((message) => message.role === "user" && !!message.turnId)
|
||||
.map((message) => message.turnId as string),
|
||||
)),
|
||||
hasPendingToolCalls,
|
||||
activeTurnId,
|
||||
};
|
||||
}
|
||||
|
||||
const FILE_PREVIEW_DEFAULT_WIDTH = 544;
|
||||
const FILE_PREVIEW_MIN_WIDTH = 360;
|
||||
const FILE_PREVIEW_MAX_WIDTH = 860;
|
||||
@ -432,6 +569,10 @@ export function ThreadShell({
|
||||
hasMoreBefore,
|
||||
userMessageOffset,
|
||||
hasPendingToolCalls,
|
||||
completedTurnIds,
|
||||
continuity: historyContinuity,
|
||||
lineage: historyLineage,
|
||||
activeTurnId: historyActiveTurnId,
|
||||
refresh: refreshHistory,
|
||||
version: historyVersion,
|
||||
forkBoundaryMessageCount,
|
||||
@ -474,9 +615,16 @@ export function ThreadShell({
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||
const skipLayoutCacheRef = useRef(false);
|
||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||
const pendingCanonicalHydrateRef = useRef<Map<string, PendingCanonicalHydrate>>(new Map());
|
||||
const pendingCanonicalCommitRef = useRef<Map<string, PendingCanonicalCommit>>(new Map());
|
||||
const pendingHistoryLineageCommitRef = useRef<Map<string, PendingHistoryLineageCommit>>(
|
||||
new Map(),
|
||||
);
|
||||
const completedCanonicalHydrateVersionRef = useRef<Map<string, number>>(new Map());
|
||||
const committedHistoryLineageRef = useRef<Map<string, number>>(new Map());
|
||||
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const currentUiMessagesRef = useRef<UIMessage[] | null>(null);
|
||||
const uiRevisionRef = useRef(0);
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (!chatId) return historical;
|
||||
@ -497,11 +645,25 @@ export function ThreadShell({
|
||||
send,
|
||||
transcribeAudio,
|
||||
stop,
|
||||
reconcileTurnComplete,
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (currentUiMessagesRef.current === messages) return;
|
||||
currentUiMessagesRef.current = messages;
|
||||
uiRevisionRef.current += 1;
|
||||
if (!chatId) return;
|
||||
const lineageCommit = pendingHistoryLineageCommitRef.current.get(chatId);
|
||||
if (!lineageCommit) return;
|
||||
pendingHistoryLineageCommitRef.current.delete(chatId);
|
||||
if (lineageCommit.messages === messages) {
|
||||
committedHistoryLineageRef.current.set(chatId, lineageCommit.lineage);
|
||||
}
|
||||
}, [chatId, messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
|
||||
}, [chatId, historyKey]);
|
||||
@ -685,47 +847,196 @@ export function ThreadShell({
|
||||
useEffect(() => {
|
||||
if (!chatId || loading) return;
|
||||
const cached = messageCacheRef.current.get(chatId);
|
||||
const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0;
|
||||
const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId);
|
||||
const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion;
|
||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||
const hasNewCanonicalHistory = (
|
||||
pendingCanonicalHydrate !== undefined
|
||||
&& historyVersion > pendingCanonicalHydrate.historyVersion
|
||||
);
|
||||
// When the user switches away and back, keep the local in-memory thread
|
||||
// state (including not-yet-persisted messages) instead of replacing it with
|
||||
// whatever the history endpoint currently knows about. Once a fresh
|
||||
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
||||
// so rendering converges to the same shape as a manual refresh.
|
||||
setMessages((prev) => {
|
||||
const normalizedHistory = projectWebuiThreadMessages(historical);
|
||||
const keepLiveMessages = (messagesToKeep: UIMessage[]) => {
|
||||
const projected = projectWebuiThreadMessages(messagesToKeep);
|
||||
messageCacheRef.current.set(chatId, projected);
|
||||
return projected;
|
||||
};
|
||||
if (hasNewCanonicalHistory && historical.length > 0) {
|
||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
const normalizedHistory = projectWebuiThreadMessages(historical);
|
||||
const keepLiveMessages = (current: UIMessage[]) => projectWebuiThreadMessages(current);
|
||||
if (hasNewCanonicalHistory && pendingCanonicalHydrate) {
|
||||
// Transcript replay strips streaming metadata and uses persisted ids.
|
||||
// Never adopt it while the turn is active: even if no assistant delta
|
||||
// arrived locally yet, the next resumed delta must create/continue the
|
||||
// live cursor rather than append to an immutable replay row.
|
||||
if (hasPendingToolCalls) {
|
||||
setMessages((current) => keepLiveMessages(current));
|
||||
return;
|
||||
}
|
||||
const authoritativeReset = (
|
||||
pendingCanonicalHydrate.uiLineage !== null
|
||||
&& historyLineage !== pendingCanonicalHydrate.uiLineage
|
||||
&& (
|
||||
historyContinuity === "reset"
|
||||
|| (
|
||||
historyContinuity === "overlap"
|
||||
&& historyLineage === pendingCanonicalHydrate.historyLineage
|
||||
)
|
||||
)
|
||||
);
|
||||
const responseUiRevision = uiRevisionRef.current;
|
||||
const resetDropsRenderedTail = (
|
||||
authoritativeReset
|
||||
&& responseUiRevision !== pendingCanonicalHydrate.uiRevision
|
||||
&& resetDropsPostRequestDurableTail(
|
||||
pendingCanonicalHydrate.uiBaseline,
|
||||
messages,
|
||||
normalizedHistory,
|
||||
)
|
||||
);
|
||||
if (
|
||||
authoritativeReset
|
||||
? resetDropsRenderedTail
|
||||
: isStaleThreadSnapshot(messages, normalizedHistory, true)
|
||||
) {
|
||||
setMessages((current) => keepLiveMessages(current));
|
||||
return;
|
||||
}
|
||||
const canonicalCompletedTurnIds = Array.from(new Set([
|
||||
...completedTurnIds,
|
||||
...completedAssistantTurnIds(normalizedHistory),
|
||||
]));
|
||||
const canonicalSnapshot = canonicalRunSnapshot(
|
||||
normalizedHistory,
|
||||
hasPendingToolCalls,
|
||||
historyActiveTurnId,
|
||||
);
|
||||
if (!client.canReconcileCanonicalCompletion(
|
||||
chatId,
|
||||
pendingCanonicalHydrate.runGeneration,
|
||||
canonicalCompletedTurnIds,
|
||||
canonicalSnapshot,
|
||||
)) {
|
||||
setMessages((current) => keepLiveMessages(current));
|
||||
return;
|
||||
}
|
||||
pendingCanonicalCommitRef.current.set(chatId, {
|
||||
canonicalSnapshot,
|
||||
completedTurnIds: canonicalCompletedTurnIds,
|
||||
expectedUiRevision: responseUiRevision + 1,
|
||||
historyLineage,
|
||||
historyVersion,
|
||||
hydrate: pendingCanonicalHydrate,
|
||||
messages: normalizedHistory,
|
||||
previousMessages: messages,
|
||||
});
|
||||
setMessages((current) => {
|
||||
if (current !== messages) return current;
|
||||
if (
|
||||
authoritativeReset
|
||||
? resetDropsRenderedTail
|
||||
: isStaleThreadSnapshot(current, normalizedHistory, true)
|
||||
) {
|
||||
return keepLiveMessages(current);
|
||||
}
|
||||
return normalizedHistory;
|
||||
});
|
||||
return;
|
||||
}
|
||||
const adoptsNormalizedHistory = cached && cached.length > 0
|
||||
? (
|
||||
normalizedHistory.length > cached.length
|
||||
&& !isStaleThreadSnapshot(messages, normalizedHistory)
|
||||
)
|
||||
: !isStaleThreadSnapshot(messages, normalizedHistory);
|
||||
if (adoptsNormalizedHistory) {
|
||||
pendingHistoryLineageCommitRef.current.set(chatId, {
|
||||
lineage: historyLineage,
|
||||
messages: normalizedHistory,
|
||||
});
|
||||
}
|
||||
setMessages((current) => {
|
||||
if (cached && cached.length > 0) {
|
||||
if (
|
||||
normalizedHistory.length > cached.length
|
||||
&& !isStaleThreadSnapshot(prev, normalizedHistory)
|
||||
&& !isStaleThreadSnapshot(current, normalizedHistory)
|
||||
) {
|
||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
return normalizedHistory;
|
||||
}
|
||||
if (isStaleThreadSnapshot(prev, cached)) return keepLiveMessages(prev);
|
||||
return cached;
|
||||
return isStaleThreadSnapshot(current, cached) ? keepLiveMessages(current) : cached;
|
||||
}
|
||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
||||
return normalizedHistory;
|
||||
return isStaleThreadSnapshot(current, normalizedHistory)
|
||||
? keepLiveMessages(current)
|
||||
: normalizedHistory;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, chatId, historical, historyVersion]);
|
||||
}, [
|
||||
loading,
|
||||
chatId,
|
||||
client,
|
||||
completedTurnIds,
|
||||
historical,
|
||||
historyVersion,
|
||||
historyContinuity,
|
||||
historyLineage,
|
||||
historyActiveTurnId,
|
||||
hasPendingToolCalls,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!chatId) return;
|
||||
const commit = pendingCanonicalCommitRef.current.get(chatId);
|
||||
if (!commit) return;
|
||||
if (
|
||||
commit.historyVersion !== historyVersion
|
||||
|| commit.historyLineage !== historyLineage
|
||||
|| commit.messages !== messages
|
||||
) {
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
return;
|
||||
}
|
||||
if (pendingCanonicalHydrateRef.current.get(chatId) !== commit.hydrate) {
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
return;
|
||||
}
|
||||
if (uiRevisionRef.current !== commit.expectedUiRevision) {
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
const fallback = messageCacheRef.current.get(chatId) ?? commit.previousMessages;
|
||||
messageCacheRef.current.set(chatId, fallback);
|
||||
setMessages((current) => current === commit.messages ? fallback : current);
|
||||
return;
|
||||
}
|
||||
if (!client.reconcileCanonicalCompletion(
|
||||
chatId,
|
||||
commit.hydrate.runGeneration,
|
||||
commit.completedTurnIds,
|
||||
commit.canonicalSnapshot,
|
||||
)) {
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
const fallback = messageCacheRef.current.get(chatId) ?? commit.previousMessages;
|
||||
messageCacheRef.current.set(chatId, fallback);
|
||||
setMessages((current) => current === commit.messages ? fallback : current);
|
||||
return;
|
||||
}
|
||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
committedHistoryLineageRef.current.set(chatId, historyLineage);
|
||||
completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion);
|
||||
}, [chatId, client, historyLineage, historyVersion, messages, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || hasPendingToolCalls) return;
|
||||
if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return;
|
||||
completedCanonicalHydrateVersionRef.current.delete(chatId);
|
||||
reconcileTurnComplete();
|
||||
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||
|
||||
const refreshCanonicalHistory = useCallback(() => {
|
||||
if (!chatId) return;
|
||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||
historyLineage,
|
||||
historyVersion,
|
||||
runGeneration: client.getRunGeneration(chatId),
|
||||
uiBaseline: durableMessageShapes(currentUiMessagesRef.current ?? []),
|
||||
uiLineage: committedHistoryLineageRef.current.get(chatId) ?? null,
|
||||
uiRevision: uiRevisionRef.current,
|
||||
});
|
||||
refreshHistory();
|
||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
@ -735,10 +1046,30 @@ export function ThreadShell({
|
||||
// A turn-end thread refresh can arrive while the viewport is easing the
|
||||
// final layout change. User-driven scrolling already disables following,
|
||||
// so keep an active programmatic follow alive across canonical hydration.
|
||||
pendingCanonicalHydrateRef.current.add(chatId);
|
||||
refreshHistory();
|
||||
refreshCanonicalHistory();
|
||||
});
|
||||
}, [chatId, client, refreshHistory]);
|
||||
}, [chatId, client, refreshCanonicalHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshOnReturn = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
refreshCanonicalHistory();
|
||||
};
|
||||
document.addEventListener("visibilitychange", refreshOnReturn);
|
||||
return () => document.removeEventListener("visibilitychange", refreshOnReturn);
|
||||
}, [refreshCanonicalHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
let refreshOnNextOpen = client.status !== "open";
|
||||
return client.onStatus((status) => {
|
||||
if (status !== "open") {
|
||||
refreshOnNextOpen = true;
|
||||
return;
|
||||
}
|
||||
if (refreshOnNextOpen) refreshCanonicalHistory();
|
||||
refreshOnNextOpen = false;
|
||||
});
|
||||
}, [client, refreshCanonicalHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId) return;
|
||||
@ -944,8 +1275,8 @@ export function ThreadShell({
|
||||
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
|
||||
if (!forkedChatId) return;
|
||||
messageCacheRef.current.delete(forkedChatId);
|
||||
appliedHistoryVersionRef.current.delete(forkedChatId);
|
||||
pendingCanonicalHydrateRef.current.add(forkedChatId);
|
||||
pendingCanonicalHydrateRef.current.delete(forkedChatId);
|
||||
completedCanonicalHydrateVersionRef.current.delete(forkedChatId);
|
||||
},
|
||||
[chatId, onForkChat],
|
||||
);
|
||||
|
||||
@ -540,6 +540,8 @@ export function useNanobotStream(
|
||||
) => SubmittedTurn | null;
|
||||
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||
stop: () => void;
|
||||
/** Mark an accepted canonical snapshot as the definitive end of the active turn. */
|
||||
reconcileTurnComplete: () => void;
|
||||
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
||||
/** Latest transport-level fault raised since the last ``dismissStreamError``.
|
||||
* ``null`` when there is nothing to show. */
|
||||
@ -581,10 +583,6 @@ export function useNanobotStream(
|
||||
* backend changes. */
|
||||
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onError((err) => setStreamError(err));
|
||||
}, [client]);
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
const clearPendingStreamWork = useCallback(() => {
|
||||
@ -654,6 +652,66 @@ export function useNanobotStream(
|
||||
return !!closedStreamId;
|
||||
}, []);
|
||||
|
||||
const applyStreamError = useCallback((err: StreamError) => {
|
||||
// One multiplexed client serves every thread. A correlated send fault
|
||||
// belongs only to its target chat. An uncorrelated transport close can
|
||||
// still be shown in the mounted thread, but cannot roll back any turn.
|
||||
if (!chatId || (err.chatId && err.chatId !== chatId)) return;
|
||||
setStreamError(err);
|
||||
if (!err.turnId) return;
|
||||
|
||||
const rejectedTurnId = err.turnId;
|
||||
pendingStreamEventsRef.current = pendingStreamEventsRef.current.filter(
|
||||
(event) => event.turn.turnId !== rejectedTurnId,
|
||||
);
|
||||
sideChannelTurnIdsRef.current.delete(rejectedTurnId);
|
||||
cancelStreamEndTimer();
|
||||
setMessages((prev) => {
|
||||
const rejectedRows = prev.filter((message) => message.turnId === rejectedTurnId);
|
||||
if (rejectedRows.length === 0) return prev;
|
||||
const rejectedIds = new Set(rejectedRows.map((message) => message.id));
|
||||
const rejectedSegments = new Set(
|
||||
rejectedRows
|
||||
.map((message) => message.activitySegmentId)
|
||||
.filter((segmentId): segmentId is string => typeof segmentId === "string"),
|
||||
);
|
||||
if (
|
||||
activeAssistantRef.current
|
||||
&& rejectedIds.has(activeAssistantRef.current.id)
|
||||
) {
|
||||
activeAssistantRef.current = null;
|
||||
}
|
||||
if (buffer.current && rejectedIds.has(buffer.current.messageId)) {
|
||||
buffer.current = null;
|
||||
}
|
||||
for (const id of rejectedIds) closedAssistantStreamIdsRef.current.delete(id);
|
||||
if (
|
||||
activitySegmentRef.current
|
||||
&& rejectedSegments.has(activitySegmentRef.current)
|
||||
) {
|
||||
activitySegmentRef.current = null;
|
||||
}
|
||||
if (
|
||||
fileEditSegmentRef.current
|
||||
&& rejectedSegments.has(fileEditSegmentRef.current)
|
||||
) {
|
||||
fileEditSegmentRef.current = null;
|
||||
}
|
||||
return prev.filter((message) => message.turnId !== rejectedTurnId);
|
||||
});
|
||||
|
||||
const remainingStartedAt = client.getRunStartedAt(chatId);
|
||||
const hasRemainingRun = (
|
||||
remainingStartedAt !== null
|
||||
|| client.hasUnsettledRun(chatId)
|
||||
);
|
||||
setRunStartedAt(remainingStartedAt);
|
||||
setIsStreaming(hasRemainingRun);
|
||||
if (!hasRemainingRun) suppressStreamUntilTurnEndRef.current = false;
|
||||
}, [cancelStreamEndTimer, chatId, client]);
|
||||
|
||||
useEffect(() => client.onError(applyStreamError), [applyStreamError, client]);
|
||||
|
||||
const resolveActiveAssistantIndex = useCallback((
|
||||
prev: UIMessage[],
|
||||
turn: UIMessageTurnFields = {},
|
||||
@ -849,6 +907,15 @@ export function useNanobotStream(
|
||||
return () => document.removeEventListener("visibilitychange", flushOnReturn);
|
||||
}, [flushPendingStreamEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((status) => {
|
||||
if (status !== "reconnecting" && status !== "closed") return;
|
||||
// A transport drop does not prove the backend turn completed. Keep the
|
||||
// semantic running state intact so queued guidance is not flushed early.
|
||||
cancelStreamEndTimer();
|
||||
});
|
||||
}, [cancelStreamEndTimer, client]);
|
||||
|
||||
// Reset local state when switching chats. Do not reset on every
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
@ -883,6 +950,31 @@ export function useNanobotStream(
|
||||
if (!chatId) return;
|
||||
|
||||
const handle = (ev: InboundEvent) => {
|
||||
if (ev.event === "error") {
|
||||
if (ev.detail === "message_too_big") {
|
||||
applyStreamError({
|
||||
kind: "message_too_big",
|
||||
chatId,
|
||||
turnId: ev.turn_id,
|
||||
});
|
||||
} else if (ev.detail === "workspace_scope_rejected") {
|
||||
applyStreamError({
|
||||
kind: "workspace_scope_rejected",
|
||||
reason: ev.reason,
|
||||
chatId,
|
||||
turnId: ev.turn_id,
|
||||
});
|
||||
} else if (ev.turn_id) {
|
||||
applyStreamError({
|
||||
kind: "turn_rejected",
|
||||
detail: ev.detail,
|
||||
reason: ev.reason,
|
||||
chatId,
|
||||
turnId: ev.turn_id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sideChannelEvent = isSideChannelEvent(ev);
|
||||
if (
|
||||
streamEndTimerRef.current !== null
|
||||
@ -1187,8 +1279,7 @@ export function useNanobotStream(
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ``attached`` / ``error`` frames aren't actionable here; the client
|
||||
// shell handles them separately.
|
||||
// ``attached`` frames aren't actionable here.
|
||||
};
|
||||
|
||||
const unsub = client.onChat(chatId, handle);
|
||||
@ -1202,6 +1293,7 @@ export function useNanobotStream(
|
||||
cancelStreamEndTimer();
|
||||
};
|
||||
}, [
|
||||
applyStreamError,
|
||||
cancelStreamEndTimer,
|
||||
chatId,
|
||||
client,
|
||||
@ -1271,12 +1363,16 @@ export function useNanobotStream(
|
||||
});
|
||||
if (!sideChannel) setIsStreaming(true);
|
||||
const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined;
|
||||
const wireOptions = { ...options, turnId };
|
||||
delete wireOptions.quotedContext;
|
||||
delete wireOptions.sideChannel;
|
||||
delete wireOptions.finalizeActiveTurn;
|
||||
delete wireOptions.continueActiveTurn;
|
||||
client.sendMessage(chatId, outboundContent, wireMedia, wireOptions);
|
||||
const clientOptions = {
|
||||
...options,
|
||||
turnId,
|
||||
...((sideChannel || continueActiveTurn) ? { startsNewRun: false } : {}),
|
||||
};
|
||||
delete clientOptions.quotedContext;
|
||||
delete clientOptions.sideChannel;
|
||||
delete clientOptions.finalizeActiveTurn;
|
||||
delete clientOptions.continueActiveTurn;
|
||||
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
|
||||
return { turnId, userMessageId, sideChannel };
|
||||
},
|
||||
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||
@ -1297,6 +1393,18 @@ export function useNanobotStream(
|
||||
client.sendMessage(chatId, "/stop");
|
||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||
|
||||
const reconcileTurnComplete = useCallback(() => {
|
||||
cancelStreamEndTimer();
|
||||
clearPendingStreamWork();
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
closedAssistantStreamIdsRef.current.clear();
|
||||
clearActivitySegment();
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
setRunStartedAt(null);
|
||||
setIsStreaming(false);
|
||||
}, [cancelStreamEndTimer, clearActivitySegment, clearPendingStreamWork]);
|
||||
|
||||
const transcribeAudio = useCallback(
|
||||
(dataUrl: string, options?: { durationMs?: number }) =>
|
||||
client.transcribeAudio(dataUrl, options),
|
||||
@ -1312,6 +1420,7 @@ export function useNanobotStream(
|
||||
send,
|
||||
transcribeAudio,
|
||||
stop,
|
||||
reconcileTurnComplete,
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
|
||||
@ -24,6 +24,8 @@ const INITIAL_HISTORY_PAGE_LIMIT = 160;
|
||||
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
||||
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
||||
|
||||
export type SessionHistoryContinuity = "initial" | "overlap" | "reset";
|
||||
|
||||
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||
return messages.map((m, idx) => ({
|
||||
...m,
|
||||
@ -32,6 +34,63 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function sameSemanticMessage(a: UIMessage, b: UIMessage): boolean {
|
||||
return (
|
||||
a.role === b.role
|
||||
&& (a.kind ?? "") === (b.kind ?? "")
|
||||
&& a.content === b.content
|
||||
&& (!a.turnId || !b.turnId || a.turnId === b.turnId)
|
||||
);
|
||||
}
|
||||
|
||||
function longestSemanticOverlap(previous: UIMessage[], latest: UIMessage[]): number {
|
||||
const maxOverlap = Math.min(previous.length, latest.length);
|
||||
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
||||
const previousStart = previous.length - overlap;
|
||||
let matches = true;
|
||||
for (let index = 0; index < overlap; index += 1) {
|
||||
if (!sameSemanticMessage(previous[previousStart + index], latest[index])) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) return overlap;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function mergeLatestHistory(
|
||||
previous: UIMessage[],
|
||||
latest: UIMessage[],
|
||||
initial: boolean,
|
||||
): {
|
||||
continuity: SessionHistoryContinuity;
|
||||
messages: UIMessage[];
|
||||
retainedPrefixLength: number;
|
||||
} {
|
||||
if (initial) {
|
||||
return {
|
||||
continuity: "initial",
|
||||
messages: latest,
|
||||
retainedPrefixLength: 0,
|
||||
};
|
||||
}
|
||||
const overlapLength = longestSemanticOverlap(previous, latest);
|
||||
if (overlapLength === 0) {
|
||||
return {
|
||||
continuity: "reset",
|
||||
messages: latest,
|
||||
retainedPrefixLength: 0,
|
||||
};
|
||||
}
|
||||
const retainedPrefixLength = previous.length - overlapLength;
|
||||
return {
|
||||
continuity: "overlap",
|
||||
messages: [...previous.slice(0, retainedPrefixLength), ...latest],
|
||||
retainedPrefixLength,
|
||||
};
|
||||
}
|
||||
|
||||
function hasPendingToolCallsFromThread(
|
||||
body: Awaited<ReturnType<typeof fetchWebuiThread>>,
|
||||
messages: UIMessage[],
|
||||
@ -42,6 +101,17 @@ function hasPendingToolCallsFromThread(
|
||||
return hasPendingAgentActivity(messages);
|
||||
}
|
||||
|
||||
function completedTurnIdsFromThread(
|
||||
body: Awaited<ReturnType<typeof fetchWebuiThread>>,
|
||||
): string[] {
|
||||
if (!Array.isArray(body?.completed_turn_ids)) return [];
|
||||
return Array.from(new Set(
|
||||
body.completed_turn_ids.filter(
|
||||
(turnId): turnId is string => typeof turnId === "string" && turnId.length > 0,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
|
||||
export function useSessions(): {
|
||||
sessions: ChatSummary[];
|
||||
@ -191,11 +261,20 @@ export function useSessionHistory(key: string | null): {
|
||||
userMessageOffset: number;
|
||||
version: number;
|
||||
forkBoundaryMessageCount: number | null;
|
||||
/** ``true`` when the replayed transcript ends with a trace row (turn still in flight). */
|
||||
/** ``true`` when the server reports that the turn is still in flight. */
|
||||
hasPendingToolCalls: boolean;
|
||||
/** Turn identities backed by explicit persisted completion events. */
|
||||
completedTurnIds: string[];
|
||||
/** Relationship between the latest canonical page and its predecessor. */
|
||||
continuity: SessionHistoryContinuity;
|
||||
/** Stable across overlapping latest pages; changes on initial load or reset. */
|
||||
lineage: number;
|
||||
/** Exact active turn when supplied by a current gateway. */
|
||||
activeTurnId: string | null;
|
||||
} {
|
||||
const { token } = useClient();
|
||||
const loadingOlderRef = useRef(false);
|
||||
const historyVersionRef = useRef(0);
|
||||
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshSeq((value) => value + 1);
|
||||
@ -207,11 +286,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: boolean;
|
||||
error: string | null;
|
||||
hasPendingToolCalls: boolean;
|
||||
completedTurnIds: string[];
|
||||
forkBoundaryMessageCount: number | null;
|
||||
beforeCursor: string | null;
|
||||
hasMoreBefore: boolean;
|
||||
userMessageOffset: number;
|
||||
version: number;
|
||||
continuity: SessionHistoryContinuity;
|
||||
lineage: number;
|
||||
activeTurnId: string | null;
|
||||
}>({
|
||||
key: null,
|
||||
messages: [],
|
||||
@ -219,11 +302,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -235,11 +322,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@ -255,11 +346,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: 0,
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
@ -268,56 +363,83 @@ export function useSessionHistory(key: string | null): {
|
||||
direction: "latest",
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (!body?.messages?.length) {
|
||||
setState((prev) => ({
|
||||
historyVersionRef.current += 1;
|
||||
const responseVersion = historyVersionRef.current;
|
||||
const completedTurnIds = completedTurnIdsFromThread(body);
|
||||
const ui = persistedMessagesToUi(body?.messages ?? []);
|
||||
const hasPending = hasPendingToolCallsFromThread(body, ui);
|
||||
const forkBoundary = typeof body?.fork_boundary_message_count === "number"
|
||||
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
|
||||
: null;
|
||||
setState((prev) => {
|
||||
const merged = prev.key === key
|
||||
? mergeLatestHistory(prev.messages, ui, prev.lineage === 0)
|
||||
: mergeLatestHistory([], ui, true);
|
||||
const retainedPrefix = merged.retainedPrefixLength > 0;
|
||||
const retainedForkBoundary = (
|
||||
retainedPrefix
|
||||
&& prev.forkBoundaryMessageCount !== null
|
||||
&& prev.forkBoundaryMessageCount <= merged.retainedPrefixLength
|
||||
)
|
||||
? prev.forkBoundaryMessageCount
|
||||
: null;
|
||||
return {
|
||||
key,
|
||||
messages: [],
|
||||
messages: merged.messages,
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const ui = persistedMessagesToUi(body.messages);
|
||||
const hasPending = hasPendingToolCallsFromThread(body, ui);
|
||||
const forkBoundary = typeof body.fork_boundary_message_count === "number"
|
||||
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
|
||||
: null;
|
||||
setState((prev) => ({
|
||||
key,
|
||||
messages: ui,
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: hasPending,
|
||||
forkBoundaryMessageCount: forkBoundary,
|
||||
beforeCursor: body.page?.before_cursor ?? null,
|
||||
hasMoreBefore: body.page?.has_more_before === true,
|
||||
userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0),
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
hasPendingToolCalls: hasPending,
|
||||
completedTurnIds,
|
||||
forkBoundaryMessageCount: forkBoundary === null
|
||||
? retainedForkBoundary
|
||||
: forkBoundary + merged.retainedPrefixLength,
|
||||
beforeCursor: retainedPrefix
|
||||
? prev.beforeCursor
|
||||
: body?.page?.before_cursor ?? null,
|
||||
hasMoreBefore: retainedPrefix
|
||||
? prev.hasMoreBefore
|
||||
: body?.page?.has_more_before === true,
|
||||
userMessageOffset: retainedPrefix
|
||||
? prev.userMessageOffset
|
||||
: Math.max(0, body?.page?.user_message_offset ?? 0),
|
||||
version: responseVersion,
|
||||
continuity: merged.continuity,
|
||||
lineage: merged.continuity === "overlap"
|
||||
? prev.lineage
|
||||
: responseVersion,
|
||||
activeTurnId: typeof body?.active_turn_id === "string"
|
||||
? body.active_turn_id
|
||||
: null,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
setState((prev) => ({
|
||||
key,
|
||||
messages: [],
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
historyVersionRef.current += 1;
|
||||
const responseVersion = historyVersionRef.current;
|
||||
setState((prev) => {
|
||||
const continuity = prev.key === key && prev.lineage > 0
|
||||
? "reset"
|
||||
: "initial";
|
||||
return {
|
||||
key,
|
||||
messages: [],
|
||||
loading: false,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: responseVersion,
|
||||
continuity,
|
||||
lineage: responseVersion,
|
||||
activeTurnId: null,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setState((prev) => ({
|
||||
key,
|
||||
@ -326,11 +448,15 @@ export function useSessionHistory(key: string | null): {
|
||||
loadingOlder: false,
|
||||
error: (e as Error).message,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
forkBoundaryMessageCount: null,
|
||||
beforeCursor: null,
|
||||
hasMoreBefore: false,
|
||||
userMessageOffset: 0,
|
||||
version: prev.key === key ? prev.version : 0,
|
||||
continuity: prev.key === key ? prev.continuity : "initial",
|
||||
lineage: prev.key === key ? prev.lineage : 0,
|
||||
activeTurnId: prev.key === key ? prev.activeTurnId : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -342,17 +468,26 @@ export function useSessionHistory(key: string | null): {
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (!key || loadingOlderRef.current) return;
|
||||
const before = state.key === key ? state.beforeCursor : null;
|
||||
if (!before || !state.hasMoreBefore) return;
|
||||
const requestKey = key;
|
||||
const requestLineage = state.key === requestKey ? state.lineage : 0;
|
||||
const beforeCursor = state.key === requestKey ? state.beforeCursor : null;
|
||||
if (!beforeCursor || !state.hasMoreBefore || requestLineage === 0) return;
|
||||
const matchesRequest = (candidate: typeof state) => (
|
||||
candidate.key === requestKey
|
||||
&& candidate.lineage === requestLineage
|
||||
&& candidate.beforeCursor === beforeCursor
|
||||
);
|
||||
loadingOlderRef.current = true;
|
||||
setState((prev) => prev.key === key ? { ...prev, loadingOlder: true, error: null } : prev);
|
||||
setState((prev) => matchesRequest(prev)
|
||||
? { ...prev, loadingOlder: true, error: null }
|
||||
: prev);
|
||||
try {
|
||||
const body = await fetchWebuiThread(token, key, {
|
||||
const body = await fetchWebuiThread(token, requestKey, {
|
||||
limit: OLDER_HISTORY_PAGE_LIMIT,
|
||||
before,
|
||||
before: beforeCursor,
|
||||
});
|
||||
setState((prev) => {
|
||||
if (prev.key !== key) return prev;
|
||||
if (!matchesRequest(prev)) return prev;
|
||||
if (!body?.messages?.length) {
|
||||
return {
|
||||
...prev,
|
||||
@ -369,21 +504,21 @@ export function useSessionHistory(key: string | null): {
|
||||
? null
|
||||
: prev.forkBoundaryMessageCount + older.length;
|
||||
const nextMessages = [...older, ...prev.messages];
|
||||
// An older page cannot change the authoritative latest-turn lifecycle
|
||||
// state or masquerade as a completed latest-page refresh.
|
||||
return {
|
||||
...prev,
|
||||
messages: nextMessages,
|
||||
loadingOlder: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: hasPendingAgentActivity(nextMessages),
|
||||
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
|
||||
beforeCursor: body.page?.before_cursor ?? null,
|
||||
hasMoreBefore: body.page?.has_more_before === true,
|
||||
userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0),
|
||||
version: prev.version + 1,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
setState((prev) => prev.key === key
|
||||
setState((prev) => matchesRequest(prev)
|
||||
? {
|
||||
...prev,
|
||||
loadingOlder: false,
|
||||
@ -398,6 +533,7 @@ export function useSessionHistory(key: string | null): {
|
||||
state.beforeCursor,
|
||||
state.hasMoreBefore,
|
||||
state.key,
|
||||
state.lineage,
|
||||
token,
|
||||
]);
|
||||
|
||||
@ -414,6 +550,10 @@ export function useSessionHistory(key: string | null): {
|
||||
version: 0,
|
||||
forkBoundaryMessageCount: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -432,6 +572,10 @@ export function useSessionHistory(key: string | null): {
|
||||
version: 0,
|
||||
forkBoundaryMessageCount: null,
|
||||
hasPendingToolCalls: false,
|
||||
completedTurnIds: [],
|
||||
continuity: "initial",
|
||||
lineage: 0,
|
||||
activeTurnId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -447,6 +591,10 @@ export function useSessionHistory(key: string | null): {
|
||||
version: state.version,
|
||||
forkBoundaryMessageCount: state.forkBoundaryMessageCount,
|
||||
hasPendingToolCalls: state.hasPendingToolCalls,
|
||||
completedTurnIds: state.completedTurnIds,
|
||||
continuity: state.continuity,
|
||||
lineage: state.lineage,
|
||||
activeTurnId: state.activeTurnId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1251,6 +1251,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace was not changed",
|
||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Message was not sent",
|
||||
"body": "The gateway rejected this message. Review its text or attachments, then try again."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -1238,6 +1238,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "El espacio de trabajo no cambió",
|
||||
"body": "El gateway rechazó el proyecto o modo de acceso solicitado, así que Nanobot conservó el espacio de trabajo anterior."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "El mensaje no se envió",
|
||||
"body": "El gateway rechazó este mensaje. Revisa el texto o los archivos adjuntos e inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "L’espace de travail n’a pas changé",
|
||||
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Le message n’a pas été envoyé",
|
||||
"body": "La passerelle a refusé ce message. Vérifiez le texte ou les pièces jointes, puis réessayez."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace tidak berubah",
|
||||
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai workspace sebelumnya."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Pesan tidak terkirim",
|
||||
"body": "Gateway menolak pesan ini. Periksa teks atau lampiran, lalu coba lagi."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "ワークスペースは変更されませんでした",
|
||||
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "メッセージは送信されませんでした",
|
||||
"body": "ゲートウェイがこのメッセージを拒否しました。本文または添付ファイルを確認して、もう一度お試しください。"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "작업공간이 변경되지 않았습니다",
|
||||
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "메시지가 전송되지 않았습니다",
|
||||
"body": "게이트웨이가 이 메시지를 거부했습니다. 텍스트나 첨부 파일을 확인한 후 다시 시도하세요."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -1251,6 +1251,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "O workspace não foi alterado",
|
||||
"body": "O nanobot manteve o workspace anterior porque o projeto ou modo de acesso solicitado foi rejeitado pelo gateway."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "A mensagem não foi enviada",
|
||||
"body": "O gateway rejeitou esta mensagem. Revise o texto ou os anexos e tente novamente."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "Workspace không thay đổi",
|
||||
"body": "Gateway đã từ chối dự án hoặc chế độ truy cập được yêu cầu, nên Nanobot giữ workspace trước đó."
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "Tin nhắn chưa được gửi",
|
||||
"body": "Gateway đã từ chối tin nhắn này. Hãy kiểm tra nội dung hoặc tệp đính kèm rồi thử lại."
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -1251,6 +1251,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "工作区未更改",
|
||||
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "消息未发送",
|
||||
"body": "网关拒绝了这条消息。请检查消息内容或附件后重试。"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -1237,6 +1237,10 @@
|
||||
"workspaceScopeRejected": {
|
||||
"title": "工作區未變更",
|
||||
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
|
||||
},
|
||||
"turnRejected": {
|
||||
"title": "訊息未傳送",
|
||||
"body": "閘道拒絕了這則訊息。請檢查內容或附件後再試一次。"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
|
||||
@ -185,6 +185,7 @@ export async function fetchWebuiThread(
|
||||
const res = await fetchWithTimeout(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
|
||||
|
||||
@ -83,8 +83,20 @@ export type StreamError =
|
||||
/** Server rejected the inbound frame as too large (WS close code 1009).
|
||||
* This is the transport fallback after text and attachment policies have
|
||||
* already been checked independently. */
|
||||
| { kind: "message_too_big" }
|
||||
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: string };
|
||||
| { kind: "message_too_big"; chatId?: string; turnId?: string }
|
||||
| {
|
||||
kind: "workspace_scope_rejected";
|
||||
reason?: string;
|
||||
chatId?: string;
|
||||
turnId?: string;
|
||||
}
|
||||
| {
|
||||
kind: "turn_rejected";
|
||||
detail?: string;
|
||||
reason?: string;
|
||||
chatId: string;
|
||||
turnId: string;
|
||||
};
|
||||
|
||||
type ErrorHandler = (error: StreamError) => void;
|
||||
|
||||
@ -95,6 +107,13 @@ interface PendingRequest<T> {
|
||||
}
|
||||
|
||||
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
||||
const TURN_REJECTION_DETAILS = new Set([
|
||||
"access_denied",
|
||||
"attachment_rejected",
|
||||
"message_rejected",
|
||||
"missing content",
|
||||
"workspace_scope_rejected",
|
||||
]);
|
||||
|
||||
export function isSystemCommandTurnId(value: string | null | undefined): value is string {
|
||||
return typeof value === "string" && value.startsWith(SYSTEM_COMMAND_TURN_PREFIX);
|
||||
@ -103,6 +122,8 @@ export function isSystemCommandTurnId(value: string | null | undefined): value i
|
||||
export interface NanobotClientOptions {
|
||||
url: string;
|
||||
reconnect?: boolean;
|
||||
/** Maximum UTF-8 bytes accepted for one websocket message. */
|
||||
maxFrameBytes?: number;
|
||||
/** Called when a connection drops so the app can refresh its token. */
|
||||
onReauth?: () => Promise<string | null>;
|
||||
/** Inject a custom WebSocket factory (used by unit tests). */
|
||||
@ -111,6 +132,24 @@ export interface NanobotClientOptions {
|
||||
maxBackoffMs?: number;
|
||||
}
|
||||
|
||||
export interface CanonicalRunSnapshot {
|
||||
/** User turn ids present in the canonical transcript page. */
|
||||
observedTurnIds: readonly string[];
|
||||
/** Whether the server still considers the transcript tail active. */
|
||||
hasPendingToolCalls: boolean;
|
||||
/** Exact active turn when supplied by a current gateway. */
|
||||
activeTurnId?: string | null;
|
||||
}
|
||||
|
||||
type PendingMessageState = "queued" | "sent" | "unknown" | "accepted";
|
||||
|
||||
interface PendingMessageSend {
|
||||
chatId: string;
|
||||
turnId: string;
|
||||
startsNewRun: boolean;
|
||||
state: PendingMessageState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton WebSocket client that multiplexes chat streams.
|
||||
*
|
||||
@ -134,6 +173,23 @@ export class NanobotClient {
|
||||
private knownChats = new Set<string>();
|
||||
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
|
||||
private runStartedAtByChatId = new Map<string, number>();
|
||||
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
|
||||
private runStartedAtByTurnKey = new Map<string, number>();
|
||||
/** Monotonic per-chat generation for local sends and observed backend runs. */
|
||||
private runGenerationByChatId = new Map<string, number>();
|
||||
/** Turn associated with the latest generation, retained after idle for reconciliation. */
|
||||
private latestRunTurnIdByChatId = new Map<string, string>();
|
||||
/** Submitted or running turns not yet closed by lifecycle or canonical state. */
|
||||
private unsettledRunTurnIdsByChatId = new Map<string, Set<string>>();
|
||||
/** Correlated WebUI sends retained until protocol/canonical disposition. */
|
||||
private pendingMessageSends = new Map<string, PendingMessageSend>();
|
||||
/** Message sends written to the current socket but not yet acknowledged. */
|
||||
private socketPendingMessageSendKeys = new Set<string>();
|
||||
/** Last application frame written, used only for conservative 1009 attribution. */
|
||||
private lastSocketMessageSendKey: string | null = null;
|
||||
/** Canonically completed turns whose delayed websocket frames must be ignored. */
|
||||
private canonicalCompletedTurnIdsByChatId = new Map<string, Set<string>>();
|
||||
private static readonly COMPLETED_TURN_FENCE_MAX = 256;
|
||||
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
||||
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
private pendingNewChat: PendingRequest<string> | null = null;
|
||||
@ -145,6 +201,7 @@ export class NanobotClient {
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private readonly shouldReconnect: boolean;
|
||||
private readonly maxBackoffMs: number;
|
||||
private maxFrameBytes: number | undefined;
|
||||
private socketFactory: (url: string) => WebSocket;
|
||||
private currentUrl: string;
|
||||
private status_: ConnectionStatus = "idle";
|
||||
@ -156,6 +213,7 @@ export class NanobotClient {
|
||||
constructor(private options: NanobotClientOptions) {
|
||||
this.shouldReconnect = options.reconnect ?? true;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
|
||||
this.maxFrameBytes = this.normalizeMaxFrameBytes(options.maxFrameBytes);
|
||||
this.socketFactory = options.socketFactory ?? createDefaultSocket;
|
||||
this.currentUrl = options.url;
|
||||
}
|
||||
@ -222,27 +280,386 @@ export class NanobotClient {
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
/** Refresh transport policy after bootstrap token renewal. */
|
||||
updateMaxFrameBytes(maxFrameBytes?: number): void {
|
||||
this.maxFrameBytes = this.normalizeMaxFrameBytes(maxFrameBytes);
|
||||
}
|
||||
|
||||
/** Generation captured when an HTTP thread reconciliation starts. */
|
||||
getRunGeneration(chatId: string): number {
|
||||
return this.runGenerationByChatId.get(chatId) ?? 0;
|
||||
}
|
||||
|
||||
/** Whether a locally submitted lifecycle turn still lacks a terminal disposition. */
|
||||
hasUnsettledRun(chatId: string): boolean {
|
||||
return (this.unsettledRunTurnIdsByChatId.get(chatId)?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
private normalizeMaxFrameBytes(value: number | undefined): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
private canonicalTurnWillSettle(
|
||||
chatId: string,
|
||||
turnId: string,
|
||||
completed: ReadonlySet<string>,
|
||||
observed: ReadonlySet<string>,
|
||||
snapshot?: CanonicalRunSnapshot,
|
||||
): boolean {
|
||||
if (completed.has(turnId)) return true;
|
||||
if (!snapshot || snapshot.activeTurnId === turnId) return false;
|
||||
if (snapshot.hasPendingToolCalls) return false;
|
||||
if (observed.has(turnId)) return true;
|
||||
const pending = this.pendingMessageSends.get(this.runSendKey(chatId, turnId));
|
||||
return pending?.state === "unknown" || pending?.state === "accepted";
|
||||
}
|
||||
|
||||
private settleNonLifecycleCanonicalSends(
|
||||
chatId: string,
|
||||
completed: ReadonlySet<string>,
|
||||
observed: ReadonlySet<string>,
|
||||
snapshot?: CanonicalRunSnapshot,
|
||||
): void {
|
||||
for (const pending of [...this.pendingMessageSends.values()]) {
|
||||
if (pending.chatId !== chatId || pending.startsNewRun) continue;
|
||||
if (!this.canonicalTurnWillSettle(
|
||||
chatId,
|
||||
pending.turnId,
|
||||
completed,
|
||||
observed,
|
||||
snapshot,
|
||||
)) continue;
|
||||
this.clearPendingMessageSend(chatId, pending.turnId);
|
||||
}
|
||||
}
|
||||
|
||||
private prunePendingInboundTurn(chatId: string, turnId: string): void {
|
||||
const pending = this.pendingInboundByChat.get(chatId);
|
||||
if (!pending) return;
|
||||
const remaining = pending.filter((event) => (
|
||||
!("turn_id" in event)
|
||||
|| event.turn_id !== turnId
|
||||
));
|
||||
if (remaining.length > 0) this.pendingInboundByChat.set(chatId, remaining);
|
||||
else this.pendingInboundByChat.delete(chatId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure preflight for canonical reconciliation.
|
||||
*
|
||||
* Unlike ``reconcileCanonicalCompletion``, this does not add completion
|
||||
* fences, prune queued frames, settle turns, or emit run-status updates.
|
||||
*/
|
||||
canReconcileCanonicalCompletion(
|
||||
chatId: string,
|
||||
expectedRunGeneration: number,
|
||||
completedTurnIds: readonly string[],
|
||||
snapshot?: CanonicalRunSnapshot,
|
||||
): boolean {
|
||||
const completed = new Set(this.canonicalCompletedTurnIdsByChatId.get(chatId));
|
||||
for (const turnId of completedTurnIds) {
|
||||
if (turnId) completed.add(turnId);
|
||||
}
|
||||
const observed = new Set(
|
||||
snapshot?.observedTurnIds.filter((turnId) => turnId.length > 0) ?? [],
|
||||
);
|
||||
const willSettle = (turnId: string): boolean => this.canonicalTurnWillSettle(
|
||||
chatId,
|
||||
turnId,
|
||||
completed,
|
||||
observed,
|
||||
snapshot,
|
||||
);
|
||||
const latestRunTurnId = this.latestRunTurnIdByChatId.get(chatId);
|
||||
const latestRunIsRepresented = (
|
||||
typeof latestRunTurnId === "string"
|
||||
&& (
|
||||
completed.has(latestRunTurnId)
|
||||
|| (
|
||||
observed.has(latestRunTurnId)
|
||||
&& willSettle(latestRunTurnId)
|
||||
)
|
||||
)
|
||||
);
|
||||
const unsettledTurnIds = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
const hasUnrepresentedTurn = (
|
||||
unsettledTurnIds !== undefined
|
||||
&& Array.from(unsettledTurnIds).some((turnId) => !willSettle(turnId))
|
||||
);
|
||||
const hasUnidentifiedActiveRun = (
|
||||
this.runStartedAtByChatId.has(chatId)
|
||||
&& latestRunTurnId === undefined
|
||||
&& (snapshot === undefined || snapshot.hasPendingToolCalls)
|
||||
);
|
||||
if (hasUnrepresentedTurn || hasUnidentifiedActiveRun) return false;
|
||||
return (
|
||||
this.getRunGeneration(chatId) === expectedRunGeneration
|
||||
|| latestRunIsRepresented
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically accept an HTTP snapshot as completed if no unrepresented run
|
||||
* started while the request was in flight.
|
||||
*
|
||||
* Completed turn ids are fenced even when the snapshot loses the generation
|
||||
* race: delayed websocket frames for older turns must never mutate newer UI.
|
||||
*/
|
||||
reconcileCanonicalCompletion(
|
||||
chatId: string,
|
||||
expectedRunGeneration: number,
|
||||
completedTurnIds: readonly string[],
|
||||
snapshot?: CanonicalRunSnapshot,
|
||||
): boolean {
|
||||
const fences = this.canonicalCompletedTurnIdsByChatId.get(chatId) ?? new Set<string>();
|
||||
for (const turnId of completedTurnIds) {
|
||||
if (!turnId) continue;
|
||||
fences.add(turnId);
|
||||
}
|
||||
while (fences.size > NanobotClient.COMPLETED_TURN_FENCE_MAX) {
|
||||
const oldest = fences.values().next().value;
|
||||
if (typeof oldest !== "string") break;
|
||||
fences.delete(oldest);
|
||||
}
|
||||
if (fences.size > 0) this.canonicalCompletedTurnIdsByChatId.set(chatId, fences);
|
||||
const pendingInbound = this.pendingInboundByChat.get(chatId);
|
||||
if (pendingInbound) {
|
||||
const remaining = pendingInbound.filter((event) => {
|
||||
const turnId = "turn_id" in event && typeof event.turn_id === "string"
|
||||
? event.turn_id
|
||||
: null;
|
||||
return turnId === null || !fences.has(turnId);
|
||||
});
|
||||
if (remaining.length > 0) this.pendingInboundByChat.set(chatId, remaining);
|
||||
else this.pendingInboundByChat.delete(chatId);
|
||||
}
|
||||
|
||||
if (!this.canReconcileCanonicalCompletion(
|
||||
chatId,
|
||||
expectedRunGeneration,
|
||||
[],
|
||||
snapshot,
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const completed = new Set(fences);
|
||||
const observed = new Set(
|
||||
snapshot?.observedTurnIds.filter((turnId) => turnId.length > 0) ?? [],
|
||||
);
|
||||
const unsettledTurnIds = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
if (unsettledTurnIds) {
|
||||
for (const turnId of [...unsettledTurnIds]) {
|
||||
if (!this.canonicalTurnWillSettle(
|
||||
chatId,
|
||||
turnId,
|
||||
completed,
|
||||
observed,
|
||||
snapshot,
|
||||
)) continue;
|
||||
unsettledTurnIds.delete(turnId);
|
||||
this.clearPendingMessageSend(chatId, turnId);
|
||||
this.runStartedAtByTurnKey.delete(this.runSendKey(chatId, turnId));
|
||||
}
|
||||
if (unsettledTurnIds.size === 0) this.unsettledRunTurnIdsByChatId.delete(chatId);
|
||||
}
|
||||
this.settleNonLifecycleCanonicalSends(chatId, completed, observed, snapshot);
|
||||
if (this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Last ``goal_state`` payload for *chatId*, if any frame has arrived this connection. */
|
||||
getGoalState(chatId: string): GoalStateWsPayload | undefined {
|
||||
return this.goalStateByChatId.get(chatId);
|
||||
}
|
||||
|
||||
private advanceRunGeneration(chatId: string, turnId?: string): void {
|
||||
this.runGenerationByChatId.set(chatId, this.getRunGeneration(chatId) + 1);
|
||||
if (turnId) {
|
||||
this.latestRunTurnIdByChatId.set(chatId, turnId);
|
||||
const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId) ?? new Set<string>();
|
||||
unsettled.add(turnId);
|
||||
this.unsettledRunTurnIdsByChatId.set(chatId, unsettled);
|
||||
} else {
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
private settleRunTurn(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
this.clearPendingMessageSend(chatId, turnId);
|
||||
this.runStartedAtByTurnKey.delete(this.runSendKey(chatId, turnId));
|
||||
const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
if (!unsettled) return;
|
||||
unsettled.delete(turnId);
|
||||
if (unsettled.size === 0) this.unsettledRunTurnIdsByChatId.delete(chatId);
|
||||
}
|
||||
|
||||
private runSendKey(chatId: string, turnId: string): string {
|
||||
return `${chatId}\u0000${turnId}`;
|
||||
}
|
||||
|
||||
private trackPendingMessageSend(
|
||||
chatId: string,
|
||||
turnId: string,
|
||||
startsNewRun: boolean,
|
||||
): void {
|
||||
const key = this.runSendKey(chatId, turnId);
|
||||
this.pendingMessageSends.set(key, {
|
||||
chatId,
|
||||
turnId,
|
||||
startsNewRun,
|
||||
state: "queued",
|
||||
});
|
||||
}
|
||||
|
||||
private clearPendingMessageSend(chatId: string, turnId: string): void {
|
||||
const key = this.runSendKey(chatId, turnId);
|
||||
this.pendingMessageSends.delete(key);
|
||||
this.socketPendingMessageSendKeys.delete(key);
|
||||
this.sendQueue = this.sendQueue.filter((frame) => !(
|
||||
frame.type === "message"
|
||||
&& frame.chat_id === chatId
|
||||
&& frame.turn_id === turnId
|
||||
));
|
||||
}
|
||||
|
||||
private recordRunAcceptance(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
const key = this.runSendKey(chatId, turnId);
|
||||
const pending = this.pendingMessageSends.get(key);
|
||||
if (!pending) return;
|
||||
this.socketPendingMessageSendKeys.delete(key);
|
||||
if (!pending.startsNewRun) {
|
||||
this.pendingMessageSends.delete(key);
|
||||
return;
|
||||
}
|
||||
pending.state = "accepted";
|
||||
}
|
||||
|
||||
private recordRunRejection(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
const rejectedLatest = this.latestRunTurnIdByChatId.get(chatId) === turnId;
|
||||
this.settleRunTurn(chatId, turnId);
|
||||
this.prunePendingInboundTurn(chatId, turnId);
|
||||
if (!rejectedLatest) return;
|
||||
|
||||
const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
const previousTurnId = unsettled ? Array.from(unsettled).at(-1) : undefined;
|
||||
if (previousTurnId) {
|
||||
this.latestRunTurnIdByChatId.set(chatId, previousTurnId);
|
||||
const previousStartedAt = this.runStartedAtByTurnKey.get(
|
||||
this.runSendKey(chatId, previousTurnId),
|
||||
);
|
||||
const currentStartedAt = this.runStartedAtByChatId.get(chatId);
|
||||
if (previousStartedAt === undefined) {
|
||||
if (this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
} else {
|
||||
this.runStartedAtByChatId.set(chatId, previousStartedAt);
|
||||
if (currentStartedAt !== previousStartedAt) {
|
||||
this.emitRunStatus(chatId, previousStartedAt);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
if (this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
}
|
||||
|
||||
private legacyRejectionTarget(ev: Extract<InboundEvent, { event: "error" }>): {
|
||||
chatId: string;
|
||||
turnId: string;
|
||||
} | null {
|
||||
if (!ev.detail || !TURN_REJECTION_DETAILS.has(ev.detail)) return null;
|
||||
if (
|
||||
ev.detail === "workspace_scope_rejected"
|
||||
&& ev.chat_id === undefined
|
||||
&& this.pendingNewChat
|
||||
) return null;
|
||||
const candidates = [...this.pendingMessageSends.values()].filter((pending) => (
|
||||
// A legacy error can only reject a frame currently awaiting its first
|
||||
// server disposition. Accepted or prior-connection unknown sends are
|
||||
// not safe candidates for an uncorrelated frame.
|
||||
pending.state === "sent"
|
||||
&& (ev.chat_id === undefined || pending.chatId === ev.chat_id)
|
||||
));
|
||||
if (candidates.length !== 1) return null;
|
||||
const [candidate] = candidates;
|
||||
if (
|
||||
this.lastSocketMessageSendKey
|
||||
!== this.runSendKey(candidate.chatId, candidate.turnId)
|
||||
) return null;
|
||||
return { chatId: candidate.chatId, turnId: candidate.turnId };
|
||||
}
|
||||
|
||||
private uniqueUnsettledTurnId(chatId: string): string | null {
|
||||
const unsettled = this.unsettledRunTurnIdsByChatId.get(chatId);
|
||||
if (!unsettled || unsettled.size !== 1) return null;
|
||||
return unsettled.values().next().value ?? null;
|
||||
}
|
||||
|
||||
private isCanonicalCompletedTurnEvent(chatId: string, ev: InboundEvent): boolean {
|
||||
const turnId = "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : null;
|
||||
return (
|
||||
turnId !== null
|
||||
&& this.canonicalCompletedTurnIdsByChatId.get(chatId)?.has(turnId) === true
|
||||
);
|
||||
}
|
||||
|
||||
private isSupersededRunCompletion(chatId: string, ev: InboundEvent): boolean {
|
||||
if (
|
||||
ev.event !== "turn_end"
|
||||
&& !(ev.event === "goal_status" && ev.status === "idle")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const turnId = "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined;
|
||||
const latestRunTurnId = this.latestRunTurnIdByChatId.get(chatId);
|
||||
if (turnId === undefined && latestRunTurnId !== undefined) return true;
|
||||
return (
|
||||
turnId !== undefined
|
||||
&& latestRunTurnId !== undefined
|
||||
&& turnId !== latestRunTurnId
|
||||
);
|
||||
}
|
||||
|
||||
private recordRunCompletion(chatId: string, turnId?: string): void {
|
||||
this.settleRunTurn(chatId, turnId);
|
||||
const latestRunTurnId = this.latestRunTurnIdByChatId.get(chatId);
|
||||
const closesCurrentRun = latestRunTurnId === undefined || turnId === latestRunTurnId;
|
||||
if (closesCurrentRun && this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
}
|
||||
|
||||
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
|
||||
if (ev.event === "turn_end") {
|
||||
if (this.runStartedAtByChatId.has(chatId)) {
|
||||
this.runStartedAtByChatId.delete(chatId);
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
this.recordRunCompletion(chatId, ev.turn_id);
|
||||
return;
|
||||
}
|
||||
if (ev.event !== "goal_status") return;
|
||||
if (ev.status === "running" && typeof ev.started_at === "number") {
|
||||
this.advanceRunGeneration(chatId, ev.turn_id);
|
||||
if (ev.turn_id) {
|
||||
this.runStartedAtByTurnKey.set(
|
||||
this.runSendKey(chatId, ev.turn_id),
|
||||
ev.started_at,
|
||||
);
|
||||
}
|
||||
const previous = this.runStartedAtByChatId.get(chatId);
|
||||
this.runStartedAtByChatId.set(chatId, ev.started_at);
|
||||
if (previous !== ev.started_at) this.emitRunStatus(chatId, ev.started_at);
|
||||
} else if (this.runStartedAtByChatId.has(chatId)) {
|
||||
this.runStartedAtByChatId.delete(chatId);
|
||||
this.emitRunStatus(chatId, null);
|
||||
} else {
|
||||
this.recordRunCompletion(chatId, ev.turn_id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -390,6 +807,8 @@ export class NanobotClient {
|
||||
quotedContext?: string;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
turnId?: string;
|
||||
/** False for side-channel or injected messages that do not own a lifecycle. */
|
||||
startsNewRun?: boolean;
|
||||
},
|
||||
): void {
|
||||
this.knownChats.add(chatId);
|
||||
@ -405,6 +824,22 @@ export class NanobotClient {
|
||||
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
||||
webui: true,
|
||||
};
|
||||
if (!this.frameFitsTransport(frame)) {
|
||||
if (options?.turnId && isSystemCommandTurnId(options.turnId)) {
|
||||
this.rejectSystemCommand(options.turnId, "message_too_big");
|
||||
}
|
||||
this.emitError({
|
||||
kind: "message_too_big",
|
||||
chatId,
|
||||
...(options?.turnId ? { turnId: options.turnId } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (options?.turnId && !isSystemCommandTurnId(options.turnId)) {
|
||||
const startsNewRun = options.startsNewRun !== false;
|
||||
if (startsNewRun) this.advanceRunGeneration(chatId, options.turnId);
|
||||
this.trackPendingMessageSend(chatId, options.turnId, startsNewRun);
|
||||
}
|
||||
this.queueSend(frame);
|
||||
}
|
||||
|
||||
@ -442,6 +877,7 @@ export class NanobotClient {
|
||||
if (this.runStartedAtByChatId.size === 0) return;
|
||||
const chatIds = [...this.runStartedAtByChatId.keys()];
|
||||
this.runStartedAtByChatId.clear();
|
||||
this.runStartedAtByTurnKey.clear();
|
||||
for (const chatId of chatIds) this.emitRunStatus(chatId, null);
|
||||
}
|
||||
|
||||
@ -476,16 +912,61 @@ export class NanobotClient {
|
||||
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
|
||||
}
|
||||
|
||||
if (parsed.event === "error" && !parsed.turn_id) {
|
||||
const fallback = this.legacyRejectionTarget(parsed);
|
||||
if (fallback) {
|
||||
parsed = {
|
||||
...parsed,
|
||||
chat_id: parsed.chat_id ?? fallback.chatId,
|
||||
turn_id: fallback.turnId,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (
|
||||
(parsed.event === "goal_status" || parsed.event === "turn_end")
|
||||
&& !parsed.turn_id
|
||||
) {
|
||||
const fallbackTurnId = this.uniqueUnsettledTurnId(parsed.chat_id);
|
||||
if (fallbackTurnId) parsed = { ...parsed, turn_id: fallbackTurnId };
|
||||
}
|
||||
|
||||
const turnId = "turn_id" in parsed && typeof parsed.turn_id === "string"
|
||||
? parsed.turn_id
|
||||
: null;
|
||||
if (parsed.event === "message_accepted") {
|
||||
this.recordRunAcceptance(parsed.chat_id, parsed.turn_id);
|
||||
return;
|
||||
}
|
||||
if (isSystemCommandTurnId(turnId)) {
|
||||
if (parsed.event === "message" || parsed.event === "turn_end") {
|
||||
if (parsed.event === "error") {
|
||||
this.rejectSystemCommand(
|
||||
turnId,
|
||||
[parsed.detail, parsed.reason].filter(Boolean).join(":") || "server error",
|
||||
);
|
||||
} else if (parsed.event === "message" || parsed.event === "turn_end") {
|
||||
this.resolveSystemCommand(turnId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const correlatedChatId = (parsed as { chat_id?: string }).chat_id;
|
||||
if (parsed.event === "error" && correlatedChatId && turnId) {
|
||||
this.recordRunRejection(correlatedChatId, turnId);
|
||||
if (parsed.detail !== "workspace_scope_rejected") {
|
||||
this.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: parsed.detail,
|
||||
reason: parsed.reason,
|
||||
chatId: correlatedChatId,
|
||||
turnId,
|
||||
});
|
||||
}
|
||||
} else if (parsed.event !== "error" && correlatedChatId && turnId) {
|
||||
// Lifecycle traffic is also an implicit acceptance signal for clients
|
||||
// connected to an older gateway that doesn't emit message_accepted.
|
||||
this.recordRunAcceptance(correlatedChatId, turnId);
|
||||
}
|
||||
|
||||
if (parsed.event === "ready") {
|
||||
this.readyChatId = parsed.chat_id;
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
@ -528,6 +1009,7 @@ export class NanobotClient {
|
||||
kind: "workspace_scope_rejected",
|
||||
reason: parsed.reason,
|
||||
chatId: parsed.chat_id,
|
||||
turnId: parsed.turn_id,
|
||||
});
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
@ -546,7 +1028,10 @@ export class NanobotClient {
|
||||
|
||||
const chatId = (parsed as { chat_id?: string }).chat_id;
|
||||
if (chatId) {
|
||||
if (this.isCanonicalCompletedTurnEvent(chatId, parsed)) return;
|
||||
const supersededRunCompletion = this.isSupersededRunCompletion(chatId, parsed);
|
||||
this.recordGoalStatusForRunStrip(chatId, parsed);
|
||||
if (supersededRunCompletion) return;
|
||||
this.recordGoalStateSnapshot(chatId, parsed);
|
||||
this.dispatch(chatId, parsed);
|
||||
}
|
||||
@ -611,9 +1096,44 @@ export class NanobotClient {
|
||||
// display the error even while the client transparently reconnects.
|
||||
// Browsers populate ``CloseEvent.code`` with the wire-level close code;
|
||||
// 1009 = Message Too Big (server's max frame guard).
|
||||
const unacknowledged = Array.from(this.socketPendingMessageSendKeys)
|
||||
.map((key) => this.pendingMessageSends.get(key))
|
||||
.filter((pending): pending is PendingMessageSend => pending !== undefined);
|
||||
if (event?.code === 1009) {
|
||||
this.emitError({ kind: "message_too_big" });
|
||||
const soleKey = unacknowledged.length === 1
|
||||
? this.runSendKey(unacknowledged[0].chatId, unacknowledged[0].turnId)
|
||||
: null;
|
||||
if (
|
||||
unacknowledged.length === 1
|
||||
&& this.lastSocketMessageSendKey === soleKey
|
||||
) {
|
||||
const [rejected] = unacknowledged;
|
||||
this.recordRunRejection(rejected.chatId, rejected.turnId);
|
||||
this.emitError({
|
||||
kind: "message_too_big",
|
||||
chatId: rejected.chatId,
|
||||
turnId: rejected.turnId,
|
||||
});
|
||||
this.dispatch(rejected.chatId, {
|
||||
event: "error",
|
||||
detail: "message_too_big",
|
||||
chat_id: rejected.chatId,
|
||||
turn_id: rejected.turnId,
|
||||
});
|
||||
} else {
|
||||
// A close frame identifies no offending application message. Never
|
||||
// roll back multiple chats merely because they shared one socket.
|
||||
this.emitError({ kind: "message_too_big" });
|
||||
}
|
||||
}
|
||||
for (const pending of unacknowledged) {
|
||||
const current = this.pendingMessageSends.get(
|
||||
this.runSendKey(pending.chatId, pending.turnId),
|
||||
);
|
||||
if (current?.state === "sent") current.state = "unknown";
|
||||
}
|
||||
this.socketPendingMessageSendKeys.clear();
|
||||
this.lastSocketMessageSendKey = null;
|
||||
if (this.intentionallyClosed || !this.shouldReconnect) {
|
||||
this.setStatus("closed");
|
||||
return;
|
||||
@ -671,6 +1191,14 @@ export class NanobotClient {
|
||||
pending.resolve();
|
||||
}
|
||||
|
||||
private rejectSystemCommand(turnId: string, detail: string): void {
|
||||
const pending = this.pendingSystemCommands.get(turnId);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingSystemCommands.delete(turnId);
|
||||
pending.reject(new Error(detail));
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.clearRunStatusesForReconnect();
|
||||
this.setStatus("reconnecting");
|
||||
@ -699,10 +1227,25 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private frameFitsTransport(frame: Outbound): boolean {
|
||||
if (this.maxFrameBytes === undefined) return true;
|
||||
return new TextEncoder().encode(JSON.stringify(frame)).byteLength <= this.maxFrameBytes;
|
||||
}
|
||||
|
||||
private rawSend(frame: Outbound): void {
|
||||
if (!this.socket) return;
|
||||
try {
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
this.lastSocketMessageSendKey = null;
|
||||
if (frame.type === "message" && frame.turn_id) {
|
||||
const key = this.runSendKey(frame.chat_id, frame.turn_id);
|
||||
const pending = this.pendingMessageSends.get(key);
|
||||
if (pending) {
|
||||
pending.state = "sent";
|
||||
this.socketPendingMessageSendKeys.add(key);
|
||||
this.lastSocketMessageSendKey = key;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Send failure will materialize as a close; queue the frame for retry.
|
||||
this.sendQueue.push(frame);
|
||||
|
||||
@ -1082,6 +1082,7 @@ export interface InboundTurnMetadata {
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string }
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| ({
|
||||
event: "message";
|
||||
chat_id: string;
|
||||
@ -1149,14 +1150,14 @@ export type InboundEvent =
|
||||
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
|
||||
goal_state?: GoalStateWsPayload;
|
||||
} & InboundTurnMetadata)
|
||||
| {
|
||||
| ({
|
||||
event: "goal_status";
|
||||
chat_id: string;
|
||||
/** Turn executing (user message through agent loop). */
|
||||
status: "running" | "idle";
|
||||
/** Server ``time.time()`` when ``status`` is ``running``. */
|
||||
started_at?: number;
|
||||
}
|
||||
} & InboundTurnMetadata)
|
||||
| {
|
||||
event: "goal_state";
|
||||
chat_id: string;
|
||||
@ -1175,7 +1176,14 @@ export type InboundEvent =
|
||||
detail?: string;
|
||||
provider?: string;
|
||||
}
|
||||
| { event: "error"; chat_id?: string; detail?: string; reason?: string };
|
||||
| {
|
||||
event: "error";
|
||||
chat_id?: string;
|
||||
detail?: string;
|
||||
reason?: string;
|
||||
/** Present when this error rejects a specific outbound WebUI turn. */
|
||||
turn_id?: string;
|
||||
};
|
||||
|
||||
/** Base64-encoded file attached to an outbound ``message`` envelope.
|
||||
*
|
||||
@ -1224,7 +1232,11 @@ export interface WebuiThreadPersistedPayload {
|
||||
savedAt?: string;
|
||||
messages: UIMessage[];
|
||||
fork_boundary_message_count?: number;
|
||||
/** Turn ids backed by an explicit persisted ``turn_end`` event. */
|
||||
completed_turn_ids?: string[];
|
||||
has_pending_tool_calls?: boolean;
|
||||
/** Exact active turn when supplied by a current gateway. */
|
||||
active_turn_id?: string | null;
|
||||
page?: WebuiThreadPagePayload;
|
||||
workspace_scope?: WorkspaceScopePayload;
|
||||
}
|
||||
|
||||
@ -77,6 +77,7 @@ describe("webui API helpers", () => {
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@ -217,6 +217,7 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
attach = attachSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
updateMaxFrameBytes = vi.fn();
|
||||
}
|
||||
|
||||
return { NanobotClient: MockClient };
|
||||
|
||||
@ -238,6 +238,891 @@ describe("NanobotClient", () => {
|
||||
expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
|
||||
});
|
||||
|
||||
it("rejects a completed snapshot when a newer run is not represented", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const requestGeneration = client.getRunGeneration("chat-race");
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-race",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-new",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-race", requestGeneration, ["turn-old"]),
|
||||
).toBe(false);
|
||||
expect(client.getRunStartedAt("chat-race")).toBe(12_345);
|
||||
});
|
||||
|
||||
it("rejects a user-only snapshot for a submitted turn that has not completed", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-submitted", "question", undefined, { turnId: "turn-submitted" });
|
||||
const requestGeneration = client.getRunGeneration("chat-submitted");
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-submitted", requestGeneration, []),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not register injected guidance as an independently unsettled run", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-guidance",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-active",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-guidance");
|
||||
|
||||
client.sendMessage("chat-guidance", "focus on sources", undefined, {
|
||||
turnId: "turn-guidance",
|
||||
startsNewRun: false,
|
||||
});
|
||||
|
||||
expect(client.getRunGeneration("chat-guidance")).toBe(requestGeneration);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-guidance",
|
||||
requestGeneration,
|
||||
["turn-active"],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an explicitly completed turn with no assistant row", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-empty-answer", "question", undefined, {
|
||||
turnId: "turn-empty-answer",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-empty-answer");
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-empty-answer",
|
||||
requestGeneration,
|
||||
["turn-empty-answer"],
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"message_rejected",
|
||||
"attachment_rejected",
|
||||
"workspace_scope_rejected",
|
||||
])("settles a specifically rejected outbound turn (%s)", (detail) => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-rejected", "question", undefined, {
|
||||
turnId: "turn-rejected",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-rejected");
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
|
||||
).toBe(false);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-rejected",
|
||||
turn_id: "turn-rejected",
|
||||
detail,
|
||||
reason: "policy",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let an older rejection settle or stop a newer run", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-rejection-race", "first", undefined, {
|
||||
turnId: "turn-old",
|
||||
});
|
||||
client.sendMessage("chat-rejection-race", "second", undefined, {
|
||||
turnId: "turn-new",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-rejection-race",
|
||||
status: "running",
|
||||
started_at: 2_000,
|
||||
turn_id: "turn-new",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-rejection-race");
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-rejection-race",
|
||||
turn_id: "turn-old",
|
||||
detail: "message_rejected",
|
||||
reason: "text_too_large",
|
||||
});
|
||||
|
||||
expect(client.getRunStartedAt("chat-rejection-race")).toBe(2_000);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-rejection-race", requestGeneration, []),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("restores the previous turn clock when the newer running turn is rejected", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-reject-newer-clock", "first", undefined, {
|
||||
turnId: "turn-clock-first",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reject-newer-clock",
|
||||
status: "running",
|
||||
started_at: 1_000,
|
||||
turn_id: "turn-clock-first",
|
||||
});
|
||||
client.sendMessage("chat-reject-newer-clock", "second", undefined, {
|
||||
turnId: "turn-clock-second",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reject-newer-clock",
|
||||
status: "running",
|
||||
started_at: 2_000,
|
||||
turn_id: "turn-clock-second",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-reject-newer-clock",
|
||||
turn_id: "turn-clock-second",
|
||||
detail: "message_rejected",
|
||||
});
|
||||
|
||||
expect(client.getRunStartedAt("chat-reject-newer-clock")).toBe(1_000);
|
||||
expect(client.hasUnsettledRun("chat-reject-newer-clock")).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back lifecycle sends that close 1009 before server acceptance", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-too-big", "oversized", undefined, {
|
||||
turnId: "turn-too-big",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-too-big");
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
expect(errors).toEqual([{
|
||||
kind: "message_too_big",
|
||||
chatId: "chat-too-big",
|
||||
turnId: "turn-too-big",
|
||||
}]);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-too-big", requestGeneration, []),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves an accepted older run when a newer send closes 1009", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-too-big-race", "first", undefined, {
|
||||
turnId: "turn-accepted",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-too-big-race",
|
||||
turn_id: "turn-accepted",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-too-big-race",
|
||||
status: "running",
|
||||
started_at: 1_000,
|
||||
turn_id: "turn-accepted",
|
||||
});
|
||||
client.sendMessage("chat-too-big-race", "oversized", undefined, {
|
||||
turnId: "turn-rejected",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-too-big-race");
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
expect(errors).toEqual([{
|
||||
kind: "message_too_big",
|
||||
chatId: "chat-too-big-race",
|
||||
turnId: "turn-rejected",
|
||||
}]);
|
||||
expect(client.getRunStartedAt("chat-too-big-race")).toBe(1_000);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-too-big-race",
|
||||
requestGeneration,
|
||||
[],
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not roll back a lifecycle send after its acceptance ACK", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-accepted", "question", undefined, {
|
||||
turnId: "turn-accepted",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-accepted");
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-accepted",
|
||||
turn_id: "turn-accepted",
|
||||
});
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion("chat-accepted", requestGeneration, []),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("preflights exact websocket frame bytes and rejects only the oversized turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
maxFrameBytes: 180,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const sentBefore = lastSocket().sent.length;
|
||||
|
||||
client.sendMessage("chat-preflight-size", "x".repeat(500), undefined, {
|
||||
turnId: "turn-preflight-size",
|
||||
});
|
||||
|
||||
expect(lastSocket().sent).toHaveLength(sentBefore);
|
||||
expect(client.hasUnsettledRun("chat-preflight-size")).toBe(false);
|
||||
expect(errors).toEqual([{
|
||||
kind: "message_too_big",
|
||||
chatId: "chat-preflight-size",
|
||||
turnId: "turn-preflight-size",
|
||||
}]);
|
||||
});
|
||||
|
||||
it("does not attribute a fallback 1009 close across multiple unacknowledged chats", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-size-a", "first", undefined, { turnId: "turn-size-a" });
|
||||
client.sendMessage("chat-size-b", "second", undefined, { turnId: "turn-size-b" });
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
expect(errors).toEqual([{ kind: "message_too_big" }]);
|
||||
expect(client.hasUnsettledRun("chat-size-a")).toBe(true);
|
||||
expect(client.hasUnsettledRun("chat-size-b")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attribute 1009 to an unacknowledged message when another frame followed it", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-before-audio", "question", undefined, {
|
||||
turnId: "turn-before-audio",
|
||||
});
|
||||
const transcription = client.transcribeAudio("data:audio/webm;base64,AAAA");
|
||||
|
||||
lastSocket().fakeCloseWithCode(1009);
|
||||
|
||||
await expect(transcription).rejects.toThrow("socket closed");
|
||||
expect(errors).toEqual([{ kind: "message_too_big" }]);
|
||||
expect(client.hasUnsettledRun("chat-before-audio")).toBe(true);
|
||||
});
|
||||
|
||||
it("settles an unknown send absent from an idle canonical snapshot after disconnect", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-never-arrived", "question", undefined, {
|
||||
turnId: "turn-never-arrived",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-never-arrived");
|
||||
|
||||
lastSocket().close();
|
||||
|
||||
const snapshot = {
|
||||
observedTurnIds: [],
|
||||
hasPendingToolCalls: false,
|
||||
activeTurnId: null,
|
||||
};
|
||||
expect(
|
||||
client.canReconcileCanonicalCompletion(
|
||||
"chat-never-arrived",
|
||||
requestGeneration,
|
||||
[],
|
||||
snapshot,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-never-arrived",
|
||||
requestGeneration,
|
||||
[],
|
||||
snapshot,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.hasUnsettledRun("chat-never-arrived")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an ACK-lost observed turn active, then settles it from an idle snapshot", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-ack-lost", "question", undefined, {
|
||||
turnId: "turn-ack-lost",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-ack-lost");
|
||||
lastSocket().close();
|
||||
|
||||
expect(
|
||||
client.canReconcileCanonicalCompletion(
|
||||
"chat-ack-lost",
|
||||
requestGeneration,
|
||||
[],
|
||||
{
|
||||
observedTurnIds: ["turn-ack-lost"],
|
||||
hasPendingToolCalls: true,
|
||||
activeTurnId: "turn-ack-lost",
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-ack-lost",
|
||||
requestGeneration,
|
||||
[],
|
||||
{
|
||||
observedTurnIds: ["turn-ack-lost"],
|
||||
hasPendingToolCalls: false,
|
||||
activeTurnId: null,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.hasUnsettledRun("chat-ack-lost")).toBe(false);
|
||||
});
|
||||
|
||||
it("settles an accepted turn that never reached running from canonical idle", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-accepted-idle", "question", undefined, {
|
||||
turnId: "turn-accepted-idle",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-accepted-idle");
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-accepted-idle",
|
||||
turn_id: "turn-accepted-idle",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-accepted-idle",
|
||||
requestGeneration,
|
||||
[],
|
||||
{
|
||||
observedTurnIds: ["turn-accepted-idle"],
|
||||
hasPendingToolCalls: false,
|
||||
activeTurnId: null,
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.hasUnsettledRun("chat-accepted-idle")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a pre-send idle response erase a newly accepted turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const requestGeneration = client.getRunGeneration("chat-stale-idle");
|
||||
client.sendMessage("chat-stale-idle", "question", undefined, {
|
||||
turnId: "turn-after-request",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-stale-idle",
|
||||
turn_id: "turn-after-request",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-stale-idle",
|
||||
requestGeneration,
|
||||
[],
|
||||
{
|
||||
observedTurnIds: [],
|
||||
hasPendingToolCalls: false,
|
||||
activeTurnId: null,
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
expect(client.hasUnsettledRun("chat-stale-idle")).toBe(true);
|
||||
});
|
||||
|
||||
it("correlates a legacy rejection only to one currently sent turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-legacy-reject", "question", undefined, {
|
||||
turnId: "turn-legacy-reject",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-legacy-reject",
|
||||
detail: "message_rejected",
|
||||
reason: "text_too_large",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-legacy-reject")).toBe(false);
|
||||
expect(errors).toEqual([expect.objectContaining({
|
||||
kind: "turn_rejected",
|
||||
chatId: "chat-legacy-reject",
|
||||
turnId: "turn-legacy-reject",
|
||||
})]);
|
||||
});
|
||||
|
||||
it("correlates legacy lifecycle completion when exactly one turn is unsettled", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onChat("chat-legacy-idle", handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-legacy-idle", "question", undefined, {
|
||||
turnId: "turn-legacy-idle",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-legacy-idle",
|
||||
status: "running",
|
||||
started_at: 4321,
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-legacy-idle",
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-legacy-idle")).toBe(false);
|
||||
expect(client.getRunStartedAt("chat-legacy-idle")).toBeNull();
|
||||
expect(handler).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
event: "goal_status",
|
||||
status: "idle",
|
||||
turn_id: "turn-legacy-idle",
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not apply an uncorrelated legacy idle to multiple unsettled turns", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onChat("chat-legacy-ambiguous", handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-legacy-ambiguous", "first", undefined, {
|
||||
turnId: "turn-legacy-first",
|
||||
});
|
||||
client.sendMessage("chat-legacy-ambiguous", "second", undefined, {
|
||||
turnId: "turn-legacy-second",
|
||||
});
|
||||
handler.mockClear();
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-legacy-ambiguous",
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-legacy-ambiguous")).toBe(true);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not correlate a legacy scope error to an already accepted turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
|
||||
client.onError((error) => errors.push(error));
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-legacy-scope", "question", undefined, {
|
||||
turnId: "turn-already-accepted",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat-legacy-scope",
|
||||
turn_id: "turn-already-accepted",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-legacy-scope",
|
||||
detail: "workspace_scope_rejected",
|
||||
reason: "chat_running",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-legacy-scope")).toBe(true);
|
||||
expect(errors).toEqual([{
|
||||
kind: "workspace_scope_rejected",
|
||||
reason: "chat_running",
|
||||
chatId: "chat-legacy-scope",
|
||||
turnId: undefined,
|
||||
}]);
|
||||
});
|
||||
|
||||
it("does not correlate a scope-control rejection to a preceding unacknowledged message", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-scope-control", "question", undefined, {
|
||||
turnId: "turn-before-scope-control",
|
||||
});
|
||||
client.setWorkspaceScope("chat-scope-control", {
|
||||
project_path: "/tmp/project",
|
||||
project_name: "project",
|
||||
access_mode: "restricted",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-scope-control",
|
||||
detail: "workspace_scope_rejected",
|
||||
reason: "chat_running",
|
||||
});
|
||||
|
||||
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-unrelated-scope", "question", undefined, {
|
||||
turnId: "turn-unrelated-scope",
|
||||
});
|
||||
const pendingChat = client.newChat(5_000, {
|
||||
project_path: "/missing",
|
||||
project_name: "missing",
|
||||
access_mode: "restricted",
|
||||
});
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
detail: "workspace_scope_rejected",
|
||||
reason: "project_path must be an existing directory",
|
||||
});
|
||||
|
||||
await expect(pendingChat).rejects.toThrow("workspace_scope_rejected");
|
||||
expect(client.hasUnsettledRun("chat-unrelated-scope")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a correlated system command instead of leaving it pending", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const pending = client.sendSystemCommand("chat-system-reject", "/model invalid");
|
||||
const sent = JSON.parse(lastSocket().sent.at(-1) ?? "{}") as { turn_id?: string };
|
||||
expect(sent.turn_id).toMatch(/^webui-system:/);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "error",
|
||||
chat_id: "chat-system-reject",
|
||||
turn_id: sent.turn_id,
|
||||
detail: "message_rejected",
|
||||
reason: "invalid_command",
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toThrow("message_rejected:invalid_command");
|
||||
});
|
||||
|
||||
it("ignores a delayed idle event from an older turn after a new run starts", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatHandler = vi.fn();
|
||||
const runHandler = vi.fn();
|
||||
client.onChat("chat-delayed-idle", chatHandler);
|
||||
client.onRunStatus(runHandler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-delayed-idle",
|
||||
status: "running",
|
||||
started_at: 1_000,
|
||||
turn_id: "turn-old",
|
||||
});
|
||||
client.sendMessage("chat-delayed-idle", "next question", undefined, {
|
||||
turnId: "turn-new",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-delayed-idle",
|
||||
status: "running",
|
||||
started_at: 2_000,
|
||||
turn_id: "turn-new",
|
||||
});
|
||||
chatHandler.mockClear();
|
||||
runHandler.mockClear();
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-delayed-idle",
|
||||
status: "idle",
|
||||
turn_id: "turn-old",
|
||||
});
|
||||
|
||||
expect(client.getRunStartedAt("chat-delayed-idle")).toBe(2_000);
|
||||
expect(runHandler).not.toHaveBeenCalled();
|
||||
expect(chatHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a completed snapshot that represents a delayed running frame", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const requestGeneration = client.getRunGeneration("chat-delayed-run");
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-delayed-run",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-complete",
|
||||
});
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-delayed-run",
|
||||
requestGeneration,
|
||||
["turn-complete"],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.getRunStartedAt("chat-delayed-run")).toBeNull();
|
||||
});
|
||||
|
||||
it("preflights canonical completion without fencing or settling the turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatHandler = vi.fn();
|
||||
client.onChat("chat-preflight", chatHandler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
client.sendMessage("chat-preflight", "question", undefined, {
|
||||
turnId: "turn-preflight",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-preflight");
|
||||
|
||||
expect(
|
||||
client.canReconcileCanonicalCompletion(
|
||||
"chat-preflight",
|
||||
requestGeneration,
|
||||
["turn-preflight"],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
client.canReconcileCanonicalCompletion("chat-preflight", requestGeneration, []),
|
||||
).toBe(false);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "delta",
|
||||
chat_id: "chat-preflight",
|
||||
turn_id: "turn-preflight",
|
||||
text: "still live",
|
||||
});
|
||||
expect(chatHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: "delta", text: "still live" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the run cache and fences delayed frames after canonical completion", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatHandler = vi.fn();
|
||||
const runHandler = vi.fn();
|
||||
client.onChat("chat-canonical", chatHandler);
|
||||
client.onRunStatus(runHandler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-canonical",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-canonical",
|
||||
});
|
||||
const requestGeneration = client.getRunGeneration("chat-canonical");
|
||||
|
||||
expect(
|
||||
client.reconcileCanonicalCompletion(
|
||||
"chat-canonical",
|
||||
requestGeneration,
|
||||
["turn-canonical"],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(client.getRunStartedAt("chat-canonical")).toBeNull();
|
||||
expect(runHandler).toHaveBeenLastCalledWith("chat-canonical", null);
|
||||
const deliveredBeforeLateFrames = chatHandler.mock.calls.length;
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "delta",
|
||||
chat_id: "chat-canonical",
|
||||
text: " delayed",
|
||||
turn_id: "turn-canonical",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "turn_end",
|
||||
chat_id: "chat-canonical",
|
||||
turn_id: "turn-canonical",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-canonical",
|
||||
status: "idle",
|
||||
turn_id: "turn-canonical",
|
||||
});
|
||||
|
||||
expect(chatHandler).toHaveBeenCalledTimes(deliveredBeforeLateFrames);
|
||||
expect(client.getRunStartedAt("chat-canonical")).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies run status subscribers and replays running chats", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,15 +3,20 @@ import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||
import type { InboundEvent, GoalStateWsPayload } from "@/lib/types";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import type { ConnectionStatus, InboundEvent, GoalStateWsPayload } from "@/lib/types";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
|
||||
|
||||
function fakeClient() {
|
||||
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
|
||||
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
|
||||
const errorHandlers = new Set<(error: StreamError) => void>();
|
||||
const runStartedAtByChatId = new Map<string, number>();
|
||||
const unsettledRunByChatId = new Map<string, boolean>();
|
||||
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
let status: ConnectionStatus = "open";
|
||||
|
||||
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
|
||||
if (ev.event === "turn_end") {
|
||||
@ -38,10 +43,19 @@ function fakeClient() {
|
||||
|
||||
return {
|
||||
client: {
|
||||
status: "open" as const,
|
||||
get status() {
|
||||
return status;
|
||||
},
|
||||
defaultChatId: null as string | null,
|
||||
onStatus: () => () => {},
|
||||
onError: () => () => {},
|
||||
onStatus(handler: (nextStatus: ConnectionStatus) => void) {
|
||||
statusHandlers.add(handler);
|
||||
handler(status);
|
||||
return () => statusHandlers.delete(handler);
|
||||
},
|
||||
onError(handler: (error: StreamError) => void) {
|
||||
errorHandlers.add(handler);
|
||||
return () => errorHandlers.delete(handler);
|
||||
},
|
||||
getRunStartedAt(chatId: string) {
|
||||
const v = runStartedAtByChatId.get(chatId);
|
||||
return v === undefined ? null : v;
|
||||
@ -49,6 +63,9 @@ function fakeClient() {
|
||||
getGoalState(chatId: string) {
|
||||
return goalStateByChatId.get(chatId);
|
||||
},
|
||||
hasUnsettledRun(chatId: string) {
|
||||
return unsettledRunByChatId.get(chatId) === true;
|
||||
},
|
||||
onChat(chatId: string, h: (ev: InboundEvent) => void) {
|
||||
let set = handlers.get(chatId);
|
||||
if (!set) {
|
||||
@ -72,6 +89,16 @@ function fakeClient() {
|
||||
const set = handlers.get(chatId);
|
||||
set?.forEach((h) => h(ev));
|
||||
},
|
||||
emitStatus(nextStatus: ConnectionStatus) {
|
||||
status = nextStatus;
|
||||
statusHandlers.forEach((handler) => handler(status));
|
||||
},
|
||||
emitError(error: StreamError) {
|
||||
errorHandlers.forEach((handler) => handler(error));
|
||||
},
|
||||
setUnsettled(chatId: string, unsettled: boolean) {
|
||||
unsettledRunByChatId.set(chatId, unsettled);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -180,6 +207,64 @@ describe("useNanobotStream", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the turn pending on disconnect without breaking a resumed stream", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-reconnect", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-reconnect", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reconnect",
|
||||
status: "running",
|
||||
started_at: 1_700,
|
||||
});
|
||||
fake.emit("chat-reconnect", {
|
||||
event: "delta",
|
||||
chat_id: "chat-reconnect",
|
||||
text: "partial",
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
const assistantId = result.current.messages[0].id;
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
|
||||
act(() => fake.emitStatus("reconnecting"));
|
||||
expect(result.current.runStartedAt).toBe(1_700);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
id: assistantId,
|
||||
content: "partial",
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitStatus("open");
|
||||
fake.emit("chat-reconnect", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reconnect",
|
||||
status: "running",
|
||||
started_at: 1_800,
|
||||
});
|
||||
fake.emit("chat-reconnect", {
|
||||
event: "delta",
|
||||
chat_id: "chat-reconnect",
|
||||
text: " resumed",
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.runStartedAt).toBe(1_800);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
id: assistantId,
|
||||
content: "partial resumed",
|
||||
isStreaming: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("flushes pending delta text before turn_end finalizes the turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
|
||||
@ -1596,6 +1681,224 @@ describe("useNanobotStream", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("removes only the optimistic turn named by a correlated rejection", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-reject-one", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let first: ReturnType<typeof result.current.send> = null;
|
||||
let second: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
first = result.current.send("first");
|
||||
second = result.current.send("second");
|
||||
});
|
||||
fake.setUnsettled("chat-reject-one", true);
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "message_rejected",
|
||||
chatId: "chat-reject-one",
|
||||
turnId: first!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
id: second!.userMessageId,
|
||||
turnId: second!.turnId,
|
||||
content: "second",
|
||||
}),
|
||||
]);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.streamError).toMatchObject({
|
||||
kind: "turn_rejected",
|
||||
turnId: first!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the previous running turn when the newer turn is rejected", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-reject-new", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let first: ReturnType<typeof result.current.send> = null;
|
||||
let second: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
first = result.current.send("first");
|
||||
fake.emit("chat-reject-new", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-reject-new",
|
||||
status: "running",
|
||||
started_at: 1234,
|
||||
turn_id: first!.turnId,
|
||||
});
|
||||
second = result.current.send("second");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "attachment_rejected",
|
||||
chatId: "chat-reject-new",
|
||||
turnId: second!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
id: first!.userMessageId,
|
||||
turnId: first!.turnId,
|
||||
}),
|
||||
]);
|
||||
expect(result.current.runStartedAt).toBe(1234);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("ends the spinner and drops pending stream work when the only turn is rejected", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-reject-only", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let submitted: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
submitted = result.current.send("only");
|
||||
fake.emit("chat-reject-only", {
|
||||
event: "delta",
|
||||
chat_id: "chat-reject-only",
|
||||
turn_id: submitted!.turnId,
|
||||
text: "must not survive",
|
||||
});
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "access_denied",
|
||||
chatId: "chat-reject-only",
|
||||
turnId: submitted!.turnId,
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.runStartedAt).toBeNull();
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("applies a correlated rejection replayed through the chat event queue", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-replayed-reject", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let submitted: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
submitted = result.current.send("queued optimistic row");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-replayed-reject", {
|
||||
event: "error",
|
||||
detail: "message_rejected",
|
||||
reason: "policy",
|
||||
chat_id: "chat-replayed-reject",
|
||||
turn_id: submitted!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.streamError).toMatchObject({
|
||||
kind: "turn_rejected",
|
||||
chatId: "chat-replayed-reject",
|
||||
turnId: submitted!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show or apply an error correlated to another chat", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-visible", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let submitted: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
submitted = result.current.send("stay");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "message_rejected",
|
||||
chatId: "chat-background",
|
||||
turnId: submitted!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].content).toBe("stay");
|
||||
expect(result.current.streamError).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an uncorrelated 1009 fault without rolling back the current turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-generic-1009", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
act(() => {
|
||||
result.current.send("stay visible");
|
||||
fake.emitError({ kind: "message_too_big" });
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({ role: "user", content: "stay visible" }),
|
||||
]);
|
||||
expect(result.current.streamError).toEqual({ kind: "message_too_big" });
|
||||
});
|
||||
|
||||
it("removes rejected side-channel guidance without stopping the main run", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-side-reject", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
let main: ReturnType<typeof result.current.send> = null;
|
||||
let side: ReturnType<typeof result.current.send> = null;
|
||||
act(() => {
|
||||
main = result.current.send("main");
|
||||
fake.emit("chat-side-reject", {
|
||||
event: "goal_status",
|
||||
chat_id: "chat-side-reject",
|
||||
status: "running",
|
||||
started_at: 9876,
|
||||
turn_id: main!.turnId,
|
||||
});
|
||||
side = result.current.send("guidance", undefined, { sideChannel: true });
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emitError({
|
||||
kind: "turn_rejected",
|
||||
detail: "message_rejected",
|
||||
chatId: "chat-side-reject",
|
||||
turnId: side!.turnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
id: main!.userMessageId,
|
||||
turnId: main!.turnId,
|
||||
}),
|
||||
]);
|
||||
expect(result.current.runStartedAt).toBe(9876);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("adds optimistic user file attachments as media", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
|
||||
@ -1801,6 +2104,7 @@ describe("useNanobotStream", () => {
|
||||
const call = fake.client.sendMessage.mock.calls.at(-1)!;
|
||||
const turnId = call[3]?.turnId;
|
||||
expect(call[3]).not.toHaveProperty("sideChannel");
|
||||
expect(call[3]).toMatchObject({ startsNewRun: false });
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
|
||||
act(() => {
|
||||
@ -1956,6 +2260,7 @@ describe("useNanobotStream", () => {
|
||||
|
||||
const guideCall = fake.client.sendMessage.mock.calls.at(-1)!;
|
||||
expect(guideCall[3]).not.toHaveProperty("continueActiveTurn");
|
||||
expect(guideCall[3]).toMatchObject({ startsNewRun: false });
|
||||
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||
"research this",
|
||||
"Initial findings",
|
||||
|
||||
@ -450,6 +450,32 @@ describe("useSessions", () => {
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes turn ids backed by persisted completion events", async () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
completed_turn_ids: ["turn-empty", "", "turn-empty"],
|
||||
messages: [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "stop",
|
||||
turnId: "turn-empty",
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:chat-empty"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.completedTurnIds).toEqual(["turn-empty"]);
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("does not flag transcript as pending when last row is not a trace", async () => {
|
||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||
schemaVersion: 3,
|
||||
@ -520,6 +546,9 @@ describe("useSessions", () => {
|
||||
});
|
||||
expect(result.current.hasMoreBefore).toBe(true);
|
||||
expect(result.current.userMessageOffset).toBe(1);
|
||||
const latestVersion = result.current.version;
|
||||
const latestLineage = result.current.lineage;
|
||||
expect(result.current.continuity).toBe("initial");
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
@ -537,6 +566,372 @@ describe("useSessions", () => {
|
||||
]);
|
||||
expect(result.current.hasMoreBefore).toBe(false);
|
||||
expect(result.current.userMessageOffset).toBe(0);
|
||||
expect(result.current.version).toBe(latestVersion);
|
||||
expect(result.current.lineage).toBe(latestLineage);
|
||||
expect(result.current.continuity).toBe("initial");
|
||||
});
|
||||
|
||||
it("preserves a loaded prefix when a canonical latest window overlaps its tail", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: true,
|
||||
messages: [
|
||||
{ id: "u2", role: "user", content: "middle question", createdAt: 2 },
|
||||
{ id: "a2", role: "assistant", content: "middle answer", createdAt: 3 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-middle",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 0,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
completed_turn_ids: ["turn-3"],
|
||||
messages: [
|
||||
{ id: "a2-replayed", role: "assistant", content: "middle answer", createdAt: 3 },
|
||||
{
|
||||
id: "u3",
|
||||
role: "user",
|
||||
content: "latest question",
|
||||
turnId: "turn-3",
|
||||
createdAt: 4,
|
||||
},
|
||||
{
|
||||
id: "a3",
|
||||
role: "assistant",
|
||||
content: "latest answer",
|
||||
turnId: "turn-3",
|
||||
createdAt: 5,
|
||||
},
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-shifted",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 3,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:paged-refresh"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
const loadedVersion = result.current.version;
|
||||
const loadedLineage = result.current.lineage;
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(result.current.version).toBeGreaterThan(loadedVersion));
|
||||
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"u1",
|
||||
"a1",
|
||||
"u2",
|
||||
"a2-replayed",
|
||||
"u3",
|
||||
"a3",
|
||||
]);
|
||||
expect(result.current.hasMoreBefore).toBe(false);
|
||||
expect(result.current.userMessageOffset).toBe(0);
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
expect(result.current.completedTurnIds).toEqual(["turn-3"]);
|
||||
expect(result.current.continuity).toBe("overlap");
|
||||
expect(result.current.lineage).toBe(loadedLineage);
|
||||
});
|
||||
|
||||
it("starts a new lineage when more than 160 new rows remove all latest-page overlap", async () => {
|
||||
const oldWindow = Array.from({ length: 160 }, (_, index) => ({
|
||||
id: `old-${index}`,
|
||||
role: index % 2 === 0 ? "user" as const : "assistant" as const,
|
||||
content: `old window row ${index}`,
|
||||
turnId: `old-turn-${Math.floor(index / 2)}`,
|
||||
createdAt: index,
|
||||
}));
|
||||
const newWindow = Array.from({ length: 160 }, (_, index) => ({
|
||||
id: `new-${index}`,
|
||||
role: index % 2 === 0 ? "user" as const : "assistant" as const,
|
||||
content: `new window row ${index}`,
|
||||
turnId: `new-turn-${Math.floor(index / 2)}`,
|
||||
createdAt: 1_000 + index,
|
||||
}));
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: oldWindow,
|
||||
page: {
|
||||
before_cursor: "old-window-cursor",
|
||||
has_more_before: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: newWindow,
|
||||
page: {
|
||||
before_cursor: "new-window-cursor",
|
||||
has_more_before: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:window-reset"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const initialLineage = result.current.lineage;
|
||||
expect(result.current.messages[0]?.id).toBe("old-0");
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(result.current.messages[0]?.id).toBe("new-0"));
|
||||
|
||||
expect(result.current.messages).toHaveLength(160);
|
||||
expect(result.current.messages.at(-1)?.id).toBe("new-159");
|
||||
expect(result.current.continuity).toBe("reset");
|
||||
expect(result.current.lineage).toBeGreaterThan(initialLineage);
|
||||
expect(result.current.hasMoreBefore).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the longest consecutive semantic overlap for legacy unstable replay metadata", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "repeat-1-old", role: "user", content: "repeat", createdAt: 10 },
|
||||
{ id: "answer-1-old", role: "assistant", content: "first answer", createdAt: 11 },
|
||||
{ id: "repeat-2-old", role: "user", content: "repeat", createdAt: 12 },
|
||||
{ id: "answer-2-old", role: "assistant", content: "second answer", createdAt: 13 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "legacy-cursor",
|
||||
has_more_before: true,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "old-prefix", role: "user", content: "old prefix", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "repeat-2-new", role: "user", content: "repeat", createdAt: 9_012 },
|
||||
{ id: "answer-2-new", role: "assistant", content: "second answer", createdAt: 9_013 },
|
||||
{ id: "new-tail", role: "assistant", content: "new tail", createdAt: 9_014 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "shifted-legacy-cursor",
|
||||
has_more_before: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:legacy-overlap"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
const lineage = result.current.lineage;
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(result.current.messages.at(-1)?.id).toBe("new-tail"));
|
||||
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual([
|
||||
"old-prefix",
|
||||
"repeat-1-old",
|
||||
"answer-1-old",
|
||||
"repeat-2-new",
|
||||
"answer-2-new",
|
||||
"new-tail",
|
||||
]);
|
||||
expect(result.current.continuity).toBe("overlap");
|
||||
expect(result.current.lineage).toBe(lineage);
|
||||
});
|
||||
|
||||
it("ignores an older-page response after a latest refresh resets its lineage", async () => {
|
||||
let resolveOlder:
|
||||
| ((value: Awaited<ReturnType<typeof api.fetchWebuiThread>>) => void)
|
||||
| null = null;
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "old-latest", role: "assistant", content: "old latest", createdAt: 10 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-old-lineage",
|
||||
has_more_before: true,
|
||||
},
|
||||
})
|
||||
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||
resolveOlder = resolve;
|
||||
}))
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "new-latest", role: "assistant", content: "new latest", createdAt: 20 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-new-lineage",
|
||||
has_more_before: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:paged-race"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const oldLineage = result.current.lineage;
|
||||
let olderRequest: Promise<void> | undefined;
|
||||
act(() => {
|
||||
olderRequest = result.current.loadOlder();
|
||||
});
|
||||
await waitFor(() => expect(api.fetchWebuiThread).toHaveBeenCalledTimes(2));
|
||||
|
||||
act(() => result.current.refresh());
|
||||
await waitFor(() => expect(result.current.messages[0]?.id).toBe("new-latest"));
|
||||
expect(result.current.continuity).toBe("reset");
|
||||
expect(result.current.lineage).toBeGreaterThan(oldLineage);
|
||||
|
||||
await act(async () => {
|
||||
resolveOlder?.({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "stale-prefix", role: "user", content: "stale prefix", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
},
|
||||
});
|
||||
await olderRequest;
|
||||
});
|
||||
|
||||
expect(result.current.messages.map((message) => message.id)).toEqual(["new-latest"]);
|
||||
expect(result.current.hasMoreBefore).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves authoritative active state while prepending older history", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: true,
|
||||
messages: [
|
||||
{ id: "u2", role: "user", content: "current question", createdAt: 2 },
|
||||
{ id: "a2", role: "assistant", content: "partial answer", createdAt: 3 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-active",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:paged-active"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.hasPendingToolCalls).toBe(true);
|
||||
const latestVersion = result.current.version;
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(result.current.hasPendingToolCalls).toBe(true);
|
||||
expect(result.current.version).toBe(latestVersion);
|
||||
});
|
||||
|
||||
it("preserves authoritative completed state while prepending trace history", async () => {
|
||||
vi.mocked(api.fetchWebuiThread)
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
has_pending_tool_calls: false,
|
||||
messages: [
|
||||
{
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "completed trace",
|
||||
traces: ["completed trace"],
|
||||
createdAt: 2,
|
||||
},
|
||||
],
|
||||
page: {
|
||||
before_cursor: "cursor-complete",
|
||||
has_more_before: true,
|
||||
loaded_message_count: 1,
|
||||
user_message_offset: 1,
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
schemaVersion: 3,
|
||||
messages: [
|
||||
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
|
||||
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
|
||||
],
|
||||
page: {
|
||||
before_cursor: null,
|
||||
has_more_before: false,
|
||||
loaded_message_count: 2,
|
||||
user_message_offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:paged-complete"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadOlder();
|
||||
});
|
||||
|
||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the session in the list when delete fails", async () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user