mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 09:28:34 +00:00
fix(webui): reconcile threads after browser resume
This commit is contained in:
parent
78cf68c291
commit
ae089aa3ae
@ -32,6 +32,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.command.builtin import builtin_command_starts_agent_turn
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_INPUT_META,
|
RUNTIME_CONTEXT_INPUT_META,
|
||||||
@ -43,7 +44,14 @@ from nanobot.security.workspace_access import (
|
|||||||
WorkspaceScopeError,
|
WorkspaceScopeError,
|
||||||
)
|
)
|
||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
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.cli_apps_api import normalize_cli_app_mentions
|
||||||
from nanobot.webui.forking import handle_webui_fork_chat
|
from nanobot.webui.forking import handle_webui_fork_chat
|
||||||
from nanobot.webui.gateway_services import GatewayServices
|
from nanobot.webui.gateway_services import GatewayServices
|
||||||
@ -57,6 +65,11 @@ from nanobot.webui.http_utils import (
|
|||||||
query_first as _query_first,
|
query_first as _query_first,
|
||||||
)
|
)
|
||||||
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
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.transcription_ws import webui_transcription_event
|
||||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
from nanobot.webui.websocket_logging import websockets_server_logger
|
||||||
|
|
||||||
@ -317,7 +330,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
t0 = websocket_turn_wall_started_at(chat_id)
|
t0 = websocket_turn_wall_started_at(chat_id)
|
||||||
if t0 is None:
|
if t0 is None:
|
||||||
return
|
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:
|
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
|
||||||
"""Replay persisted or actively running per-chat state after subscribe."""
|
"""Replay persisted or actively running per-chat state after subscribe."""
|
||||||
@ -633,17 +651,40 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
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):
|
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
|
return
|
||||||
message_rejection = self._ingress.validate_text(content)
|
message_rejection = self._ingress.validate_text(content)
|
||||||
if message_rejection is not None:
|
if message_rejection is not None:
|
||||||
await self._send_event(
|
await self._send_event(
|
||||||
connection,
|
connection,
|
||||||
"error",
|
"error",
|
||||||
chat_id=cid,
|
|
||||||
detail="message_rejected",
|
detail="message_rejected",
|
||||||
reason=message_rejection,
|
reason=message_rejection,
|
||||||
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -656,6 +697,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"error",
|
"error",
|
||||||
detail="attachment_rejected",
|
detail="attachment_rejected",
|
||||||
reason="malformed",
|
reason="malformed",
|
||||||
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
media_paths, reason = self._media.store_inbound_attachments(raw_media)
|
media_paths, reason = self._media.store_inbound_attachments(raw_media)
|
||||||
@ -665,12 +707,18 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"error",
|
"error",
|
||||||
detail="attachment_rejected",
|
detail="attachment_rejected",
|
||||||
reason=reason,
|
reason=reason,
|
||||||
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Allow media-only turns (content may be empty when attachments are present).
|
# Allow media-only turns (content may be empty when attachments are present).
|
||||||
if not content.strip() and not media_paths:
|
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
|
return
|
||||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||||
self._attach(connection, cid)
|
self._attach(connection, cid)
|
||||||
@ -686,10 +734,23 @@ class WebSocketChannel(BaseChannel):
|
|||||||
controls_available=self._workspace_controls_available(connection),
|
controls_available=self._workspace_controls_available(connection),
|
||||||
),
|
),
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
|
turn_id=turn_id,
|
||||||
)
|
)
|
||||||
if scope is None:
|
if scope is None:
|
||||||
return
|
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)}
|
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
||||||
if envelope.get("webui") is True:
|
if envelope.get("webui") is True:
|
||||||
metadata["webui"] = True
|
metadata["webui"] = True
|
||||||
@ -702,29 +763,48 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata["mcp_presets"] = mcp_presets
|
metadata["mcp_presets"] = mcp_presets
|
||||||
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
|
metadata[WORKSPACE_SCOPE_METADATA_KEY] = scope.metadata()
|
||||||
self._workspaces.persist_scope(cid, scope)
|
self._workspaces.persist_scope(cid, scope)
|
||||||
if metadata.get("webui") is True and self.is_allowed(client_id):
|
is_webui = metadata.get("webui") is True
|
||||||
self._transcripts.append_user_message(
|
queued_owner = None
|
||||||
cid,
|
if is_webui and builtin_command_starts_agent_turn(content):
|
||||||
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,
|
metadata=metadata,
|
||||||
media_paths=media_paths or None,
|
is_dm=False,
|
||||||
cli_apps=cli_apps or None,
|
)
|
||||||
mcp_presets=mcp_presets or None,
|
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
|
return
|
||||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||||
|
|
||||||
@ -734,6 +814,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
resolver: Callable[[], Any],
|
resolver: Callable[[], Any],
|
||||||
*,
|
*,
|
||||||
chat_id: str | None = None,
|
chat_id: str | None = None,
|
||||||
|
turn_id: str | None = None,
|
||||||
) -> Any | None:
|
) -> Any | None:
|
||||||
try:
|
try:
|
||||||
return resolver()
|
return resolver()
|
||||||
@ -744,6 +825,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
detail="workspace_scope_rejected",
|
detail="workspace_scope_rejected",
|
||||||
reason=exc.message,
|
reason=exc.message,
|
||||||
**({"chat_id": chat_id} if chat_id else {}),
|
**({"chat_id": chat_id} if chat_id else {}),
|
||||||
|
**({"turn_id": turn_id} if turn_id else {}),
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -782,6 +864,37 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self.logger.exception("send failed{}", label)
|
self.logger.exception("send failed{}", label)
|
||||||
raise
|
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:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
event = outbound_event_from_message(msg)
|
event = outbound_event_from_message(msg)
|
||||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
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})
|
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
||||||
return
|
return
|
||||||
if isinstance(event, GoalStatusEvent):
|
if isinstance(event, GoalStatusEvent):
|
||||||
if conns:
|
turn_id = (msg.metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
||||||
if event.status in ("running", "idle"):
|
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(
|
await self.send_goal_status(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
event.status,
|
event.status,
|
||||||
started_at=event.started_at,
|
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
|
return
|
||||||
# Signal that the agent has fully finished processing the current turn.
|
# Signal that the agent has fully finished processing the current turn.
|
||||||
if isinstance(event, TurnEndEvent):
|
if isinstance(event, TurnEndEvent):
|
||||||
|
turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||||
await self.send_turn_end(
|
await self.send_turn_end(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
latency_ms=event.latency_ms,
|
latency_ms=event.latency_ms,
|
||||||
goal_state=event.goal_state,
|
goal_state=event.goal_state,
|
||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
|
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
||||||
)
|
)
|
||||||
await self.send_session_updated(msg.chat_id, scope="thread")
|
await self.send_session_updated(msg.chat_id, scope="thread")
|
||||||
return
|
return
|
||||||
@ -884,7 +1014,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
elif progress_event:
|
elif progress_event:
|
||||||
payload["kind"] = "progress"
|
payload["kind"] = "progress"
|
||||||
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
|
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,
|
msg.chat_id,
|
||||||
payload,
|
payload,
|
||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
@ -922,7 +1052,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
if stream_id is not None:
|
if stream_id is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = stream_id
|
||||||
self._transcripts.prepare_and_append(
|
self._persist_turn_transcript_event(
|
||||||
chat_id,
|
chat_id,
|
||||||
body,
|
body,
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
@ -950,7 +1080,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
if stream_id is not None:
|
if stream_id is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = stream_id
|
||||||
self._transcripts.prepare_and_append(
|
self._persist_turn_transcript_event(
|
||||||
chat_id,
|
chat_id,
|
||||||
body,
|
body,
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
@ -974,7 +1104,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"edits": edits,
|
"edits": edits,
|
||||||
}
|
}
|
||||||
self._transcripts.prepare_and_append(
|
self._persist_turn_transcript_event(
|
||||||
chat_id,
|
chat_id,
|
||||||
payload,
|
payload,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
@ -1026,7 +1156,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
body["resuming"] = True
|
body["resuming"] = True
|
||||||
if stream_end and merge_next:
|
if stream_end and merge_next:
|
||||||
body["merge_next"] = True
|
body["merge_next"] = True
|
||||||
self._transcripts.prepare_and_append(
|
self._persist_turn_transcript_event(
|
||||||
chat_id,
|
chat_id,
|
||||||
body,
|
body,
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
@ -1045,6 +1175,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
*,
|
*,
|
||||||
goal_state: dict[str, Any] | None = None,
|
goal_state: dict[str, Any] | None = None,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
|
turn_owner: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Signal that the agent has fully finished processing the current turn."""
|
"""Signal that the agent has fully finished processing the current turn."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
@ -1053,12 +1184,27 @@ class WebSocketChannel(BaseChannel):
|
|||||||
body["latency_ms"] = int(latency_ms)
|
body["latency_ms"] = int(latency_ms)
|
||||||
if goal_state is not None:
|
if goal_state is not None:
|
||||||
body["goal_state"] = goal_state
|
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,
|
chat_id,
|
||||||
body,
|
body,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
phase="complete",
|
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)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
if not conns:
|
if not conns:
|
||||||
return
|
return
|
||||||
@ -1081,6 +1227,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
status: str,
|
status: str,
|
||||||
*,
|
*,
|
||||||
started_at: float | None = None,
|
started_at: float | None = None,
|
||||||
|
turn_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Notify subscribed clients that a turn started or finished (wall-clock hint)."""
|
"""Notify subscribed clients that a turn started or finished (wall-clock hint)."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
@ -1093,6 +1240,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
if status == "running" and started_at is not None:
|
if status == "running" and started_at is not None:
|
||||||
body["started_at"] = started_at
|
body["started_at"] = started_at
|
||||||
|
if turn_id:
|
||||||
|
body["turn_id"] = turn_id
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" goal_status ")
|
await self._safe_send_to(connection, raw, label=" goal_status ")
|
||||||
|
|||||||
@ -49,8 +49,13 @@ from nanobot.webui.http_utils import (
|
|||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
parse_request_path as _parse_request_path,
|
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.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
|
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)
|
@pytest.fixture(autouse=True)
|
||||||
def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
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.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.webui.workspaces.get_webui_dir",
|
"nanobot.webui.workspaces.get_webui_dir",
|
||||||
lambda: tmp_path / "webui",
|
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
|
@pytest.mark.asyncio
|
||||||
@ -743,6 +757,7 @@ async def test_webui_scope_rejects_running_scope_change(bus: MagicMock, tmp_path
|
|||||||
"chat_id": "chat-running",
|
"chat_id": "chat-running",
|
||||||
"content": "hello",
|
"content": "hello",
|
||||||
"webui": True,
|
"webui": True,
|
||||||
|
"turn_id": "turn-scope-rejected",
|
||||||
"workspace_scope": {
|
"workspace_scope": {
|
||||||
"project_path": str(other),
|
"project_path": str(other),
|
||||||
"access_mode": "full",
|
"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["detail"] == "workspace_scope_rejected"
|
||||||
assert payload["reason"] == "chat_running"
|
assert payload["reason"] == "chat_running"
|
||||||
assert payload["chat_id"] == "chat-running"
|
assert payload["chat_id"] == "chat-running"
|
||||||
|
assert payload["turn_id"] == "turn-scope-rejected"
|
||||||
bus.publish_inbound.assert_not_awaited()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_turn_end_includes_latency_ms_when_present() -> None:
|
async def test_send_turn_end_includes_latency_ms_when_present() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
@ -1654,6 +2098,7 @@ async def test_send_goal_status_running_emits_event_with_started_at() -> None:
|
|||||||
channel="websocket",
|
channel="websocket",
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
content="",
|
content="",
|
||||||
|
metadata={"webui_turn_id": "turn-running"},
|
||||||
event=GoalStatusEvent(status="running", started_at=1_700_000_000.5),
|
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",
|
"chat_id": "chat-1",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"started_at": 1_700_000_000.5,
|
"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",
|
channel="websocket",
|
||||||
chat_id="chat-1",
|
chat_id="chat-1",
|
||||||
content="",
|
content="",
|
||||||
|
metadata={"webui_turn_id": "turn-idle"},
|
||||||
event=GoalStatusEvent(status="idle", started_at=99.0),
|
event=GoalStatusEvent(status="idle", started_at=99.0),
|
||||||
))
|
))
|
||||||
|
|
||||||
mock_ws.send.assert_awaited_once()
|
mock_ws.send.assert_awaited_once()
|
||||||
body = json.loads(mock_ws.send.await_args.args[0])
|
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
|
@pytest.mark.asyncio
|
||||||
@ -2725,6 +3177,147 @@ async def test_allow_from_rejects_unauthorized_client_id(bus: MagicMock) -> None
|
|||||||
await server_task
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_client_id_truncation(bus: MagicMock) -> None:
|
async def test_client_id_truncation(bus: MagicMock) -> None:
|
||||||
port = 29883
|
port = 29883
|
||||||
@ -3238,6 +3831,255 @@ def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
|
|||||||
assert len(body["messages"]) == 1
|
assert len(body["messages"]) == 1
|
||||||
assert body["messages"][0]["role"] == "user"
|
assert body["messages"][0]["role"] == "user"
|
||||||
assert body["messages"][0]["content"] == "hi"
|
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:
|
def test_handle_webui_thread_get_accepts_pagination_query(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from nanobot.channels.websocket.runtime import (
|
|||||||
WebSocketChannel,
|
WebSocketChannel,
|
||||||
WebSocketConfig,
|
WebSocketConfig,
|
||||||
)
|
)
|
||||||
|
from nanobot.session import webui_turns as wth
|
||||||
from nanobot.webui.gateway_services import build_gateway_services
|
from nanobot.webui.gateway_services import build_gateway_services
|
||||||
|
|
||||||
|
|
||||||
@ -59,6 +60,19 @@ def _make_channel() -> WebSocketChannel:
|
|||||||
return channel
|
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 ----------------------------------------------------
|
# -- max_message_bytes bump ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@ -94,6 +108,28 @@ async def test_message_without_media_backward_compatible() -> None:
|
|||||||
assert call.kwargs["media"] is 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
|
@pytest.mark.asyncio
|
||||||
async def test_message_text_policy_is_independent_from_transport_limit() -> None:
|
async def test_message_text_policy_is_independent_from_transport_limit() -> None:
|
||||||
channel = _make_channel()
|
channel = _make_channel()
|
||||||
@ -102,6 +138,7 @@ async def test_message_text_policy_is_independent_from_transport_limit() -> None
|
|||||||
"type": "message",
|
"type": "message",
|
||||||
"chat_id": "abc123",
|
"chat_id": "abc123",
|
||||||
"content": "你" * 22_000,
|
"content": "你" * 22_000,
|
||||||
|
"turn_id": "turn-text-policy",
|
||||||
}
|
}
|
||||||
|
|
||||||
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
|
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",
|
"chat_id": "abc123",
|
||||||
"detail": "message_rejected",
|
"detail": "message_rejected",
|
||||||
"reason": "text_too_large",
|
"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",
|
"chat_id": "abc123",
|
||||||
"content": "hi",
|
"content": "hi",
|
||||||
"media": [{"data_url": _tiny_png_data_url()}] * 5,
|
"media": [{"data_url": _tiny_png_data_url()}] * 5,
|
||||||
|
"turn_id": "turn-attachments",
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch(
|
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()
|
mock_conn.send.assert_awaited_once()
|
||||||
err = json.loads(mock_conn.send.call_args[0][0])
|
err = json.loads(mock_conn.send.call_args[0][0])
|
||||||
assert err["event"] == "error"
|
assert err["event"] == "error"
|
||||||
|
assert err["chat_id"] == "abc123"
|
||||||
assert err["detail"] == "attachment_rejected"
|
assert err["detail"] == "attachment_rejected"
|
||||||
assert err["reason"] == "too_many_images"
|
assert err["reason"] == "too_many_images"
|
||||||
|
assert err["turn_id"] == "turn-attachments"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@ -53,9 +53,19 @@ async def test_hydrate_after_subscribe_pushes_running_when_turn_active():
|
|||||||
channel.send_goal_state = mock_send_goal_state
|
channel.send_goal_state = mock_send_goal_state
|
||||||
channel.send_goal_status = mock_send_goal_status
|
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")
|
await channel._hydrate_after_subscribe("test-chat")
|
||||||
|
|
||||||
running_events = [e for e in sent_events if e[0] == "goal_status" and e[2] == "running"]
|
running_events = [e for e in sent_events if e[0] == "goal_status" and e[2] == "running"]
|
||||||
assert len(running_events) == 1
|
assert len(running_events) == 1
|
||||||
assert running_events[0][3]["started_at"] == 1234567890.0
|
assert running_events[0][3]["started_at"] == 1234567890.0
|
||||||
|
assert running_events[0][3]["turn_id"] == "turn-active"
|
||||||
|
|||||||
@ -14,7 +14,7 @@ from typing import Literal
|
|||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
from nanobot.agent.goal_permission import goal_mutation_permission
|
from nanobot.agent.goal_permission import goal_mutation_permission
|
||||||
from nanobot.bus.events import OutboundMessage
|
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.helpers import build_status_content
|
||||||
from nanobot.utils.restart import set_restart_notice_to_env
|
from nanobot.utils.restart import set_restart_notice_to_env
|
||||||
from nanobot.utils.workspace_prompts import initialize_workspace_prompt
|
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]
|
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:
|
async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Cancel all active tasks and subagents for the session."""
|
"""Cancel all active tasks and subagents for the session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
|
|||||||
@ -42,7 +42,10 @@ from nanobot.session.history_visibility import is_hidden_history_message
|
|||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.helpers import strip_think, truncate_text
|
from nanobot.utils.helpers import strip_think, truncate_text
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
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_SESSION_METADATA_KEY = "webui"
|
||||||
WEBUI_TITLE_METADATA_KEY = "title"
|
WEBUI_TITLE_METADATA_KEY = "title"
|
||||||
@ -51,9 +54,42 @@ TITLE_MAX_CHARS = 60
|
|||||||
TITLE_GENERATION_MAX_TOKENS = 96
|
TITLE_GENERATION_MAX_TOKENS = 96
|
||||||
TITLE_GENERATION_REASONING_EFFORT = "none"
|
TITLE_GENERATION_REASONING_EFFORT = "none"
|
||||||
|
|
||||||
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
|
# Latest active turn projection per ``chat_id`` (websocket only). It survives browser refresh
|
||||||
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
|
# while the gateway process stays up and is implicitly dropped on restart.
|
||||||
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
|
_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:
|
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)
|
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(
|
def build_bus_progress_callback(
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
@ -229,9 +355,17 @@ async def publish_turn_run_status(
|
|||||||
else:
|
else:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
started_at_event = t0
|
started_at_event = t0
|
||||||
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
|
owner = msg.metadata.get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||||
else:
|
if not isinstance(owner, str) or not owner:
|
||||||
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
|
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(
|
await bus.publish_outbound(
|
||||||
outbound_message_for_event(
|
outbound_message_for_event(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
@ -254,25 +388,50 @@ class WebuiTurnRoutePolicy:
|
|||||||
route: TurnRoute,
|
route: TurnRoute,
|
||||||
) -> TurnRoute:
|
) -> TurnRoute:
|
||||||
"""Make an independently dispatched late subagent result visible in WebUI."""
|
"""Make an independently dispatched late subagent result visible in WebUI."""
|
||||||
|
routed = route
|
||||||
if (
|
if (
|
||||||
msg.channel != "system"
|
msg.channel == "system"
|
||||||
or msg.sender_id != "subagent"
|
and msg.sender_id == "subagent"
|
||||||
or msg.metadata.get("injected_event") != "subagent_result"
|
and msg.metadata.get("injected_event") == "subagent_result"
|
||||||
or route.channel != "websocket"
|
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 routed.channel == "websocket" and routed.publish_lifecycle:
|
||||||
if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
|
metadata = dict(routed.metadata)
|
||||||
return route
|
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)
|
return routed
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver:
|
def build_webui_fallback_model_observer(bus: MessageBus) -> FallbackModelObserver:
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
"""Shared WebUI metadata keys."""
|
"""Shared WebUI metadata keys."""
|
||||||
|
|
||||||
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
||||||
|
WEBSOCKET_TURN_OWNER_METADATA_KEY = "_websocket_turn_owner"
|
||||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN
|
|||||||
|
|
||||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
|
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
|
||||||
WEBUI_FORK_MARKER_EVENT = "fork_marker"
|
WEBUI_FORK_MARKER_EVENT = "fork_marker"
|
||||||
|
WEBUI_TRANSCRIPT_INCOMPLETE_KEY = "transcript_incomplete"
|
||||||
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
|
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
|
||||||
_TARGET_ACTIVE_TRANSCRIPT_BYTES = _MAX_TRANSCRIPT_FILE_BYTES // 2
|
_TARGET_ACTIVE_TRANSCRIPT_BYTES = _MAX_TRANSCRIPT_FILE_BYTES // 2
|
||||||
_TRANSCRIPT_SEGMENT_MANIFEST_VERSION = 2
|
_TRANSCRIPT_SEGMENT_MANIFEST_VERSION = 2
|
||||||
@ -151,6 +152,12 @@ class _TranscriptChunkRef(NamedTuple):
|
|||||||
user_count: int
|
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:
|
def _record_json_line(record: dict[str, Any]) -> str:
|
||||||
return json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
return json.dumps(record, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
|
||||||
@ -665,7 +672,7 @@ class WebUITranscriptRecorder:
|
|||||||
phase: str | None = None,
|
phase: str | None = None,
|
||||||
include_source: bool = False,
|
include_source: bool = False,
|
||||||
transcript_overrides: dict[str, Any] | None = None,
|
transcript_overrides: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> bool:
|
||||||
self.prepare_event(
|
self.prepare_event(
|
||||||
chat_id,
|
chat_id,
|
||||||
event,
|
event,
|
||||||
@ -676,7 +683,7 @@ class WebUITranscriptRecorder:
|
|||||||
record = dict(event)
|
record = dict(event)
|
||||||
if transcript_overrides:
|
if transcript_overrides:
|
||||||
record.update(transcript_overrides)
|
record.update(transcript_overrides)
|
||||||
self.append(chat_id, record)
|
return self.append(chat_id, record)
|
||||||
|
|
||||||
def append_user_message(
|
def append_user_message(
|
||||||
self,
|
self,
|
||||||
@ -687,9 +694,9 @@ class WebUITranscriptRecorder:
|
|||||||
media_paths: list[str] | None = None,
|
media_paths: list[str] | None = None,
|
||||||
cli_apps: list[dict[str, Any]] | None = None,
|
cli_apps: list[dict[str, Any]] | None = None,
|
||||||
mcp_presets: list[dict[str, Any]] | None = None,
|
mcp_presets: list[dict[str, Any]] | None = None,
|
||||||
) -> None:
|
) -> bool:
|
||||||
if text.strip() == "/stop" and not media_paths:
|
if text.strip() == "/stop" and not media_paths:
|
||||||
return
|
return False
|
||||||
payload = build_user_transcript_event(
|
payload = build_user_transcript_event(
|
||||||
chat_id,
|
chat_id,
|
||||||
text,
|
text,
|
||||||
@ -698,15 +705,17 @@ class WebUITranscriptRecorder:
|
|||||||
mcp_presets=mcp_presets,
|
mcp_presets=mcp_presets,
|
||||||
)
|
)
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return
|
return False
|
||||||
self.prepare_and_append(chat_id, payload, metadata=metadata, phase="user")
|
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:
|
try:
|
||||||
dup = json.loads(json.dumps(event, ensure_ascii=False))
|
dup = json.loads(json.dumps(event, ensure_ascii=False))
|
||||||
append_transcript_object(f"websocket:{chat_id}", dup)
|
append_transcript_object(f"websocket:{chat_id}", dup)
|
||||||
except (OSError, ValueError, TypeError) as e:
|
except (OSError, ValueError, TypeError) as e:
|
||||||
self._log.warning("webui transcript append failed: {}", 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:
|
def _next_turn_seq(self, chat_id: str, turn_id: str) -> int:
|
||||||
key = (chat_id, turn_id)
|
key = (chat_id, turn_id)
|
||||||
@ -921,32 +930,69 @@ def _assistant_text_signature(value: Any) -> str:
|
|||||||
return value.strip() if isinstance(value, str) else ""
|
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(
|
def _session_backfill_turns(
|
||||||
session_key: str,
|
session_key: str,
|
||||||
session_messages: list[dict[str, Any]],
|
session_messages: list[dict[str, Any]],
|
||||||
) -> list[tuple[dict[str, Any], tuple[str, ...]]]:
|
) -> list[_SessionBackfillTurn]:
|
||||||
turns: list[tuple[dict[str, Any], tuple[str, ...]]] = []
|
turns: list[_SessionBackfillTurn] = []
|
||||||
current_user: dict[str, Any] | None = None
|
current_user: dict[str, Any] | None = None
|
||||||
assistant_texts: list[str] = []
|
assistant_records: list[dict[str, Any]] = []
|
||||||
|
|
||||||
def flush() -> None:
|
def flush() -> None:
|
||||||
if current_user is None:
|
if current_user is None or not assistant_records:
|
||||||
return
|
return
|
||||||
signature = tuple(text for text in assistant_texts if text)
|
signature = tuple(
|
||||||
if signature:
|
text
|
||||||
turns.append((current_user, signature))
|
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:
|
for message in session_messages:
|
||||||
role = message.get("role")
|
role = message.get("role")
|
||||||
if role == "user":
|
if role == "user":
|
||||||
flush()
|
flush()
|
||||||
current_user = _session_user_event(session_key, message)
|
current_user = _session_user_event(session_key, message)
|
||||||
assistant_texts = []
|
assistant_records = []
|
||||||
continue
|
continue
|
||||||
if role == "assistant" and current_user is not None:
|
if role == "assistant" and current_user is not None:
|
||||||
text = _assistant_text_signature(message.get("content"))
|
record = _session_assistant_event(session_key, message)
|
||||||
if text:
|
if record is not None:
|
||||||
assistant_texts.append(text)
|
assistant_records.append(record)
|
||||||
flush()
|
flush()
|
||||||
return turns
|
return turns
|
||||||
|
|
||||||
@ -976,7 +1022,7 @@ def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...]
|
|||||||
|
|
||||||
|
|
||||||
def _find_unique_session_turn(
|
def _find_unique_session_turn(
|
||||||
session_turns: list[tuple[dict[str, Any], tuple[str, ...]]],
|
session_turns: list[_SessionBackfillTurn],
|
||||||
signature: tuple[str, ...],
|
signature: tuple[str, ...],
|
||||||
start: int,
|
start: int,
|
||||||
) -> int | None:
|
) -> int | None:
|
||||||
@ -984,7 +1030,7 @@ def _find_unique_session_turn(
|
|||||||
return None
|
return None
|
||||||
found: int | None = None
|
found: int | None = None
|
||||||
for index in range(start, len(session_turns)):
|
for index in range(start, len(session_turns)):
|
||||||
if session_turns[index][1] != signature:
|
if session_turns[index].assistant_signature != signature:
|
||||||
continue
|
continue
|
||||||
if found is not None:
|
if found is not None:
|
||||||
return None
|
return None
|
||||||
@ -992,6 +1038,101 @@ def _find_unique_session_turn(
|
|||||||
return found
|
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(
|
def _with_backfilled_user(
|
||||||
records: list[dict[str, Any]],
|
records: list[dict[str, Any]],
|
||||||
user_event: 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
|
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."""
|
"""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):
|
for rec in reversed(lines):
|
||||||
ev = rec.get("event")
|
ev = rec.get("event")
|
||||||
if ev == "turn_end":
|
if ev == "turn_end":
|
||||||
@ -1995,6 +2164,24 @@ def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool:
|
|||||||
return False
|
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(
|
def build_webui_thread_response(
|
||||||
session_key: str,
|
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_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
|
||||||
augment_assistant_text: Callable[[str], str] | None = None,
|
augment_assistant_text: Callable[[str], str] | None = None,
|
||||||
session_messages: list[dict[str, Any]] | 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,
|
limit: int | None = None,
|
||||||
direction: str | None = None,
|
direction: str | None = None,
|
||||||
before: 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)
|
lines, page = _select_transcript_page(session_key, limit=limit, before=before)
|
||||||
else:
|
else:
|
||||||
lines = read_transcript_lines(session_key)
|
lines = read_transcript_lines(session_key)
|
||||||
if not lines:
|
if not lines and active_turn_started_at is None:
|
||||||
return None
|
return None
|
||||||
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
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)
|
fork_boundary = fork_boundary_message_count(lines)
|
||||||
msgs = replay_transcript_to_ui_messages(
|
msgs = replay_transcript_to_ui_messages(
|
||||||
lines,
|
lines,
|
||||||
@ -2027,7 +2222,16 @@ def build_webui_thread_response(
|
|||||||
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||||
"sessionKey": session_key,
|
"sessionKey": session_key,
|
||||||
"messages": msgs,
|
"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:
|
if page is not None:
|
||||||
page["loaded_message_count"] = len(msgs)
|
page["loaded_message_count"] = len(msgs)
|
||||||
|
|||||||
@ -474,6 +474,18 @@ class GatewayHTTPHandler:
|
|||||||
if direction is not None and direction not in {"latest"}:
|
if direction is not None and direction not in {"latest"}:
|
||||||
return _http_error(400, "invalid direction")
|
return _http_error(400, "invalid direction")
|
||||||
before = _query_first(query, "before")
|
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(
|
data = build_webui_thread_response(
|
||||||
decoded_key,
|
decoded_key,
|
||||||
augment_user_media=self.media.augment_transcript_media,
|
augment_user_media=self.media.augment_transcript_media,
|
||||||
@ -483,6 +495,11 @@ class GatewayHTTPHandler:
|
|||||||
workspace_path=scope.project_path,
|
workspace_path=scope.project_path,
|
||||||
),
|
),
|
||||||
session_messages=session_messages,
|
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,
|
limit=limit,
|
||||||
direction=direction,
|
direction=direction,
|
||||||
before=before,
|
before=before,
|
||||||
|
|||||||
@ -7,8 +7,11 @@ from nanobot.agent.loop import AgentLoop
|
|||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import GoalStatusEvent
|
from nanobot.bus.outbound_events import GoalStatusEvent
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.channels.websocket.runtime import WebSocketChannel
|
||||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
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):
|
def _make_loop(tmp_path):
|
||||||
@ -32,6 +35,7 @@ def _make_loop(tmp_path):
|
|||||||
sessions=loop.sessions,
|
sessions=loop.sessions,
|
||||||
schedule_background=lambda coro: loop._schedule_background(coro),
|
schedule_background=lambda coro: loop._schedule_background(coro),
|
||||||
).subscribe(loop.runtime_events)
|
).subscribe(loop.runtime_events)
|
||||||
|
loop.turn_delivery_factory.route_policy = WebuiTurnRoutePolicy(loop.sessions)
|
||||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||||
return loop
|
return loop
|
||||||
|
|
||||||
@ -39,29 +43,51 @@ def _make_loop(tmp_path):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
|
async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
|
gateway = MagicMock()
|
||||||
response = await loop.process_direct(
|
channel = WebSocketChannel(
|
||||||
"deliver reminder",
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
session_key="cron:reminder-1",
|
loop.bus,
|
||||||
channel="websocket",
|
gateway=gateway,
|
||||||
chat_id="chat-1",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response is not None
|
try:
|
||||||
assert response.content == "done"
|
response = await loop.process_direct(
|
||||||
|
"deliver reminder",
|
||||||
|
session_key="cron:reminder-1",
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-1",
|
||||||
|
)
|
||||||
|
|
||||||
events = []
|
assert response is not None
|
||||||
while loop.bus.outbound_size:
|
assert response.content == "done"
|
||||||
events.append(await loop.bus.consume_outbound())
|
|
||||||
|
|
||||||
statuses = [
|
events = []
|
||||||
event.event
|
while loop.bus.outbound_size:
|
||||||
for event in events
|
event = await loop.bus.consume_outbound()
|
||||||
if isinstance(event.event, GoalStatusEvent)
|
events.append(event)
|
||||||
]
|
await channel.send(event)
|
||||||
assert [status.status for status in statuses] == ["running", "idle"]
|
|
||||||
assert isinstance(statuses[0].started_at, float)
|
status_messages = [
|
||||||
assert statuses[1].started_at is None
|
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
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@ -28,7 +28,10 @@ from nanobot.utils.progress_events import (
|
|||||||
invoke_file_edit_progress,
|
invoke_file_edit_progress,
|
||||||
on_progress_accepts_file_edit_events,
|
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:
|
def _make_loop(tmp_path: Path) -> AgentLoop:
|
||||||
@ -903,6 +906,12 @@ class TestToolEventProgress:
|
|||||||
turn_id = turn_ids.pop()
|
turn_id = turn_ids.pop()
|
||||||
assert isinstance(turn_id, str)
|
assert isinstance(turn_id, str)
|
||||||
assert turn_id.startswith("subagent:")
|
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(
|
assert all(
|
||||||
(message.channel, message.chat_id) == ("websocket", "chat-a")
|
(message.channel, message.chat_id) == ("websocket", "chat-a")
|
||||||
and message.metadata.get("webui") is True
|
and message.metadata.get("webui") is True
|
||||||
@ -910,6 +919,7 @@ class TestToolEventProgress:
|
|||||||
and set(message.metadata) <= {
|
and set(message.metadata) <= {
|
||||||
"webui",
|
"webui",
|
||||||
"_wants_stream",
|
"_wants_stream",
|
||||||
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||||
WEBUI_TURN_METADATA_KEY,
|
WEBUI_TURN_METADATA_KEY,
|
||||||
"latency_ms",
|
"latency_ms",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,147 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
from nanobot.agent.turn_delivery import TurnDeliveryFactory
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
|
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:
|
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) == {
|
assert set(first_visible_route.metadata) == {
|
||||||
"webui",
|
"webui",
|
||||||
"_wants_stream",
|
"_wants_stream",
|
||||||
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||||
WEBUI_TURN_METADATA_KEY,
|
WEBUI_TURN_METADATA_KEY,
|
||||||
}
|
}
|
||||||
assert first_visible_route.metadata["webui"] is True
|
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 first_turn_id.startswith("subagent:")
|
||||||
assert second_turn_id.startswith("subagent:")
|
assert second_turn_id.startswith("subagent:")
|
||||||
assert first_turn_id != second_turn_id
|
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 == {
|
assert msg.metadata == {
|
||||||
"injected_event": "subagent_result",
|
"injected_event": "subagent_result",
|
||||||
"subagent_task_id": "sub-1",
|
"subagent_task_id": "sub-1",
|
||||||
|
|||||||
@ -6,7 +6,10 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
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
|
from nanobot.command.router import CommandContext, CommandRouter
|
||||||
|
|
||||||
|
|
||||||
@ -64,6 +67,20 @@ class TestIsDispatchableCommand:
|
|||||||
assert not router.is_dispatchable_command("/foo bar")
|
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:
|
class TestMidTurnCommandDispatchedDirectly:
|
||||||
"""Verify that commands matching is_dispatchable_command() are dispatched
|
"""Verify that commands matching is_dispatchable_command() are dispatched
|
||||||
correctly when session=None (the mid-turn path)."""
|
correctly when session=None (the mid-turn path)."""
|
||||||
|
|||||||
@ -389,6 +389,7 @@ def test_thread_response_does_not_mark_completed_message_tool_tail_pending(
|
|||||||
|
|
||||||
assert out is not None
|
assert out is not None
|
||||||
assert out["has_pending_tool_calls"] is False
|
assert out["has_pending_tool_calls"] is False
|
||||||
|
assert out["completed_turn_ids"] == [turn_id]
|
||||||
assert out["messages"][-1]["kind"] == "trace"
|
assert out["messages"][-1]["kind"] == "trace"
|
||||||
assert out["messages"][-2]["content"] == "Cron test"
|
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 is not None
|
||||||
assert out["has_pending_tool_calls"] is True
|
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:
|
def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
@ -8,26 +8,40 @@ from nanobot.agent.tools.context import RequestContext, request_context
|
|||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent
|
from nanobot.bus.outbound_events import GoalStatusEvent, TurnModelUpdatedEvent
|
||||||
from nanobot.session import webui_turns as wth
|
from nanobot.session import webui_turns as wth
|
||||||
|
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _clear_turn_wall_clock() -> None:
|
def _clear_turn_wall_clock() -> None:
|
||||||
|
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||||
|
wth._WEBSOCKET_TURN_IDS.clear()
|
||||||
|
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||||
yield
|
yield
|
||||||
|
wth._WEBSOCKET_ACTIVE_TURNS.clear()
|
||||||
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
|
||||||
|
wth._WEBSOCKET_TURN_IDS.clear()
|
||||||
|
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_publish_turn_run_status_running_records_wall_clock() -> None:
|
async def test_publish_turn_run_status_running_records_wall_clock() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
bus.publish_outbound = AsyncMock()
|
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")
|
await wth.publish_turn_run_status(bus, msg, "running")
|
||||||
|
|
||||||
assert "chat-a" in wth._WEBSOCKET_TURN_WALL_STARTED_AT
|
assert "chat-a" in wth._WEBSOCKET_TURN_WALL_STARTED_AT
|
||||||
t0 = wth.websocket_turn_wall_started_at("chat-a")
|
t0 = wth.websocket_turn_wall_started_at("chat-a")
|
||||||
assert isinstance(t0, float)
|
assert isinstance(t0, float)
|
||||||
|
assert wth.websocket_turn_id("chat-a") == "turn-a"
|
||||||
call = bus.publish_outbound.await_args[0][0]
|
call = bus.publish_outbound.await_args[0][0]
|
||||||
assert call.chat_id == "chat-a"
|
assert call.chat_id == "chat-a"
|
||||||
assert isinstance(call.event, GoalStatusEvent)
|
assert isinstance(call.event, GoalStatusEvent)
|
||||||
@ -49,16 +63,67 @@ async def test_publish_turn_run_status_reuses_explicit_wall_clock() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 = MagicMock()
|
||||||
bus.publish_outbound = AsyncMock()
|
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")
|
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_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")
|
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_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
|
@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")
|
await wth.publish_turn_run_status(bus, msg, "running")
|
||||||
|
|
||||||
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
|
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
|
||||||
|
assert wth._WEBSOCKET_TURN_IDS == {}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@ -738,6 +738,7 @@ export default function App() {
|
|||||||
} else {
|
} else {
|
||||||
client.updateUrl(url);
|
client.updateUrl(url);
|
||||||
}
|
}
|
||||||
|
client.updateMaxFrameBytes(boot.limits?.transport.max_frame_bytes);
|
||||||
setState((current) =>
|
setState((current) =>
|
||||||
current.status === "ready" && current.client === client
|
current.status === "ready" && current.client === client
|
||||||
? {
|
? {
|
||||||
@ -769,6 +770,7 @@ export default function App() {
|
|||||||
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
|
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities);
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url,
|
url,
|
||||||
|
maxFrameBytes: boot.limits?.transport.max_frame_bytes,
|
||||||
socketFactory: runtimeHost.socketFactory,
|
socketFactory: runtimeHost.socketFactory,
|
||||||
onReauth: async () => {
|
onReauth: async () => {
|
||||||
try {
|
try {
|
||||||
@ -1206,6 +1208,7 @@ function Shell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return client.onError((error) => {
|
return client.onError((error) => {
|
||||||
if (error.kind !== "workspace_scope_rejected") return;
|
if (error.kind !== "workspace_scope_rejected") return;
|
||||||
|
if (error.chatId && error.chatId !== activeChatIdRef.current) return;
|
||||||
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
setWorkspaceError(t("errors.workspaceScopeRejected.body"));
|
||||||
void refreshWorkspaces();
|
void refreshWorkspaces();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -67,6 +67,11 @@ function resolveCopy(
|
|||||||
title: t("errors.workspaceScopeRejected.title"),
|
title: t("errors.workspaceScopeRejected.title"),
|
||||||
body: t("errors.workspaceScopeRejected.body"),
|
body: t("errors.workspaceScopeRejected.body"),
|
||||||
};
|
};
|
||||||
|
case "turn_rejected":
|
||||||
|
return {
|
||||||
|
title: t("errors.turnRejected.title"),
|
||||||
|
body: t("errors.turnRejected.body"),
|
||||||
|
};
|
||||||
default: {
|
default: {
|
||||||
// Exhaustiveness guard: if a new StreamError kind is added, TS will
|
// Exhaustiveness guard: if a new StreamError kind is added, TS will
|
||||||
// complain here until we add a corresponding i18n branch.
|
// complain here until we add a corresponding i18n branch.
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import {
|
|||||||
installedMcpPresetsFromPayload,
|
installedMcpPresetsFromPayload,
|
||||||
isMcpPresetsPayload,
|
isMcpPresetsPayload,
|
||||||
} from "@/lib/mcp-preset-events";
|
} from "@/lib/mcp-preset-events";
|
||||||
|
import type { CanonicalRunSnapshot } from "@/lib/nanobot-client";
|
||||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
@ -44,13 +45,65 @@ import type {
|
|||||||
import { projectWebuiThreadMessages } from "@/lib/thread-display-compat";
|
import { projectWebuiThreadMessages } from "@/lib/thread-display-compat";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
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 {
|
function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
|
||||||
return (
|
return (
|
||||||
a.role === b.role
|
a.role === b.role
|
||||||
&& (a.kind ?? "") === (b.kind ?? "")
|
&& (a.kind ?? "") === (b.kind ?? "")
|
||||||
&& a.content === b.content
|
&& 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,
|
role: message.role,
|
||||||
kind: message.kind,
|
kind: message.kind,
|
||||||
content: message.content,
|
content: message.content,
|
||||||
|
isStreaming: message.isStreaming,
|
||||||
|
turnId: message.turnId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]): boolean {
|
function durableMessageShapes(messages: UIMessage[]): MessageShape[] {
|
||||||
// Canonical history refreshes can race with live websocket messages after fork/send.
|
return messages
|
||||||
// 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
|
|
||||||
.map(durableMessageShape)
|
.map(durableMessageShape)
|
||||||
.filter((message): message is MessageShape => message !== null);
|
.filter((message): message is MessageShape => message !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preservesMessageShapes(
|
||||||
|
expected: MessageShape[],
|
||||||
|
candidates: MessageShape[],
|
||||||
|
allowCompletedTurnReplacement: boolean,
|
||||||
|
): boolean {
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
let previousCandidate: MessageShape | null = null;
|
||||||
for (const message of expected) {
|
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;
|
let found = false;
|
||||||
while (cursor < candidates.length) {
|
while (cursor < candidates.length) {
|
||||||
const candidate = candidates[cursor];
|
const candidate = candidates[cursor];
|
||||||
cursor += 1;
|
cursor += 1;
|
||||||
if (sameMessageShape(message, candidate)) {
|
if (snapshotPreservesMessage(message, candidate, allowCompletedTurnReplacement)) {
|
||||||
found = true;
|
found = true;
|
||||||
|
previousCandidate = candidate;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -94,11 +163,55 @@ function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]):
|
|||||||
return true;
|
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 (current.length === 0) return false;
|
||||||
if (snapshot.length === 0) return true;
|
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 (snapshot.length >= current.length) return false;
|
||||||
|
if (allowCompletedTurnReplacement) return false;
|
||||||
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
return snapshot.every((message, index) => sameMessageShape(current[index], message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,6 +227,30 @@ function latestActiveTurnId(messages: UIMessage[]): string | null {
|
|||||||
return 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_DEFAULT_WIDTH = 544;
|
||||||
const FILE_PREVIEW_MIN_WIDTH = 360;
|
const FILE_PREVIEW_MIN_WIDTH = 360;
|
||||||
const FILE_PREVIEW_MAX_WIDTH = 860;
|
const FILE_PREVIEW_MAX_WIDTH = 860;
|
||||||
@ -432,6 +569,10 @@ export function ThreadShell({
|
|||||||
hasMoreBefore,
|
hasMoreBefore,
|
||||||
userMessageOffset,
|
userMessageOffset,
|
||||||
hasPendingToolCalls,
|
hasPendingToolCalls,
|
||||||
|
completedTurnIds,
|
||||||
|
continuity: historyContinuity,
|
||||||
|
lineage: historyLineage,
|
||||||
|
activeTurnId: historyActiveTurnId,
|
||||||
refresh: refreshHistory,
|
refresh: refreshHistory,
|
||||||
version: historyVersion,
|
version: historyVersion,
|
||||||
forkBoundaryMessageCount,
|
forkBoundaryMessageCount,
|
||||||
@ -474,9 +615,16 @@ export function ThreadShell({
|
|||||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||||
const skipLayoutCacheRef = useRef(false);
|
const skipLayoutCacheRef = useRef(false);
|
||||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
const pendingCanonicalHydrateRef = useRef<Map<string, PendingCanonicalHydrate>>(new Map());
|
||||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
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 sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||||
|
const currentUiMessagesRef = useRef<UIMessage[] | null>(null);
|
||||||
|
const uiRevisionRef = useRef(0);
|
||||||
|
|
||||||
const initial = useMemo(() => {
|
const initial = useMemo(() => {
|
||||||
if (!chatId) return historical;
|
if (!chatId) return historical;
|
||||||
@ -497,11 +645,25 @@ export function ThreadShell({
|
|||||||
send,
|
send,
|
||||||
transcribeAudio,
|
transcribeAudio,
|
||||||
stop,
|
stop,
|
||||||
|
reconcileTurnComplete,
|
||||||
setMessages,
|
setMessages,
|
||||||
streamError,
|
streamError,
|
||||||
dismissStreamError,
|
dismissStreamError,
|
||||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
} = 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(() => {
|
useEffect(() => {
|
||||||
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
|
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
|
||||||
}, [chatId, historyKey]);
|
}, [chatId, historyKey]);
|
||||||
@ -685,47 +847,196 @@ export function ThreadShell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId || loading) return;
|
if (!chatId || loading) return;
|
||||||
const cached = messageCacheRef.current.get(chatId);
|
const cached = messageCacheRef.current.get(chatId);
|
||||||
const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0;
|
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||||
const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId);
|
const hasNewCanonicalHistory = (
|
||||||
const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion;
|
pendingCanonicalHydrate !== undefined
|
||||||
|
&& historyVersion > pendingCanonicalHydrate.historyVersion
|
||||||
|
);
|
||||||
// When the user switches away and back, keep the local in-memory thread
|
// When the user switches away and back, keep the local in-memory thread
|
||||||
// state (including not-yet-persisted messages) instead of replacing it with
|
// state (including not-yet-persisted messages) instead of replacing it with
|
||||||
// whatever the history endpoint currently knows about. Once a fresh
|
// whatever the history endpoint currently knows about. Once a fresh
|
||||||
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
|
||||||
// so rendering converges to the same shape as a manual refresh.
|
// so rendering converges to the same shape as a manual refresh.
|
||||||
setMessages((prev) => {
|
const normalizedHistory = projectWebuiThreadMessages(historical);
|
||||||
const normalizedHistory = projectWebuiThreadMessages(historical);
|
const keepLiveMessages = (current: UIMessage[]) => projectWebuiThreadMessages(current);
|
||||||
const keepLiveMessages = (messagesToKeep: UIMessage[]) => {
|
if (hasNewCanonicalHistory && pendingCanonicalHydrate) {
|
||||||
const projected = projectWebuiThreadMessages(messagesToKeep);
|
// Transcript replay strips streaming metadata and uses persisted ids.
|
||||||
messageCacheRef.current.set(chatId, projected);
|
// Never adopt it while the turn is active: even if no assistant delta
|
||||||
return projected;
|
// arrived locally yet, the next resumed delta must create/continue the
|
||||||
};
|
// live cursor rather than append to an immutable replay row.
|
||||||
if (hasNewCanonicalHistory && historical.length > 0) {
|
if (hasPendingToolCalls) {
|
||||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
setMessages((current) => keepLiveMessages(current));
|
||||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
return;
|
||||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
|
||||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
|
||||||
return normalizedHistory;
|
|
||||||
}
|
}
|
||||||
|
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 (cached && cached.length > 0) {
|
||||||
if (
|
if (
|
||||||
normalizedHistory.length > cached.length
|
normalizedHistory.length > cached.length
|
||||||
&& !isStaleThreadSnapshot(prev, normalizedHistory)
|
&& !isStaleThreadSnapshot(current, normalizedHistory)
|
||||||
) {
|
) {
|
||||||
messageCacheRef.current.set(chatId, normalizedHistory);
|
|
||||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
|
||||||
return normalizedHistory;
|
return normalizedHistory;
|
||||||
}
|
}
|
||||||
if (isStaleThreadSnapshot(prev, cached)) return keepLiveMessages(prev);
|
return isStaleThreadSnapshot(current, cached) ? keepLiveMessages(current) : cached;
|
||||||
return cached;
|
|
||||||
}
|
}
|
||||||
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
|
return isStaleThreadSnapshot(current, normalizedHistory)
|
||||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
? keepLiveMessages(current)
|
||||||
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
|
: normalizedHistory;
|
||||||
return 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(() => {
|
useEffect(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
@ -735,10 +1046,30 @@ export function ThreadShell({
|
|||||||
// A turn-end thread refresh can arrive while the viewport is easing the
|
// A turn-end thread refresh can arrive while the viewport is easing the
|
||||||
// final layout change. User-driven scrolling already disables following,
|
// final layout change. User-driven scrolling already disables following,
|
||||||
// so keep an active programmatic follow alive across canonical hydration.
|
// so keep an active programmatic follow alive across canonical hydration.
|
||||||
pendingCanonicalHydrateRef.current.add(chatId);
|
refreshCanonicalHistory();
|
||||||
refreshHistory();
|
|
||||||
});
|
});
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (chatId) return;
|
if (chatId) return;
|
||||||
@ -944,8 +1275,8 @@ export function ThreadShell({
|
|||||||
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
|
const forkedChatId = await onForkChat(chatId, beforeUserIndex);
|
||||||
if (!forkedChatId) return;
|
if (!forkedChatId) return;
|
||||||
messageCacheRef.current.delete(forkedChatId);
|
messageCacheRef.current.delete(forkedChatId);
|
||||||
appliedHistoryVersionRef.current.delete(forkedChatId);
|
pendingCanonicalHydrateRef.current.delete(forkedChatId);
|
||||||
pendingCanonicalHydrateRef.current.add(forkedChatId);
|
completedCanonicalHydrateVersionRef.current.delete(forkedChatId);
|
||||||
},
|
},
|
||||||
[chatId, onForkChat],
|
[chatId, onForkChat],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -540,6 +540,8 @@ export function useNanobotStream(
|
|||||||
) => SubmittedTurn | null;
|
) => SubmittedTurn | null;
|
||||||
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
transcribeAudio: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
|
/** Mark an accepted canonical snapshot as the definitive end of the active turn. */
|
||||||
|
reconcileTurnComplete: () => void;
|
||||||
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
||||||
/** Latest transport-level fault raised since the last ``dismissStreamError``.
|
/** Latest transport-level fault raised since the last ``dismissStreamError``.
|
||||||
* ``null`` when there is nothing to show. */
|
* ``null`` when there is nothing to show. */
|
||||||
@ -581,10 +583,6 @@ export function useNanobotStream(
|
|||||||
* backend changes. */
|
* backend changes. */
|
||||||
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const streamEndTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return client.onError((err) => setStreamError(err));
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||||
|
|
||||||
const clearPendingStreamWork = useCallback(() => {
|
const clearPendingStreamWork = useCallback(() => {
|
||||||
@ -654,6 +652,66 @@ export function useNanobotStream(
|
|||||||
return !!closedStreamId;
|
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((
|
const resolveActiveAssistantIndex = useCallback((
|
||||||
prev: UIMessage[],
|
prev: UIMessage[],
|
||||||
turn: UIMessageTurnFields = {},
|
turn: UIMessageTurnFields = {},
|
||||||
@ -849,6 +907,15 @@ export function useNanobotStream(
|
|||||||
return () => document.removeEventListener("visibilitychange", flushOnReturn);
|
return () => document.removeEventListener("visibilitychange", flushOnReturn);
|
||||||
}, [flushPendingStreamEvents]);
|
}, [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
|
// Reset local state when switching chats. Do not reset on every
|
||||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||||
// history response after the optimistic first message has already rendered.
|
// history response after the optimistic first message has already rendered.
|
||||||
@ -883,6 +950,31 @@ export function useNanobotStream(
|
|||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
|
|
||||||
const handle = (ev: InboundEvent) => {
|
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);
|
const sideChannelEvent = isSideChannelEvent(ev);
|
||||||
if (
|
if (
|
||||||
streamEndTimerRef.current !== null
|
streamEndTimerRef.current !== null
|
||||||
@ -1187,8 +1279,7 @@ export function useNanobotStream(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// ``attached`` / ``error`` frames aren't actionable here; the client
|
// ``attached`` frames aren't actionable here.
|
||||||
// shell handles them separately.
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsub = client.onChat(chatId, handle);
|
const unsub = client.onChat(chatId, handle);
|
||||||
@ -1202,6 +1293,7 @@ export function useNanobotStream(
|
|||||||
cancelStreamEndTimer();
|
cancelStreamEndTimer();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
applyStreamError,
|
||||||
cancelStreamEndTimer,
|
cancelStreamEndTimer,
|
||||||
chatId,
|
chatId,
|
||||||
client,
|
client,
|
||||||
@ -1271,12 +1363,16 @@ export function useNanobotStream(
|
|||||||
});
|
});
|
||||||
if (!sideChannel) setIsStreaming(true);
|
if (!sideChannel) setIsStreaming(true);
|
||||||
const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined;
|
const wireMedia = hasAttachments ? images!.map((i) => i.media) : undefined;
|
||||||
const wireOptions = { ...options, turnId };
|
const clientOptions = {
|
||||||
delete wireOptions.quotedContext;
|
...options,
|
||||||
delete wireOptions.sideChannel;
|
turnId,
|
||||||
delete wireOptions.finalizeActiveTurn;
|
...((sideChannel || continueActiveTurn) ? { startsNewRun: false } : {}),
|
||||||
delete wireOptions.continueActiveTurn;
|
};
|
||||||
client.sendMessage(chatId, outboundContent, wireMedia, wireOptions);
|
delete clientOptions.quotedContext;
|
||||||
|
delete clientOptions.sideChannel;
|
||||||
|
delete clientOptions.finalizeActiveTurn;
|
||||||
|
delete clientOptions.continueActiveTurn;
|
||||||
|
client.sendMessage(chatId, outboundContent, wireMedia, clientOptions);
|
||||||
return { turnId, userMessageId, sideChannel };
|
return { turnId, userMessageId, sideChannel };
|
||||||
},
|
},
|
||||||
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
[cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
|
||||||
@ -1297,6 +1393,18 @@ export function useNanobotStream(
|
|||||||
client.sendMessage(chatId, "/stop");
|
client.sendMessage(chatId, "/stop");
|
||||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
}, [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(
|
const transcribeAudio = useCallback(
|
||||||
(dataUrl: string, options?: { durationMs?: number }) =>
|
(dataUrl: string, options?: { durationMs?: number }) =>
|
||||||
client.transcribeAudio(dataUrl, options),
|
client.transcribeAudio(dataUrl, options),
|
||||||
@ -1312,6 +1420,7 @@ export function useNanobotStream(
|
|||||||
send,
|
send,
|
||||||
transcribeAudio,
|
transcribeAudio,
|
||||||
stop,
|
stop,
|
||||||
|
reconcileTurnComplete,
|
||||||
setMessages,
|
setMessages,
|
||||||
streamError,
|
streamError,
|
||||||
dismissStreamError,
|
dismissStreamError,
|
||||||
|
|||||||
@ -24,6 +24,8 @@ const INITIAL_HISTORY_PAGE_LIMIT = 160;
|
|||||||
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
||||||
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
export type SessionHistoryContinuity = "initial" | "overlap" | "reset";
|
||||||
|
|
||||||
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||||
return messages.map((m, idx) => ({
|
return messages.map((m, idx) => ({
|
||||||
...m,
|
...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(
|
function hasPendingToolCallsFromThread(
|
||||||
body: Awaited<ReturnType<typeof fetchWebuiThread>>,
|
body: Awaited<ReturnType<typeof fetchWebuiThread>>,
|
||||||
messages: UIMessage[],
|
messages: UIMessage[],
|
||||||
@ -42,6 +101,17 @@ function hasPendingToolCallsFromThread(
|
|||||||
return hasPendingAgentActivity(messages);
|
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. */
|
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
|
||||||
export function useSessions(): {
|
export function useSessions(): {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
@ -191,11 +261,20 @@ export function useSessionHistory(key: string | null): {
|
|||||||
userMessageOffset: number;
|
userMessageOffset: number;
|
||||||
version: number;
|
version: number;
|
||||||
forkBoundaryMessageCount: number | null;
|
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;
|
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 { token } = useClient();
|
||||||
const loadingOlderRef = useRef(false);
|
const loadingOlderRef = useRef(false);
|
||||||
|
const historyVersionRef = useRef(0);
|
||||||
const [refreshSeq, setRefreshSeq] = useState(0);
|
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
setRefreshSeq((value) => value + 1);
|
setRefreshSeq((value) => value + 1);
|
||||||
@ -207,11 +286,15 @@ export function useSessionHistory(key: string | null): {
|
|||||||
loadingOlder: boolean;
|
loadingOlder: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
hasPendingToolCalls: boolean;
|
hasPendingToolCalls: boolean;
|
||||||
|
completedTurnIds: string[];
|
||||||
forkBoundaryMessageCount: number | null;
|
forkBoundaryMessageCount: number | null;
|
||||||
beforeCursor: string | null;
|
beforeCursor: string | null;
|
||||||
hasMoreBefore: boolean;
|
hasMoreBefore: boolean;
|
||||||
userMessageOffset: number;
|
userMessageOffset: number;
|
||||||
version: number;
|
version: number;
|
||||||
|
continuity: SessionHistoryContinuity;
|
||||||
|
lineage: number;
|
||||||
|
activeTurnId: string | null;
|
||||||
}>({
|
}>({
|
||||||
key: null,
|
key: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
@ -219,11 +302,15 @@ export function useSessionHistory(key: string | null): {
|
|||||||
loadingOlder: false,
|
loadingOlder: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
|
completedTurnIds: [],
|
||||||
forkBoundaryMessageCount: null,
|
forkBoundaryMessageCount: null,
|
||||||
beforeCursor: null,
|
beforeCursor: null,
|
||||||
hasMoreBefore: false,
|
hasMoreBefore: false,
|
||||||
userMessageOffset: 0,
|
userMessageOffset: 0,
|
||||||
version: 0,
|
version: 0,
|
||||||
|
continuity: "initial",
|
||||||
|
lineage: 0,
|
||||||
|
activeTurnId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -235,11 +322,15 @@ export function useSessionHistory(key: string | null): {
|
|||||||
loadingOlder: false,
|
loadingOlder: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
|
completedTurnIds: [],
|
||||||
forkBoundaryMessageCount: null,
|
forkBoundaryMessageCount: null,
|
||||||
beforeCursor: null,
|
beforeCursor: null,
|
||||||
hasMoreBefore: false,
|
hasMoreBefore: false,
|
||||||
userMessageOffset: 0,
|
userMessageOffset: 0,
|
||||||
version: 0,
|
version: 0,
|
||||||
|
continuity: "initial",
|
||||||
|
lineage: 0,
|
||||||
|
activeTurnId: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -255,11 +346,15 @@ export function useSessionHistory(key: string | null): {
|
|||||||
loadingOlder: false,
|
loadingOlder: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
|
completedTurnIds: [],
|
||||||
forkBoundaryMessageCount: null,
|
forkBoundaryMessageCount: null,
|
||||||
beforeCursor: null,
|
beforeCursor: null,
|
||||||
hasMoreBefore: false,
|
hasMoreBefore: false,
|
||||||
userMessageOffset: 0,
|
userMessageOffset: 0,
|
||||||
version: 0,
|
version: 0,
|
||||||
|
continuity: "initial",
|
||||||
|
lineage: 0,
|
||||||
|
activeTurnId: null,
|
||||||
});
|
});
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@ -268,56 +363,83 @@ export function useSessionHistory(key: string | null): {
|
|||||||
direction: "latest",
|
direction: "latest",
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!body?.messages?.length) {
|
historyVersionRef.current += 1;
|
||||||
setState((prev) => ({
|
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,
|
key,
|
||||||
messages: [],
|
messages: merged.messages,
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingOlder: false,
|
loadingOlder: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: hasPending,
|
||||||
forkBoundaryMessageCount: null,
|
completedTurnIds,
|
||||||
beforeCursor: null,
|
forkBoundaryMessageCount: forkBoundary === null
|
||||||
hasMoreBefore: false,
|
? retainedForkBoundary
|
||||||
userMessageOffset: 0,
|
: forkBoundary + merged.retainedPrefixLength,
|
||||||
version: prev.key === key ? prev.version + 1 : 1,
|
beforeCursor: retainedPrefix
|
||||||
}));
|
? prev.beforeCursor
|
||||||
return;
|
: body?.page?.before_cursor ?? null,
|
||||||
}
|
hasMoreBefore: retainedPrefix
|
||||||
const ui = persistedMessagesToUi(body.messages);
|
? prev.hasMoreBefore
|
||||||
const hasPending = hasPendingToolCallsFromThread(body, ui);
|
: body?.page?.has_more_before === true,
|
||||||
const forkBoundary = typeof body.fork_boundary_message_count === "number"
|
userMessageOffset: retainedPrefix
|
||||||
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
|
? prev.userMessageOffset
|
||||||
: null;
|
: Math.max(0, body?.page?.user_message_offset ?? 0),
|
||||||
setState((prev) => ({
|
version: responseVersion,
|
||||||
key,
|
continuity: merged.continuity,
|
||||||
messages: ui,
|
lineage: merged.continuity === "overlap"
|
||||||
loading: false,
|
? prev.lineage
|
||||||
loadingOlder: false,
|
: responseVersion,
|
||||||
error: null,
|
activeTurnId: typeof body?.active_turn_id === "string"
|
||||||
hasPendingToolCalls: hasPending,
|
? body.active_turn_id
|
||||||
forkBoundaryMessageCount: forkBoundary,
|
: null,
|
||||||
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,
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
setState((prev) => ({
|
historyVersionRef.current += 1;
|
||||||
key,
|
const responseVersion = historyVersionRef.current;
|
||||||
messages: [],
|
setState((prev) => {
|
||||||
loading: false,
|
const continuity = prev.key === key && prev.lineage > 0
|
||||||
loadingOlder: false,
|
? "reset"
|
||||||
error: null,
|
: "initial";
|
||||||
hasPendingToolCalls: false,
|
return {
|
||||||
forkBoundaryMessageCount: null,
|
key,
|
||||||
beforeCursor: null,
|
messages: [],
|
||||||
hasMoreBefore: false,
|
loading: false,
|
||||||
userMessageOffset: 0,
|
loadingOlder: false,
|
||||||
version: prev.key === key ? prev.version + 1 : 1,
|
error: null,
|
||||||
}));
|
hasPendingToolCalls: false,
|
||||||
|
completedTurnIds: [],
|
||||||
|
forkBoundaryMessageCount: null,
|
||||||
|
beforeCursor: null,
|
||||||
|
hasMoreBefore: false,
|
||||||
|
userMessageOffset: 0,
|
||||||
|
version: responseVersion,
|
||||||
|
continuity,
|
||||||
|
lineage: responseVersion,
|
||||||
|
activeTurnId: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
key,
|
key,
|
||||||
@ -326,11 +448,15 @@ export function useSessionHistory(key: string | null): {
|
|||||||
loadingOlder: false,
|
loadingOlder: false,
|
||||||
error: (e as Error).message,
|
error: (e as Error).message,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
|
completedTurnIds: [],
|
||||||
forkBoundaryMessageCount: null,
|
forkBoundaryMessageCount: null,
|
||||||
beforeCursor: null,
|
beforeCursor: null,
|
||||||
hasMoreBefore: false,
|
hasMoreBefore: false,
|
||||||
userMessageOffset: 0,
|
userMessageOffset: 0,
|
||||||
version: prev.key === key ? prev.version : 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 () => {
|
const loadOlder = useCallback(async () => {
|
||||||
if (!key || loadingOlderRef.current) return;
|
if (!key || loadingOlderRef.current) return;
|
||||||
const before = state.key === key ? state.beforeCursor : null;
|
const requestKey = key;
|
||||||
if (!before || !state.hasMoreBefore) return;
|
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;
|
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 {
|
try {
|
||||||
const body = await fetchWebuiThread(token, key, {
|
const body = await fetchWebuiThread(token, requestKey, {
|
||||||
limit: OLDER_HISTORY_PAGE_LIMIT,
|
limit: OLDER_HISTORY_PAGE_LIMIT,
|
||||||
before,
|
before: beforeCursor,
|
||||||
});
|
});
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.key !== key) return prev;
|
if (!matchesRequest(prev)) return prev;
|
||||||
if (!body?.messages?.length) {
|
if (!body?.messages?.length) {
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
@ -369,21 +504,21 @@ export function useSessionHistory(key: string | null): {
|
|||||||
? null
|
? null
|
||||||
: prev.forkBoundaryMessageCount + older.length;
|
: prev.forkBoundaryMessageCount + older.length;
|
||||||
const nextMessages = [...older, ...prev.messages];
|
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 {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
messages: nextMessages,
|
messages: nextMessages,
|
||||||
loadingOlder: false,
|
loadingOlder: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: hasPendingAgentActivity(nextMessages),
|
|
||||||
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
|
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
|
||||||
beforeCursor: body.page?.before_cursor ?? null,
|
beforeCursor: body.page?.before_cursor ?? null,
|
||||||
hasMoreBefore: body.page?.has_more_before === true,
|
hasMoreBefore: body.page?.has_more_before === true,
|
||||||
userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0),
|
userMessageOffset: Math.max(0, body.page?.user_message_offset ?? 0),
|
||||||
version: prev.version + 1,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState((prev) => prev.key === key
|
setState((prev) => matchesRequest(prev)
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
loadingOlder: false,
|
loadingOlder: false,
|
||||||
@ -398,6 +533,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
state.beforeCursor,
|
state.beforeCursor,
|
||||||
state.hasMoreBefore,
|
state.hasMoreBefore,
|
||||||
state.key,
|
state.key,
|
||||||
|
state.lineage,
|
||||||
token,
|
token,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -414,6 +550,10 @@ export function useSessionHistory(key: string | null): {
|
|||||||
version: 0,
|
version: 0,
|
||||||
forkBoundaryMessageCount: null,
|
forkBoundaryMessageCount: null,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
|
completedTurnIds: [],
|
||||||
|
continuity: "initial",
|
||||||
|
lineage: 0,
|
||||||
|
activeTurnId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -432,6 +572,10 @@ export function useSessionHistory(key: string | null): {
|
|||||||
version: 0,
|
version: 0,
|
||||||
forkBoundaryMessageCount: null,
|
forkBoundaryMessageCount: null,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
|
completedTurnIds: [],
|
||||||
|
continuity: "initial",
|
||||||
|
lineage: 0,
|
||||||
|
activeTurnId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,6 +591,10 @@ export function useSessionHistory(key: string | null): {
|
|||||||
version: state.version,
|
version: state.version,
|
||||||
forkBoundaryMessageCount: state.forkBoundaryMessageCount,
|
forkBoundaryMessageCount: state.forkBoundaryMessageCount,
|
||||||
hasPendingToolCalls: state.hasPendingToolCalls,
|
hasPendingToolCalls: state.hasPendingToolCalls,
|
||||||
|
completedTurnIds: state.completedTurnIds,
|
||||||
|
continuity: state.continuity,
|
||||||
|
lineage: state.lineage,
|
||||||
|
activeTurnId: state.activeTurnId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1251,6 +1251,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Workspace was not changed",
|
"title": "Workspace was not changed",
|
||||||
"body": "Nanobot kept the previous workspace because the requested project or access mode was rejected by the gateway."
|
"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": {
|
"workspace": {
|
||||||
|
|||||||
@ -1238,6 +1238,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "El espacio de trabajo no cambió",
|
"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."
|
"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": {
|
"workspace": {
|
||||||
|
|||||||
@ -1237,6 +1237,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "L’espace de travail n’a pas changé",
|
"title": "L’espace de travail n’a pas changé",
|
||||||
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
"body": "La passerelle a refusé le projet ou le mode d’accès demandé ; Nanobot a conservé l’espace de travail précédent."
|
||||||
|
},
|
||||||
|
"turnRejected": {
|
||||||
|
"title": "Le message n’a pas été envoyé",
|
||||||
|
"body": "La passerelle a refusé ce message. Vérifiez le texte ou les pièces jointes, puis réessayez."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
|
|||||||
@ -1237,6 +1237,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Workspace tidak berubah",
|
"title": "Workspace tidak berubah",
|
||||||
"body": "Gateway menolak proyek atau mode akses yang diminta, jadi Nanobot tetap memakai workspace sebelumnya."
|
"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": {
|
"workspace": {
|
||||||
|
|||||||
@ -1237,6 +1237,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "ワークスペースは変更されませんでした",
|
"title": "ワークスペースは変更されませんでした",
|
||||||
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
|
"body": "要求されたプロジェクトまたはアクセスモードがゲートウェイで拒否されたため、Nanobot は以前のワークスペースをそのまま使用しています。"
|
||||||
|
},
|
||||||
|
"turnRejected": {
|
||||||
|
"title": "メッセージは送信されませんでした",
|
||||||
|
"body": "ゲートウェイがこのメッセージを拒否しました。本文または添付ファイルを確認して、もう一度お試しください。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
|
|||||||
@ -1237,6 +1237,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "작업공간이 변경되지 않았습니다",
|
"title": "작업공간이 변경되지 않았습니다",
|
||||||
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
|
"body": "요청한 프로젝트 또는 접근 모드가 게이트웨이에서 거부되어 Nanobot이 이전 작업공간을 계속 사용합니다."
|
||||||
|
},
|
||||||
|
"turnRejected": {
|
||||||
|
"title": "메시지가 전송되지 않았습니다",
|
||||||
|
"body": "게이트웨이가 이 메시지를 거부했습니다. 텍스트나 첨부 파일을 확인한 후 다시 시도하세요."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
|
|||||||
@ -1251,6 +1251,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "O workspace não foi alterado",
|
"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."
|
"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": {
|
"workspace": {
|
||||||
|
|||||||
@ -1237,6 +1237,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "Workspace không thay đổi",
|
"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 đó."
|
"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": {
|
"workspace": {
|
||||||
|
|||||||
@ -1251,6 +1251,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "工作区未更改",
|
"title": "工作区未更改",
|
||||||
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
|
"body": "网关拒绝了请求的项目或访问权限,Nanobot 已继续使用之前的工作区。"
|
||||||
|
},
|
||||||
|
"turnRejected": {
|
||||||
|
"title": "消息未发送",
|
||||||
|
"body": "网关拒绝了这条消息。请检查消息内容或附件后重试。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
|
|||||||
@ -1237,6 +1237,10 @@
|
|||||||
"workspaceScopeRejected": {
|
"workspaceScopeRejected": {
|
||||||
"title": "工作區未變更",
|
"title": "工作區未變更",
|
||||||
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
|
"body": "閘道拒絕要求的專案或存取模式,因此 Nanobot 繼續使用先前的工作區。"
|
||||||
|
},
|
||||||
|
"turnRejected": {
|
||||||
|
"title": "訊息未傳送",
|
||||||
|
"body": "閘道拒絕了這則訊息。請檢查內容或附件後再試一次。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
|
|||||||
@ -185,6 +185,7 @@ export async function fetchWebuiThread(
|
|||||||
const res = await fetchWithTimeout(url, {
|
const res = await fetchWithTimeout(url, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
|
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
|
||||||
|
|||||||
@ -83,8 +83,20 @@ export type StreamError =
|
|||||||
/** Server rejected the inbound frame as too large (WS close code 1009).
|
/** Server rejected the inbound frame as too large (WS close code 1009).
|
||||||
* This is the transport fallback after text and attachment policies have
|
* This is the transport fallback after text and attachment policies have
|
||||||
* already been checked independently. */
|
* already been checked independently. */
|
||||||
| { kind: "message_too_big" }
|
| { kind: "message_too_big"; chatId?: string; turnId?: string }
|
||||||
| { kind: "workspace_scope_rejected"; reason?: string; chatId?: 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;
|
type ErrorHandler = (error: StreamError) => void;
|
||||||
|
|
||||||
@ -95,6 +107,13 @@ interface PendingRequest<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
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 {
|
export function isSystemCommandTurnId(value: string | null | undefined): value is string {
|
||||||
return typeof value === "string" && value.startsWith(SYSTEM_COMMAND_TURN_PREFIX);
|
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 {
|
export interface NanobotClientOptions {
|
||||||
url: string;
|
url: string;
|
||||||
reconnect?: boolean;
|
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. */
|
/** Called when a connection drops so the app can refresh its token. */
|
||||||
onReauth?: () => Promise<string | null>;
|
onReauth?: () => Promise<string | null>;
|
||||||
/** Inject a custom WebSocket factory (used by unit tests). */
|
/** Inject a custom WebSocket factory (used by unit tests). */
|
||||||
@ -111,6 +132,24 @@ export interface NanobotClientOptions {
|
|||||||
maxBackoffMs?: number;
|
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.
|
* Singleton WebSocket client that multiplexes chat streams.
|
||||||
*
|
*
|
||||||
@ -134,6 +173,23 @@ export class NanobotClient {
|
|||||||
private knownChats = new Set<string>();
|
private knownChats = new Set<string>();
|
||||||
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
|
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
|
||||||
private runStartedAtByChatId = new Map<string, number>();
|
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). */
|
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
||||||
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||||
private pendingNewChat: PendingRequest<string> | null = null;
|
private pendingNewChat: PendingRequest<string> | null = null;
|
||||||
@ -145,6 +201,7 @@ export class NanobotClient {
|
|||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private readonly shouldReconnect: boolean;
|
private readonly shouldReconnect: boolean;
|
||||||
private readonly maxBackoffMs: number;
|
private readonly maxBackoffMs: number;
|
||||||
|
private maxFrameBytes: number | undefined;
|
||||||
private socketFactory: (url: string) => WebSocket;
|
private socketFactory: (url: string) => WebSocket;
|
||||||
private currentUrl: string;
|
private currentUrl: string;
|
||||||
private status_: ConnectionStatus = "idle";
|
private status_: ConnectionStatus = "idle";
|
||||||
@ -156,6 +213,7 @@ export class NanobotClient {
|
|||||||
constructor(private options: NanobotClientOptions) {
|
constructor(private options: NanobotClientOptions) {
|
||||||
this.shouldReconnect = options.reconnect ?? true;
|
this.shouldReconnect = options.reconnect ?? true;
|
||||||
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
|
this.maxBackoffMs = options.maxBackoffMs ?? 15_000;
|
||||||
|
this.maxFrameBytes = this.normalizeMaxFrameBytes(options.maxFrameBytes);
|
||||||
this.socketFactory = options.socketFactory ?? createDefaultSocket;
|
this.socketFactory = options.socketFactory ?? createDefaultSocket;
|
||||||
this.currentUrl = options.url;
|
this.currentUrl = options.url;
|
||||||
}
|
}
|
||||||
@ -222,27 +280,386 @@ export class NanobotClient {
|
|||||||
return v === undefined ? null : v;
|
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. */
|
/** Last ``goal_state`` payload for *chatId*, if any frame has arrived this connection. */
|
||||||
getGoalState(chatId: string): GoalStateWsPayload | undefined {
|
getGoalState(chatId: string): GoalStateWsPayload | undefined {
|
||||||
return this.goalStateByChatId.get(chatId);
|
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 {
|
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
|
||||||
if (ev.event === "turn_end") {
|
if (ev.event === "turn_end") {
|
||||||
if (this.runStartedAtByChatId.has(chatId)) {
|
this.recordRunCompletion(chatId, ev.turn_id);
|
||||||
this.runStartedAtByChatId.delete(chatId);
|
|
||||||
this.emitRunStatus(chatId, null);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ev.event !== "goal_status") return;
|
if (ev.event !== "goal_status") return;
|
||||||
if (ev.status === "running" && typeof ev.started_at === "number") {
|
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);
|
const previous = this.runStartedAtByChatId.get(chatId);
|
||||||
this.runStartedAtByChatId.set(chatId, ev.started_at);
|
this.runStartedAtByChatId.set(chatId, ev.started_at);
|
||||||
if (previous !== ev.started_at) this.emitRunStatus(chatId, ev.started_at);
|
if (previous !== ev.started_at) this.emitRunStatus(chatId, ev.started_at);
|
||||||
} else if (this.runStartedAtByChatId.has(chatId)) {
|
} else {
|
||||||
this.runStartedAtByChatId.delete(chatId);
|
this.recordRunCompletion(chatId, ev.turn_id);
|
||||||
this.emitRunStatus(chatId, null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -390,6 +807,8 @@ export class NanobotClient {
|
|||||||
quotedContext?: string;
|
quotedContext?: string;
|
||||||
workspaceScope?: WorkspaceScopePayload | null;
|
workspaceScope?: WorkspaceScopePayload | null;
|
||||||
turnId?: string;
|
turnId?: string;
|
||||||
|
/** False for side-channel or injected messages that do not own a lifecycle. */
|
||||||
|
startsNewRun?: boolean;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
this.knownChats.add(chatId);
|
this.knownChats.add(chatId);
|
||||||
@ -405,6 +824,22 @@ export class NanobotClient {
|
|||||||
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
...(options?.turnId ? { turn_id: options.turnId } : {}),
|
||||||
webui: true,
|
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);
|
this.queueSend(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -442,6 +877,7 @@ export class NanobotClient {
|
|||||||
if (this.runStartedAtByChatId.size === 0) return;
|
if (this.runStartedAtByChatId.size === 0) return;
|
||||||
const chatIds = [...this.runStartedAtByChatId.keys()];
|
const chatIds = [...this.runStartedAtByChatId.keys()];
|
||||||
this.runStartedAtByChatId.clear();
|
this.runStartedAtByChatId.clear();
|
||||||
|
this.runStartedAtByTurnKey.clear();
|
||||||
for (const chatId of chatIds) this.emitRunStatus(chatId, null);
|
for (const chatId of chatIds) this.emitRunStatus(chatId, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -476,16 +912,61 @@ export class NanobotClient {
|
|||||||
console.log("[nanobot ws inbound]", summarizeInboundWsPayload(parsed));
|
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"
|
const turnId = "turn_id" in parsed && typeof parsed.turn_id === "string"
|
||||||
? parsed.turn_id
|
? parsed.turn_id
|
||||||
: null;
|
: null;
|
||||||
|
if (parsed.event === "message_accepted") {
|
||||||
|
this.recordRunAcceptance(parsed.chat_id, parsed.turn_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isSystemCommandTurnId(turnId)) {
|
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);
|
this.resolveSystemCommand(turnId);
|
||||||
}
|
}
|
||||||
return;
|
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") {
|
if (parsed.event === "ready") {
|
||||||
this.readyChatId = parsed.chat_id;
|
this.readyChatId = parsed.chat_id;
|
||||||
this.knownChats.add(parsed.chat_id);
|
this.knownChats.add(parsed.chat_id);
|
||||||
@ -528,6 +1009,7 @@ export class NanobotClient {
|
|||||||
kind: "workspace_scope_rejected",
|
kind: "workspace_scope_rejected",
|
||||||
reason: parsed.reason,
|
reason: parsed.reason,
|
||||||
chatId: parsed.chat_id,
|
chatId: parsed.chat_id,
|
||||||
|
turnId: parsed.turn_id,
|
||||||
});
|
});
|
||||||
if (this.pendingNewChat) {
|
if (this.pendingNewChat) {
|
||||||
clearTimeout(this.pendingNewChat.timer);
|
clearTimeout(this.pendingNewChat.timer);
|
||||||
@ -546,7 +1028,10 @@ export class NanobotClient {
|
|||||||
|
|
||||||
const chatId = (parsed as { chat_id?: string }).chat_id;
|
const chatId = (parsed as { chat_id?: string }).chat_id;
|
||||||
if (chatId) {
|
if (chatId) {
|
||||||
|
if (this.isCanonicalCompletedTurnEvent(chatId, parsed)) return;
|
||||||
|
const supersededRunCompletion = this.isSupersededRunCompletion(chatId, parsed);
|
||||||
this.recordGoalStatusForRunStrip(chatId, parsed);
|
this.recordGoalStatusForRunStrip(chatId, parsed);
|
||||||
|
if (supersededRunCompletion) return;
|
||||||
this.recordGoalStateSnapshot(chatId, parsed);
|
this.recordGoalStateSnapshot(chatId, parsed);
|
||||||
this.dispatch(chatId, parsed);
|
this.dispatch(chatId, parsed);
|
||||||
}
|
}
|
||||||
@ -611,9 +1096,44 @@ export class NanobotClient {
|
|||||||
// display the error even while the client transparently reconnects.
|
// display the error even while the client transparently reconnects.
|
||||||
// Browsers populate ``CloseEvent.code`` with the wire-level close code;
|
// Browsers populate ``CloseEvent.code`` with the wire-level close code;
|
||||||
// 1009 = Message Too Big (server's max frame guard).
|
// 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) {
|
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) {
|
if (this.intentionallyClosed || !this.shouldReconnect) {
|
||||||
this.setStatus("closed");
|
this.setStatus("closed");
|
||||||
return;
|
return;
|
||||||
@ -671,6 +1191,14 @@ export class NanobotClient {
|
|||||||
pending.resolve();
|
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 {
|
private scheduleReconnect(): void {
|
||||||
this.clearRunStatusesForReconnect();
|
this.clearRunStatusesForReconnect();
|
||||||
this.setStatus("reconnecting");
|
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 {
|
private rawSend(frame: Outbound): void {
|
||||||
if (!this.socket) return;
|
if (!this.socket) return;
|
||||||
try {
|
try {
|
||||||
this.socket.send(JSON.stringify(frame));
|
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 {
|
} catch {
|
||||||
// Send failure will materialize as a close; queue the frame for retry.
|
// Send failure will materialize as a close; queue the frame for retry.
|
||||||
this.sendQueue.push(frame);
|
this.sendQueue.push(frame);
|
||||||
|
|||||||
@ -1082,6 +1082,7 @@ export interface InboundTurnMetadata {
|
|||||||
export type InboundEvent =
|
export type InboundEvent =
|
||||||
| { event: "ready"; chat_id: string; client_id: string }
|
| { event: "ready"; chat_id: string; client_id: string }
|
||||||
| { event: "attached"; chat_id: string }
|
| { event: "attached"; chat_id: string }
|
||||||
|
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||||
| ({
|
| ({
|
||||||
event: "message";
|
event: "message";
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
@ -1149,14 +1150,14 @@ export type InboundEvent =
|
|||||||
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
|
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
|
||||||
goal_state?: GoalStateWsPayload;
|
goal_state?: GoalStateWsPayload;
|
||||||
} & InboundTurnMetadata)
|
} & InboundTurnMetadata)
|
||||||
| {
|
| ({
|
||||||
event: "goal_status";
|
event: "goal_status";
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
/** Turn executing (user message through agent loop). */
|
/** Turn executing (user message through agent loop). */
|
||||||
status: "running" | "idle";
|
status: "running" | "idle";
|
||||||
/** Server ``time.time()`` when ``status`` is ``running``. */
|
/** Server ``time.time()`` when ``status`` is ``running``. */
|
||||||
started_at?: number;
|
started_at?: number;
|
||||||
}
|
} & InboundTurnMetadata)
|
||||||
| {
|
| {
|
||||||
event: "goal_state";
|
event: "goal_state";
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
@ -1175,7 +1176,14 @@ export type InboundEvent =
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
provider?: 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.
|
/** Base64-encoded file attached to an outbound ``message`` envelope.
|
||||||
*
|
*
|
||||||
@ -1224,7 +1232,11 @@ export interface WebuiThreadPersistedPayload {
|
|||||||
savedAt?: string;
|
savedAt?: string;
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
fork_boundary_message_count?: number;
|
fork_boundary_message_count?: number;
|
||||||
|
/** Turn ids backed by an explicit persisted ``turn_end`` event. */
|
||||||
|
completed_turn_ids?: string[];
|
||||||
has_pending_tool_calls?: boolean;
|
has_pending_tool_calls?: boolean;
|
||||||
|
/** Exact active turn when supplied by a current gateway. */
|
||||||
|
active_turn_id?: string | null;
|
||||||
page?: WebuiThreadPagePayload;
|
page?: WebuiThreadPagePayload;
|
||||||
workspace_scope?: WorkspaceScopePayload;
|
workspace_scope?: WorkspaceScopePayload;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,7 @@ describe("webui API helpers", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: { Authorization: "Bearer tok" },
|
headers: { Authorization: "Bearer tok" },
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
|
cache: "no-store",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -217,6 +217,7 @@ vi.mock("@/lib/nanobot-client", () => {
|
|||||||
attach = attachSpy;
|
attach = attachSpy;
|
||||||
close = vi.fn();
|
close = vi.fn();
|
||||||
updateUrl = updateUrlSpy;
|
updateUrl = updateUrlSpy;
|
||||||
|
updateMaxFrameBytes = vi.fn();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { NanobotClient: MockClient };
|
return { NanobotClient: MockClient };
|
||||||
|
|||||||
@ -238,6 +238,891 @@ describe("NanobotClient", () => {
|
|||||||
expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
|
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", () => {
|
it("notifies run status subscribers and replays running chats", () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
url: "ws://test",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -3,15 +3,20 @@ import type { ReactNode } from "react";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
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";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
|
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
|
||||||
|
|
||||||
function fakeClient() {
|
function fakeClient() {
|
||||||
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
|
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 runStartedAtByChatId = new Map<string, number>();
|
||||||
|
const unsettledRunByChatId = new Map<string, boolean>();
|
||||||
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||||
|
let status: ConnectionStatus = "open";
|
||||||
|
|
||||||
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
|
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
|
||||||
if (ev.event === "turn_end") {
|
if (ev.event === "turn_end") {
|
||||||
@ -38,10 +43,19 @@ function fakeClient() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
client: {
|
client: {
|
||||||
status: "open" as const,
|
get status() {
|
||||||
|
return status;
|
||||||
|
},
|
||||||
defaultChatId: null as string | null,
|
defaultChatId: null as string | null,
|
||||||
onStatus: () => () => {},
|
onStatus(handler: (nextStatus: ConnectionStatus) => void) {
|
||||||
onError: () => () => {},
|
statusHandlers.add(handler);
|
||||||
|
handler(status);
|
||||||
|
return () => statusHandlers.delete(handler);
|
||||||
|
},
|
||||||
|
onError(handler: (error: StreamError) => void) {
|
||||||
|
errorHandlers.add(handler);
|
||||||
|
return () => errorHandlers.delete(handler);
|
||||||
|
},
|
||||||
getRunStartedAt(chatId: string) {
|
getRunStartedAt(chatId: string) {
|
||||||
const v = runStartedAtByChatId.get(chatId);
|
const v = runStartedAtByChatId.get(chatId);
|
||||||
return v === undefined ? null : v;
|
return v === undefined ? null : v;
|
||||||
@ -49,6 +63,9 @@ function fakeClient() {
|
|||||||
getGoalState(chatId: string) {
|
getGoalState(chatId: string) {
|
||||||
return goalStateByChatId.get(chatId);
|
return goalStateByChatId.get(chatId);
|
||||||
},
|
},
|
||||||
|
hasUnsettledRun(chatId: string) {
|
||||||
|
return unsettledRunByChatId.get(chatId) === true;
|
||||||
|
},
|
||||||
onChat(chatId: string, h: (ev: InboundEvent) => void) {
|
onChat(chatId: string, h: (ev: InboundEvent) => void) {
|
||||||
let set = handlers.get(chatId);
|
let set = handlers.get(chatId);
|
||||||
if (!set) {
|
if (!set) {
|
||||||
@ -72,6 +89,16 @@ function fakeClient() {
|
|||||||
const set = handlers.get(chatId);
|
const set = handlers.get(chatId);
|
||||||
set?.forEach((h) => h(ev));
|
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", () => {
|
it("flushes pending delta text before turn_end finalizes the turn", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
|
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", () => {
|
it("adds optimistic user file attachments as media", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
|
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 call = fake.client.sendMessage.mock.calls.at(-1)!;
|
||||||
const turnId = call[3]?.turnId;
|
const turnId = call[3]?.turnId;
|
||||||
expect(call[3]).not.toHaveProperty("sideChannel");
|
expect(call[3]).not.toHaveProperty("sideChannel");
|
||||||
|
expect(call[3]).toMatchObject({ startsNewRun: false });
|
||||||
expect(result.current.isStreaming).toBe(false);
|
expect(result.current.isStreaming).toBe(false);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -1956,6 +2260,7 @@ describe("useNanobotStream", () => {
|
|||||||
|
|
||||||
const guideCall = fake.client.sendMessage.mock.calls.at(-1)!;
|
const guideCall = fake.client.sendMessage.mock.calls.at(-1)!;
|
||||||
expect(guideCall[3]).not.toHaveProperty("continueActiveTurn");
|
expect(guideCall[3]).not.toHaveProperty("continueActiveTurn");
|
||||||
|
expect(guideCall[3]).toMatchObject({ startsNewRun: false });
|
||||||
expect(result.current.messages.map((message) => message.content)).toEqual([
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||||
"research this",
|
"research this",
|
||||||
"Initial findings",
|
"Initial findings",
|
||||||
|
|||||||
@ -450,6 +450,32 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
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 () => {
|
it("does not flag transcript as pending when last row is not a trace", async () => {
|
||||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
schemaVersion: 3,
|
schemaVersion: 3,
|
||||||
@ -520,6 +546,9 @@ describe("useSessions", () => {
|
|||||||
});
|
});
|
||||||
expect(result.current.hasMoreBefore).toBe(true);
|
expect(result.current.hasMoreBefore).toBe(true);
|
||||||
expect(result.current.userMessageOffset).toBe(1);
|
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 act(async () => {
|
||||||
await result.current.loadOlder();
|
await result.current.loadOlder();
|
||||||
@ -537,6 +566,372 @@ describe("useSessions", () => {
|
|||||||
]);
|
]);
|
||||||
expect(result.current.hasMoreBefore).toBe(false);
|
expect(result.current.hasMoreBefore).toBe(false);
|
||||||
expect(result.current.userMessageOffset).toBe(0);
|
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 () => {
|
it("keeps the session in the list when delete fails", async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user