fix(webui): reconcile threads after browser resume

This commit is contained in:
chengyongru 2026-07-28 11:40:01 +08:00 committed by chengyongru
parent 78cf68c291
commit ae089aa3ae
39 changed files with 6151 additions and 243 deletions

View File

@ -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 ")

View File

@ -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:

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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:

View File

@ -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"

View File

@ -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)

View File

@ -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,

View File

@ -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

View File

@ -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",
}

View File

@ -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",

View File

@ -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)."""

View File

@ -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:

View File

@ -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

View File

@ -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();
});

View File

@ -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.

View File

@ -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],
);

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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": {

View File

@ -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": {

View File

@ -1237,6 +1237,10 @@
"workspaceScopeRejected": {
"title": "Lespace de travail na pas changé",
"body": "La passerelle a refusé le projet ou le mode daccès demandé ; Nanobot a conservé lespace de travail précédent."
},
"turnRejected": {
"title": "Le message na pas été envoyé",
"body": "La passerelle a refusé ce message. Vérifiez le texte ou les pièces jointes, puis réessayez."
}
},
"workspace": {

View File

@ -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": {

View File

@ -1237,6 +1237,10 @@
"workspaceScopeRejected": {
"title": "ワークスペースは変更されませんでした",
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
},
"turnRejected": {
"title": "メッセージは送信されませんでした",
"body": "ゲートウェイがこのメッセージを拒否しました。本文または添付ファイルを確認して、もう一度お試しください。"
}
},
"workspace": {

View File

@ -1237,6 +1237,10 @@
"workspaceScopeRejected": {
"title": "작업공간이 변경되지 않았습니다",
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
},
"turnRejected": {
"title": "메시지가 전송되지 않았습니다",
"body": "게이트웨이가 이 메시지를 거부했습니다. 텍스트나 첨부 파일을 확인한 후 다시 시도하세요."
}
},
"workspace": {

View File

@ -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": {

View File

@ -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": {

View File

@ -1251,6 +1251,10 @@
"workspaceScopeRejected": {
"title": "工作区未更改",
"body": "网关拒绝了请求的项目或访问权限Nanobot 已继续使用之前的工作区。"
},
"turnRejected": {
"title": "消息未发送",
"body": "网关拒绝了这条消息。请检查消息内容或附件后重试。"
}
},
"workspace": {

View File

@ -1237,6 +1237,10 @@
"workspaceScopeRejected": {
"title": "工作區未變更",
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
},
"turnRejected": {
"title": "訊息未傳送",
"body": "閘道拒絕了這則訊息。請檢查內容或附件後再試一次。"
}
},
"workspace": {

View File

@ -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}`);

View File

@ -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);

View File

@ -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;
}

View File

@ -77,6 +77,7 @@ describe("webui API helpers", () => {
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
credentials: "same-origin",
cache: "no-store",
}),
);
});

View File

@ -217,6 +217,7 @@ vi.mock("@/lib/nanobot-client", () => {
attach = attachSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
updateMaxFrameBytes = vi.fn();
}
return { NanobotClient: MockClient };

View File

@ -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

View File

@ -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",

View File

@ -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 () => {