mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(tui): synchronize shared session clients
This commit is contained in:
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Alt+Up` returns the latest queued message for editing. Press `Ctrl+J` to add a newline and `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another one. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts a local gateway only when needed and releases it when you exit. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
||||
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another WebSocket session; use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts the shared local gateway when needed and leaves it available to other terminals and the WebUI; stop it explicitly with `nanobot gateway stop`. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
||||
|
||||
For one request and an immediate exit, use:
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` r
|
||||
|---|---|
|
||||
| `nanobot agent -m "Hello!"` | Send one message and exit |
|
||||
| `nanobot agent` | Start interactive terminal chat |
|
||||
| `nanobot agent --session <id>` | Use a specific session key |
|
||||
| `nanobot agent --session <id>` | Use a WebSocket session key; add `--classic` for another channel |
|
||||
| `nanobot agent --workspace <path>` | Override workspace |
|
||||
| `nanobot agent --config <path>` | Use a specific config file |
|
||||
| `nanobot agent --classic` | Use the classic Python prompt instead of the native terminal UI |
|
||||
@@ -121,11 +121,11 @@ nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bo
|
||||
The command never deletes the external store and refuses to overwrite a different existing
|
||||
workspace file. Back up both the config directory and workspace before changing versions.
|
||||
|
||||
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, the command starts one for the lifetime of the terminal UI and stops it on exit.
|
||||
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, the command starts the shared background gateway. Exiting one TUI disconnects only that client, so other terminals and the WebUI stay connected; use `nanobot gateway stop` when you want to stop the shared process.
|
||||
|
||||
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably.
|
||||
|
||||
`Enter` sends the current message. While a turn is active, `Enter` steers it immediately, `Tab` queues a visible follow-up for the next turn, and `Alt+Up` returns the latest queued message to the composer. Press `Ctrl+J` to add a newline (`Shift+Enter`, `Alt+Enter`, and `Ctrl+Enter` are accepted when the terminal can distinguish them) and use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||
`Enter` sends the current message. While a turn is active, `Enter` steers it immediately, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message to the composer. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback when a terminal cannot distinguish modified Enter keys. `Alt+Enter` and `Ctrl+Enter` are also accepted when distinguishable. Use `Up`/`Down` at the composer edge to recall prompts from the current saved session. Large pastes appear as a compact placeholder in the composer but are sent unchanged. Type `/` to discover nanobot commands and terminal navigation in one palette, or type `@` to complete installed apps, configured MCP servers, and saved sessions. Use the arrow keys to choose an item and `Tab` to complete it. `/sessions` opens a searchable conversation picker, `/new-chat` preserves the current conversation and starts another one, and `/branch` forks from a completed reply. `/diff` opens a read-only unified diff for the newest turn; use `Left`/`Right` to switch edits and `Esc` to close it. The core `/new` command retains its cross-channel behavior and resets the current chat. `Ctrl+C` copies a selection, stops a running turn, clears a non-empty composer, or exits when idle. Use `PageUp`/`PageDown` to scroll, `Ctrl+Home`/`Ctrl+End` to jump to the transcript edges, and `Ctrl+O` to expand or collapse long tool traces. When you leave the bottom, the TUI shows a scrollbar and a `Ctrl+End` hint until you return. The footer reports provider token/cache usage when available. Selections copy through OSC 52 when the terminal supports it. The transcript reflows when the terminal is resized, and exiting restores the previous screen.
|
||||
|
||||
Packaged releases fetch a version-matched, checksummed terminal binary for macOS (Apple Silicon and Intel), Linux (x64 and ARM64), or Windows x64 on first use and cache it under the nanobot data directory. Windows ARM64 currently falls back to the classic prompt because the Bun runtime disables the FFI required by OpenTUI on that platform. Set `NANOBOT_TUI_NO_DOWNLOAD=1` or pass `--classic` to keep the Python-only path. A source checkout can run the client with Bun after `bun install --cwd tui`.
|
||||
|
||||
|
||||
+4
-2
@@ -112,8 +112,10 @@ Then start an interactive terminal chat with:
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
In interactive mode, `Enter` sends and `Ctrl+J` inserts a newline. While a turn is running,
|
||||
`Enter` steers it, `Tab` queues a follow-up, and `Alt+Up` edits the latest queued message. Exit
|
||||
In interactive mode, `Enter` sends and `Shift+Enter` inserts a newline (`Ctrl+J` is the
|
||||
universal fallback). While a turn is running,
|
||||
`Enter` steers it, `Tab` queues a follow-up, and `Option+Up` on macOS (`Alt+Up` on
|
||||
Windows/Linux) edits the latest queued message. Exit
|
||||
with `exit`, `/exit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
## Choose One Next Step
|
||||
|
||||
@@ -62,6 +62,7 @@ from nanobot.session.webui_turns import (
|
||||
websocket_turn_transcript_persistence_failed,
|
||||
websocket_turn_wall_started_at,
|
||||
)
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.webui.cli_apps_api import normalize_cli_app_mentions
|
||||
from nanobot.webui.forking import handle_webui_fork_chat
|
||||
from nanobot.webui.gateway_services import GatewayServices
|
||||
@@ -594,6 +595,57 @@ class WebSocketChannel(BaseChannel):
|
||||
for connection in tuple(self._webui_connections):
|
||||
await self._send_event(connection, event, **fields)
|
||||
|
||||
async def _broadcast_user_message(
|
||||
self,
|
||||
origin: ServerConnection,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
*,
|
||||
turn_id: str | None,
|
||||
starts_turn: bool,
|
||||
media_paths: list[str],
|
||||
media_names: list[str | None],
|
||||
cli_apps: list[dict[str, Any]],
|
||||
mcp_presets: list[dict[str, Any]],
|
||||
session_mentions: list[SessionMention],
|
||||
) -> None:
|
||||
"""Project one accepted user message to the other clients on the chat.
|
||||
|
||||
The origin already has an optimistic row and receives canonical turn
|
||||
ownership in ``message_accepted``. Peers need the ingress projection.
|
||||
"""
|
||||
body: dict[str, Any] = {
|
||||
"event": "user_message",
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"starts_turn": starts_turn,
|
||||
}
|
||||
if turn_id is not None:
|
||||
body["turn_id"] = turn_id
|
||||
media = self._media.augment_transcript_user_media(media_paths)
|
||||
for attachment, name in zip(media, media_names, strict=False):
|
||||
if name:
|
||||
attachment["name"] = name
|
||||
if media:
|
||||
body["media_urls"] = media
|
||||
if cli_apps:
|
||||
body["cli_apps"] = cli_apps
|
||||
if mcp_presets:
|
||||
body["mcp_presets"] = mcp_presets
|
||||
if session_mentions:
|
||||
body["session_mentions"] = session_mentions
|
||||
active_turn_id = websocket_turn_id(chat_id)
|
||||
if active_turn_id is not None:
|
||||
body["active_turn_id"] = active_turn_id
|
||||
started_at = websocket_turn_wall_started_at(chat_id)
|
||||
if active_turn_id is not None and started_at is not None:
|
||||
body["started_at"] = started_at
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
for connection in tuple(self._subs.get(chat_id, ())):
|
||||
if connection is origin:
|
||||
continue
|
||||
await self._safe_send_to(connection, raw, label=" user_message ")
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebSocketConfig().model_dump(by_alias=True)
|
||||
@@ -1036,6 +1088,7 @@ class WebSocketChannel(BaseChannel):
|
||||
|
||||
raw_media = envelope.get("media")
|
||||
media_paths: list[str] = []
|
||||
media_names: list[str | None] = []
|
||||
if raw_media is not None:
|
||||
if not isinstance(raw_media, list):
|
||||
await self._send_event(
|
||||
@@ -1056,6 +1109,12 @@ class WebSocketChannel(BaseChannel):
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
for item in cast(list[Any], raw_media):
|
||||
attachment = cast(dict[str, Any], item) if isinstance(item, dict) else {}
|
||||
name = attachment.get("name")
|
||||
media_names.append(
|
||||
(safe_filename(name) or None) if isinstance(name, str) else None
|
||||
)
|
||||
if temporary_policy is not None:
|
||||
self._temporary_chats.register_media(connection, cid, media_paths)
|
||||
|
||||
@@ -1190,12 +1249,38 @@ class WebSocketChannel(BaseChannel):
|
||||
finally:
|
||||
if not accepted and queued_owner is not None:
|
||||
clear_websocket_turn_if_current(cid, queued_owner)
|
||||
if is_webui:
|
||||
await self._broadcast_user_message(
|
||||
connection,
|
||||
cid,
|
||||
content,
|
||||
turn_id=turn_id,
|
||||
starts_turn=queued_owner is not None,
|
||||
media_paths=media_paths,
|
||||
media_names=media_names,
|
||||
cli_apps=cli_apps,
|
||||
mcp_presets=mcp_presets,
|
||||
session_mentions=session_mentions,
|
||||
)
|
||||
if is_webui and turn_id:
|
||||
active_turn_id = websocket_turn_id(cid)
|
||||
started_at = websocket_turn_wall_started_at(cid)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"message_accepted",
|
||||
chat_id=cid,
|
||||
turn_id=turn_id,
|
||||
starts_turn=queued_owner is not None,
|
||||
**(
|
||||
{"active_turn_id": active_turn_id}
|
||||
if active_turn_id is not None
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"started_at": started_at}
|
||||
if active_turn_id is not None and started_at is not None
|
||||
else {}
|
||||
),
|
||||
)
|
||||
return
|
||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||
|
||||
@@ -4263,9 +4263,89 @@ async def test_authorized_webui_turn_is_acked_after_bus_acceptance(
|
||||
"event": "message_accepted",
|
||||
"chat_id": "chat-accepted",
|
||||
"turn_id": "turn-accepted",
|
||||
"starts_turn": True,
|
||||
"active_turn_id": "turn-accepted",
|
||||
"started_at": wth.websocket_turn_wall_started_at("chat-accepted"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_messages_fan_out_to_other_clients_on_the_same_chat(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
channel = _ch(bus)
|
||||
origin = AsyncMock()
|
||||
origin.remote_address = ("127.0.0.1", 50123)
|
||||
peer = AsyncMock()
|
||||
outsider = AsyncMock()
|
||||
channel._attach(peer, "shared-chat")
|
||||
channel._attach(outsider, "other-chat")
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
origin,
|
||||
"terminal-a",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "shared-chat",
|
||||
"content": "hello from A",
|
||||
"webui": True,
|
||||
"turn_id": "turn-a",
|
||||
},
|
||||
)
|
||||
await channel._dispatch_envelope(
|
||||
origin,
|
||||
"terminal-a",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "shared-chat",
|
||||
"content": "one more detail",
|
||||
"webui": True,
|
||||
"turn_id": "steer-a",
|
||||
},
|
||||
)
|
||||
|
||||
peer_payloads = [
|
||||
payload
|
||||
for payload in _sent_ws_payloads(peer)
|
||||
if payload["event"] == "user_message"
|
||||
]
|
||||
assert len(peer_payloads) == 2
|
||||
assert peer_payloads[0] == {
|
||||
"event": "user_message",
|
||||
"chat_id": "shared-chat",
|
||||
"text": "hello from A",
|
||||
"starts_turn": True,
|
||||
"turn_id": "turn-a",
|
||||
"active_turn_id": "turn-a",
|
||||
"started_at": pytest.approx(wth.websocket_turn_wall_started_at("shared-chat")),
|
||||
}
|
||||
assert peer_payloads[1] == {
|
||||
"event": "user_message",
|
||||
"chat_id": "shared-chat",
|
||||
"text": "one more detail",
|
||||
"starts_turn": False,
|
||||
"turn_id": "steer-a",
|
||||
"active_turn_id": "turn-a",
|
||||
"started_at": pytest.approx(wth.websocket_turn_wall_started_at("shared-chat")),
|
||||
}
|
||||
origin_payloads = _sent_ws_payloads(origin)
|
||||
assert [
|
||||
(
|
||||
payload["event"],
|
||||
payload["turn_id"],
|
||||
payload["starts_turn"],
|
||||
payload["active_turn_id"],
|
||||
)
|
||||
for payload in origin_payloads
|
||||
if payload["event"] == "message_accepted"
|
||||
] == [
|
||||
("message_accepted", "turn-a", True, "turn-a"),
|
||||
("message_accepted", "steer-a", False, "turn-a"),
|
||||
]
|
||||
outsider.send.assert_not_awaited()
|
||||
assert bus.publish_inbound.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_side_channel_command_does_not_register_queued_turn(
|
||||
bus: MagicMock,
|
||||
@@ -4294,6 +4374,7 @@ async def test_side_channel_command_does_not_register_queued_turn(
|
||||
"event": "message_accepted",
|
||||
"chat_id": "chat-status",
|
||||
"turn_id": "turn-status",
|
||||
"starts_turn": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -129,9 +129,47 @@ async def test_webui_message_acceptance_echoes_turn_id() -> None:
|
||||
"event": "message_accepted",
|
||||
"chat_id": "abc123",
|
||||
"turn_id": "turn-accepted",
|
||||
"starts_turn": True,
|
||||
"active_turn_id": "turn-accepted",
|
||||
"started_at": wth.websocket_turn_wall_started_at("abc123"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webui_message_projects_attachments_to_other_clients(tmp_path: Path) -> None:
|
||||
channel = _make_channel()
|
||||
origin = AsyncMock()
|
||||
peer = AsyncMock()
|
||||
channel._attach(origin, "abc123")
|
||||
channel._attach(peer, "abc123")
|
||||
channel._webui_connections.add(origin)
|
||||
envelope = {
|
||||
"type": "message",
|
||||
"chat_id": "abc123",
|
||||
"content": "please inspect @drawio",
|
||||
"webui": True,
|
||||
"turn_id": "turn-shared",
|
||||
"media": [{"data_url": _tiny_png_data_url(), "name": "shot.png"}],
|
||||
"cli_apps": [{"name": "DrawIO", "entry_point": "cli-anything-drawio"}],
|
||||
}
|
||||
|
||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=tmp_path):
|
||||
await channel._dispatch_envelope(origin, "client-1", envelope)
|
||||
|
||||
event = json.loads(peer.send.await_args.args[0])
|
||||
assert event["event"] == "user_message"
|
||||
assert event["turn_id"] == "turn-shared"
|
||||
assert event["text"] == "please inspect @drawio"
|
||||
assert event["cli_apps"] == [{
|
||||
"name": "drawio",
|
||||
"entry_point": "cli-anything-drawio",
|
||||
}]
|
||||
assert event["media_urls"][0]["kind"] == "image"
|
||||
assert event["media_urls"][0]["name"] == "shot.png"
|
||||
assert event["media_urls"][0]["url"].startswith("/api/media/")
|
||||
assert json.loads(origin.send.await_args.args[0])["event"] == "message_accepted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_text_policy_is_independent_from_transport_limit() -> None:
|
||||
channel = _make_channel()
|
||||
|
||||
@@ -334,6 +334,56 @@ async def test_independent_sessions(bus: MagicMock) -> None:
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_session_projects_one_turn_to_both_clients(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29925)
|
||||
t = asyncio.create_task(ch.start())
|
||||
try:
|
||||
async with WsTestClient("ws://127.0.0.1:29925/", client_id="terminal-a") as a:
|
||||
async with WsTestClient("ws://127.0.0.1:29925/", client_id="terminal-b") as b:
|
||||
chat_id = (await a.recv_ready()).chat_id
|
||||
await b.recv_ready()
|
||||
await b.send_json({"type": "attach", "chat_id": chat_id})
|
||||
attached = await b.recv()
|
||||
assert attached.event == "attached"
|
||||
assert attached.chat_id == chat_id
|
||||
|
||||
await a.send_json(
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "hello from terminal A",
|
||||
"webui": True,
|
||||
"turn_id": "turn-a",
|
||||
}
|
||||
)
|
||||
|
||||
accepted = await a.recv()
|
||||
projected = await b.recv()
|
||||
assert accepted.event == "message_accepted"
|
||||
assert accepted.raw["turn_id"] == "turn-a"
|
||||
assert accepted.raw["starts_turn"] is True
|
||||
assert accepted.raw["active_turn_id"] == "turn-a"
|
||||
assert projected.raw == {
|
||||
"event": "user_message",
|
||||
"chat_id": chat_id,
|
||||
"text": "hello from terminal A",
|
||||
"starts_turn": True,
|
||||
"turn_id": "turn-a",
|
||||
"active_turn_id": "turn-a",
|
||||
"started_at": projected.raw["started_at"],
|
||||
}
|
||||
|
||||
await ch.send_delta(chat_id, "shared reply", stream_id="stream-a")
|
||||
assert (await a.recv_delta()).text == "shared reply"
|
||||
assert (await b.recv_delta()).text == "shared reply"
|
||||
|
||||
assert bus.publish_inbound.await_count == 1
|
||||
finally:
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
|
||||
ch = _ch(bus, 29914)
|
||||
|
||||
+45
-51
@@ -33,15 +33,9 @@ class TuiUnavailableError(RuntimeError):
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _GatewayLease:
|
||||
runtime: Any
|
||||
owned: bool
|
||||
class _GatewayHandle:
|
||||
base_url: str
|
||||
|
||||
def close(self) -> None:
|
||||
if self.owned:
|
||||
self.runtime.stop(timeout_s=20)
|
||||
|
||||
|
||||
def launch_tui(
|
||||
config: Config,
|
||||
@@ -51,47 +45,44 @@ def launch_tui(
|
||||
session_id: str | None,
|
||||
theme: str,
|
||||
) -> int:
|
||||
"""Run the native TUI, owning a local gateway only when one is not running."""
|
||||
"""Run the native TUI against the shared local gateway."""
|
||||
command = _resolve_tui_command()
|
||||
lease = _ensure_gateway(
|
||||
gateway = _ensure_gateway(
|
||||
config,
|
||||
config_path=config_path,
|
||||
workspace_override=workspace_override,
|
||||
)
|
||||
bootstrap = _fetch_bootstrap(
|
||||
gateway.base_url,
|
||||
secret=webui_bootstrap_secret(config),
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
|
||||
"NANOBOT_TUI_API_URL": gateway.base_url,
|
||||
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
|
||||
"NANOBOT_TUI_MODEL": _model_display(config)[0],
|
||||
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
|
||||
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
|
||||
"NANOBOT_TUI_VERSION": __version__,
|
||||
"NANOBOT_TUI_ACCESS": (
|
||||
"workspace access" if config.tools.restrict_to_workspace else "full access"
|
||||
),
|
||||
"NANOBOT_TUI_THEME": theme,
|
||||
}
|
||||
)
|
||||
state_path = config_path.parent / "tui" / "state.json"
|
||||
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
|
||||
chat_id = _initial_tui_chat_id(session_id, state_path)
|
||||
if chat_id:
|
||||
env["NANOBOT_TUI_CHAT_ID"] = chat_id
|
||||
else:
|
||||
env.pop("NANOBOT_TUI_CHAT_ID", None)
|
||||
try:
|
||||
bootstrap = _fetch_bootstrap(
|
||||
lease.base_url,
|
||||
secret=webui_bootstrap_secret(config),
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
|
||||
"NANOBOT_TUI_API_URL": lease.base_url,
|
||||
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
|
||||
"NANOBOT_TUI_MODEL": _model_display(config)[0],
|
||||
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
|
||||
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
|
||||
"NANOBOT_TUI_VERSION": __version__,
|
||||
"NANOBOT_TUI_ACCESS": (
|
||||
"workspace access" if config.tools.restrict_to_workspace else "full access"
|
||||
),
|
||||
"NANOBOT_TUI_THEME": theme,
|
||||
}
|
||||
)
|
||||
state_path = config_path.parent / "tui" / "state.json"
|
||||
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
|
||||
chat_id = _initial_tui_chat_id(session_id, state_path)
|
||||
if chat_id:
|
||||
env["NANOBOT_TUI_CHAT_ID"] = chat_id
|
||||
else:
|
||||
env.pop("NANOBOT_TUI_CHAT_ID", None)
|
||||
try:
|
||||
return subprocess.run(command, env=env, check=False).returncode
|
||||
except OSError as exc:
|
||||
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
|
||||
finally:
|
||||
lease.close()
|
||||
return subprocess.run(command, env=env, check=False).returncode
|
||||
except OSError as exc:
|
||||
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
|
||||
|
||||
|
||||
def _resolve_tui_command() -> list[str]:
|
||||
@@ -236,7 +227,7 @@ def _ensure_gateway(
|
||||
*,
|
||||
config_path: Path,
|
||||
workspace_override: str | None,
|
||||
) -> _GatewayLease:
|
||||
) -> _GatewayHandle:
|
||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
||||
|
||||
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/")
|
||||
@@ -265,7 +256,7 @@ def _ensure_gateway(
|
||||
"restart it or use `nanobot agent --classic`"
|
||||
)
|
||||
if endpoint_reachable:
|
||||
return _GatewayLease(runtime=runtime, owned=False, base_url=base_url)
|
||||
return _GatewayHandle(base_url=base_url)
|
||||
elif endpoint_reachable:
|
||||
raise TuiUnavailableError(
|
||||
"the configured gateway port belongs to a different nanobot instance; "
|
||||
@@ -279,7 +270,7 @@ def _ensure_gateway(
|
||||
config_path=str(config_path),
|
||||
)
|
||||
)
|
||||
owned = result.ok
|
||||
started_here = result.ok
|
||||
if not result.ok and result.message != "gateway_already_running":
|
||||
raise TuiUnavailableError(
|
||||
f"could not start the local gateway ({result.message}); logs: {result.status.log_path}"
|
||||
@@ -290,7 +281,7 @@ def _ensure_gateway(
|
||||
if _webui_endpoint_reachable(base_url):
|
||||
current = runtime.status()
|
||||
if current.running and current.port in {None, config.gateway.port}:
|
||||
return _GatewayLease(runtime=runtime, owned=owned, base_url=base_url)
|
||||
return _GatewayHandle(base_url=base_url)
|
||||
break
|
||||
if not runtime.status().running and not _gateway_health_ready(
|
||||
config.gateway.host,
|
||||
@@ -299,7 +290,7 @@ def _ensure_gateway(
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
if owned:
|
||||
if started_here:
|
||||
runtime.stop(timeout_s=5)
|
||||
raise TuiUnavailableError(
|
||||
f"local gateway did not become ready; logs: {result.status.log_path}"
|
||||
@@ -343,16 +334,19 @@ def _websocket_chat_id(session_id: str) -> str | None:
|
||||
"""Map the CLI selector to the WebSocket namespace used by the native TUI."""
|
||||
if session_id.startswith("websocket:"):
|
||||
return session_id.split(":", 1)[1] or None
|
||||
if session_id == "cli:direct":
|
||||
return "tui-direct"
|
||||
return session_id.split(":", 1)[-1] or None
|
||||
if ":" in session_id:
|
||||
raise TuiUnavailableError(
|
||||
"the native TUI can open only WebSocket sessions; use --classic to resume "
|
||||
f"{session_id!r}"
|
||||
)
|
||||
return session_id or None
|
||||
|
||||
|
||||
def _initial_tui_chat_id(session_id: str | None, state_path: Path) -> str | None:
|
||||
"""Resume the default TUI, while keeping an explicit selector authoritative."""
|
||||
if session_id is not None:
|
||||
return _websocket_chat_id(session_id)
|
||||
return _read_tui_chat_id(state_path) or _websocket_chat_id("cli:direct")
|
||||
return _read_tui_chat_id(state_path) or "tui-direct"
|
||||
|
||||
|
||||
def _read_tui_chat_id(path: Path) -> str | None:
|
||||
|
||||
@@ -33,7 +33,6 @@ def test_authenticated_ws_url_preserves_existing_query(monkeypatch: pytest.Monke
|
||||
@pytest.mark.parametrize(
|
||||
("session_id", "expected"),
|
||||
[
|
||||
("cli:direct", "tui-direct"),
|
||||
("websocket:abc", "abc"),
|
||||
("abc", "abc"),
|
||||
],
|
||||
@@ -42,6 +41,11 @@ def test_websocket_chat_id(session_id: str, expected: str | None) -> None:
|
||||
assert _websocket_chat_id(session_id) == expected
|
||||
|
||||
|
||||
def test_native_tui_rejects_a_session_owned_by_another_channel() -> None:
|
||||
with pytest.raises(TuiUnavailableError, match="only WebSocket sessions"):
|
||||
_websocket_chat_id("telegram:123")
|
||||
|
||||
|
||||
def test_tui_chat_state_is_optional_and_validated(tmp_path: Path) -> None:
|
||||
path = tmp_path / "tui" / "state.json"
|
||||
assert _read_tui_chat_id(path) is None
|
||||
@@ -60,9 +64,11 @@ def test_default_tui_resumes_but_explicit_session_wins(tmp_path: Path) -> None:
|
||||
path.write_text('{"chat_id": "saved-chat"}', encoding="utf-8")
|
||||
|
||||
assert _initial_tui_chat_id(None, path) == "saved-chat"
|
||||
assert _initial_tui_chat_id("cli:direct", path) == "tui-direct"
|
||||
assert _initial_tui_chat_id("websocket:chosen", path) == "chosen"
|
||||
|
||||
path.unlink()
|
||||
assert _initial_tui_chat_id(None, path) == "tui-direct"
|
||||
|
||||
|
||||
def test_launcher_passes_the_canonical_model_preset_to_the_tui(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -71,7 +77,6 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
|
||||
config = Config()
|
||||
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
|
||||
config.agents.defaults.model_preset = "Deep Research"
|
||||
closed: list[bool] = []
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
|
||||
@@ -79,7 +84,6 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
|
||||
"nanobot.cli.tui_launcher._ensure_gateway",
|
||||
lambda *args, **kwargs: SimpleNamespace(
|
||||
base_url="http://127.0.0.1:8765",
|
||||
close=lambda: closed.append(True),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -110,7 +114,6 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
|
||||
assert result == 0
|
||||
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
|
||||
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research"
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_explicit_tui_binary_must_exist(
|
||||
@@ -401,13 +404,10 @@ def test_gateway_reuses_the_matching_managed_instance(
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
config.agents.defaults.workspace = str(workspace)
|
||||
runtime: object | None = None
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, *, paths: object) -> None:
|
||||
nonlocal runtime
|
||||
self.paths = paths
|
||||
runtime = self
|
||||
|
||||
def status(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(running=True, port=config.gateway.port)
|
||||
@@ -418,12 +418,55 @@ def test_gateway_reuses_the_matching_managed_instance(
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._webui_endpoint_reachable", lambda _url: True)
|
||||
|
||||
lease = _ensure_gateway(
|
||||
gateway = _ensure_gateway(
|
||||
config,
|
||||
config_path=tmp_path / "config.json",
|
||||
workspace_override=str(workspace),
|
||||
)
|
||||
|
||||
assert lease.runtime is runtime
|
||||
assert lease.owned is False
|
||||
lease.close()
|
||||
assert gateway.base_url == "http://127.0.0.1:8765"
|
||||
|
||||
|
||||
def test_gateway_started_for_tui_remains_shared_after_launch(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = Config()
|
||||
started = False
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, *, paths: object) -> None:
|
||||
self.paths = paths
|
||||
|
||||
def status(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
running=started,
|
||||
port=config.gateway.port if started else None,
|
||||
)
|
||||
|
||||
def start_background(self, _options: object) -> SimpleNamespace:
|
||||
nonlocal started
|
||||
started = True
|
||||
return SimpleNamespace(
|
||||
ok=True,
|
||||
message="gateway_started",
|
||||
status=SimpleNamespace(log_path=tmp_path / "gateway.log"),
|
||||
)
|
||||
|
||||
def stop(self, *, timeout_s: int) -> None:
|
||||
raise AssertionError(f"shared gateway stopped with timeout {timeout_s}")
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher._webui_endpoint_reachable",
|
||||
lambda _url: started,
|
||||
)
|
||||
|
||||
gateway = _ensure_gateway(
|
||||
config,
|
||||
config_path=tmp_path / "config.json",
|
||||
workspace_override=None,
|
||||
)
|
||||
|
||||
assert gateway.base_url == "http://127.0.0.1:8765"
|
||||
assert started is True
|
||||
|
||||
+14
-5
@@ -9,7 +9,7 @@ bun run --cwd tui test
|
||||
bun run --cwd tui build
|
||||
```
|
||||
|
||||
`nanobot agent` launches this client, attaches to an existing local gateway or leases one for the process lifetime, and passes an authenticated local endpoint through environment variables. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
`nanobot agent` launches this client, attaches to the shared local gateway or starts it in the background, and passes an authenticated local endpoint through environment variables. Exiting one client leaves that gateway available to other terminals and the WebUI; stop it explicitly with `nanobot gateway stop`. Source checkouts automatically align dependencies with `bun.lock` before launch; released installs use a version-matched, checksum-verified sidecar. Startup fails explicitly if the native client is unavailable. The legacy Python prompt is only selected with `nanobot agent --classic`.
|
||||
|
||||
The renderer uses OpenTUI's retained full-screen layout: the transcript reflows with the terminal while the composer stays fixed at the bottom. Mouse and keyboard scrolling operate inside the transcript, and leaving the TUI restores the previous terminal screen.
|
||||
|
||||
@@ -22,10 +22,11 @@ to move, `Tab` to complete, and `Esc` to close the menu.
|
||||
|
||||
Type `@` to complete installed CLI apps, configured MCP servers, or saved sessions through the
|
||||
same gateway metadata used by the WebUI. While nanobot is working, `Enter` steers the current
|
||||
turn, `Tab` queues a follow-up for the next turn, and `Alt+Up` returns the latest queued message
|
||||
to the composer for editing. The pending queue stays visible above the composer.
|
||||
Use `Ctrl+J` for a newline; `Shift+Enter`, `Alt+Enter`, and `Ctrl+Enter` are also accepted when
|
||||
the terminal can distinguish them.
|
||||
turn, `Tab` queues a follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on
|
||||
Windows/Linux) returns the latest queued message to the composer for editing. The pending queue
|
||||
stays visible above the composer.
|
||||
Use `Shift+Enter` for a newline; `Ctrl+J` is the universal fallback when a terminal cannot
|
||||
distinguish modified Enter keys. `Alt+Enter` and `Ctrl+Enter` are also accepted when distinguishable.
|
||||
Unsent prompts return to the composer if the turn stops or fails.
|
||||
|
||||
Use `/sessions` to search and switch persisted conversations without leaving the terminal.
|
||||
@@ -34,6 +35,14 @@ command keeps its cross-channel behavior and resets the current chat. The next l
|
||||
the last session unless `--session` selects another one. When earlier transcript pages exist,
|
||||
press `PageUp` at the top to load them in place.
|
||||
|
||||
The native client accepts bare WebSocket chat IDs or `websocket:<id>` selectors. Use
|
||||
`nanobot agent --classic --session <channel:id>` to resume a session owned by another channel;
|
||||
the TUI never silently maps one channel's identity into the WebSocket namespace.
|
||||
|
||||
Sessions are live across clients: if two terminals or the WebUI attach to the same session, an
|
||||
accepted user message and the resulting agent/tool stream appear on every attached client while
|
||||
the gateway executes the input only once.
|
||||
|
||||
`/branch` creates a new saved conversation from a completed reply without changing the source
|
||||
session. The picker uses durable history indices, so paginated transcripts branch at the selected
|
||||
turn rather than the currently visible row.
|
||||
|
||||
+79
-3
@@ -167,7 +167,7 @@ describe("NanobotTui layout", () => {
|
||||
expect(sent).toEqual(["你好"])
|
||||
})
|
||||
|
||||
test("inserts a newline without sending and gives the composer breathing room", async () => {
|
||||
test("inserts newlines with Shift+Enter and the universal Ctrl+J fallback", async () => {
|
||||
const sent: string[] = []
|
||||
setup = await createRenderer({
|
||||
width: 72,
|
||||
@@ -184,11 +184,13 @@ describe("NanobotTui layout", () => {
|
||||
}
|
||||
|
||||
await setup.mockInput.typeText("first")
|
||||
setup.mockInput.pressKey("j", { ctrl: true })
|
||||
setup.mockInput.pressEnter({ shift: true })
|
||||
await setup.mockInput.typeText("second")
|
||||
setup.mockInput.pressKey("j", { ctrl: true })
|
||||
await setup.mockInput.typeText("third")
|
||||
await setup.flush()
|
||||
|
||||
expect(ui.composer.plainText).toBe("first\nsecond")
|
||||
expect(ui.composer.plainText).toBe("first\nsecond\nthird")
|
||||
expect(sent).toEqual([])
|
||||
expect(ui.composerFrame.height).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
@@ -309,6 +311,80 @@ describe("NanobotTui layout", () => {
|
||||
expect((app as unknown as { activeTurn: boolean }).activeTurn).toBeTrue()
|
||||
})
|
||||
|
||||
test("projects user turns from another terminal without duplicating replayed history", async () => {
|
||||
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
|
||||
const app = mount(setup)
|
||||
app.accept({ event: "attached", chat_id: "chat" })
|
||||
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
||||
|
||||
const first = {
|
||||
event: "user_message" as const,
|
||||
chat_id: "chat",
|
||||
text: "hello from terminal A",
|
||||
turn_id: "remote-turn",
|
||||
active_turn_id: "remote-turn",
|
||||
starts_turn: true,
|
||||
started_at: 1_700_000_000,
|
||||
media_urls: [{
|
||||
kind: "file" as const,
|
||||
url: "/api/media/sig/report",
|
||||
name: "report.pdf",
|
||||
}],
|
||||
}
|
||||
app.accept(first)
|
||||
app.accept(first)
|
||||
app.accept({
|
||||
event: "user_message",
|
||||
chat_id: "chat",
|
||||
text: "one more remote detail",
|
||||
turn_id: "remote-steer",
|
||||
active_turn_id: "remote-turn",
|
||||
starts_turn: false,
|
||||
})
|
||||
await setup.flush()
|
||||
|
||||
const state = app as unknown as { activeTurn: boolean; activeTurnId: string | null }
|
||||
const frame = setup.captureCharFrame()
|
||||
expect(occurrences(frame, "hello from terminal A")).toBe(1)
|
||||
expect(occurrences(frame, "Attachments: report.pdf")).toBe(1)
|
||||
expect(occurrences(frame, "one more remote detail")).toBe(1)
|
||||
expect(state.activeTurn).toBeTrue()
|
||||
expect(state.activeTurnId).toBe("remote-turn")
|
||||
|
||||
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "remote-turn" })
|
||||
expect(state.activeTurn).toBeFalse()
|
||||
})
|
||||
|
||||
test("reconciles simultaneous submits to the gateway-owned turn", async () => {
|
||||
const sent: string[] = []
|
||||
setup = await createRenderer({ width: 88, height: 24, screenMode: "alternate-screen" })
|
||||
const app = mount(setup, sent)
|
||||
app.accept({ event: "attached", chat_id: "chat" })
|
||||
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
||||
|
||||
const ui = app as unknown as {
|
||||
composer: TextareaRenderable
|
||||
activeTurn: boolean
|
||||
activeTurnId: string | null
|
||||
}
|
||||
ui.composer.setText("submitted from terminal B")
|
||||
ui.composer.submit()
|
||||
await waitUntil(() => sent.length === 1)
|
||||
expect(ui.activeTurnId).toBe("turn")
|
||||
|
||||
app.accept({
|
||||
event: "message_accepted",
|
||||
chat_id: "chat",
|
||||
turn_id: "turn",
|
||||
active_turn_id: "terminal-a-turn",
|
||||
starts_turn: false,
|
||||
started_at: 1_700_000_000,
|
||||
})
|
||||
|
||||
expect(ui.activeTurn).toBeTrue()
|
||||
expect(ui.activeTurnId).toBe("terminal-a-turn")
|
||||
})
|
||||
|
||||
test("recalls submitted prompts without stealing multiline cursor movement", async () => {
|
||||
const sent: string[] = []
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
|
||||
+41
-3
@@ -586,6 +586,7 @@ export class NanobotTui {
|
||||
|
||||
this.renderer.keyInput.on("keypress", this.handleKey)
|
||||
this.renderer.on(CliRenderEvents.THEME_MODE, this.handleTheme)
|
||||
this.renderer.on(CliRenderEvents.CAPABILITIES, this.handleCapabilities)
|
||||
this.renderer.on(CliRenderEvents.RESIZE, this.handleResize)
|
||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||
this.renderer.console.onCopySelection = (text) => void this.copySelection(text)
|
||||
@@ -720,12 +721,17 @@ export class NanobotTui {
|
||||
this.commandMenu.hide()
|
||||
this.mentionMenu.hide()
|
||||
this.recordPrompt(prompt.content)
|
||||
this.transcript.user(prompt.content)
|
||||
this.transcript.user(prompt.content, turnId)
|
||||
if (steering) {
|
||||
this.status.content = `Steering current turn${this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""}`
|
||||
this.updateMeta()
|
||||
return true
|
||||
}
|
||||
this.beginTurn(turnId)
|
||||
return true
|
||||
}
|
||||
|
||||
private beginTurn(turnId: string | null, startedAt?: number): void {
|
||||
this.activeTurnId = turnId
|
||||
this.readyDetail = ""
|
||||
this.finalMessage = ""
|
||||
@@ -733,8 +739,23 @@ export class NanobotTui {
|
||||
this.lastProgress = ""
|
||||
this.activeLabel = "Thinking"
|
||||
this.currentFileEdits = []
|
||||
this.setActive(true)
|
||||
return true
|
||||
this.setActive(true, startedAt)
|
||||
}
|
||||
|
||||
private reconcileTurnOwnership(event: {
|
||||
turn_id?: string
|
||||
active_turn_id?: string
|
||||
starts_turn?: boolean
|
||||
started_at?: number
|
||||
}): void {
|
||||
if (event.active_turn_id && this.activeTurn) {
|
||||
this.activeTurnId = event.active_turn_id
|
||||
} else if (event.active_turn_id || (event.starts_turn && !this.activeTurn)) {
|
||||
this.beginTurn(
|
||||
event.active_turn_id || event.turn_id || null,
|
||||
typeof event.started_at === "number" ? event.started_at * 1000 : undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
accept(event: InboundEvent): void {
|
||||
@@ -777,7 +798,18 @@ export class NanobotTui {
|
||||
|
||||
switch (event.event) {
|
||||
case "message_accepted":
|
||||
this.reconcileTurnOwnership(event)
|
||||
return
|
||||
case "user_message": {
|
||||
const attachments = event.media_urls?.map((media) => media.name).filter(Boolean) || []
|
||||
const content = [
|
||||
event.text,
|
||||
attachments.length ? `Attachments: ${attachments.join(", ")}` : "",
|
||||
].filter(Boolean).join("\n")
|
||||
if (this.transcript.user(content, event.turn_id)) this.recordPrompt(event.text)
|
||||
this.reconcileTurnOwnership(event)
|
||||
return
|
||||
}
|
||||
case "delta":
|
||||
this.setActive(true)
|
||||
this.activeLabel = "Writing"
|
||||
@@ -1277,6 +1309,10 @@ export class NanobotTui {
|
||||
this.applyTheme(mode)
|
||||
}
|
||||
|
||||
private handleCapabilities = (): void => {
|
||||
this.updateMeta()
|
||||
}
|
||||
|
||||
private resolveThemeMode(detected: ThemeMode | null): ThemeMode {
|
||||
return this.options.theme === "auto" ? detected ?? "dark" : this.options.theme
|
||||
}
|
||||
@@ -1328,6 +1364,8 @@ export class NanobotTui {
|
||||
mode,
|
||||
this.renderer.width,
|
||||
footerHintTheme(this.palette),
|
||||
process.platform,
|
||||
Boolean(this.renderer.capabilities?.kitty_keyboard),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,29 @@ describe("footerHints", () => {
|
||||
})
|
||||
|
||||
test("adapts the active-turn vocabulary to available width", () => {
|
||||
const wide = contextualFooterHints("active", 100, theme)
|
||||
const compact = contextualFooterHints("active", 72, theme)
|
||||
const wide = contextualFooterHints("active", 100, theme, "linux")
|
||||
const compact = contextualFooterHints("active", 72, theme, "linux")
|
||||
|
||||
expect(wide.chunks.map(({ text }) => text).join(""))
|
||||
.toBe("enter steer · tab queue · alt+↑ edit · ctrl+c stop")
|
||||
expect(compact.chunks.map(({ text }) => text).join(""))
|
||||
.toBe("enter steer · tab queue · ctrl+c stop")
|
||||
})
|
||||
|
||||
test("uses the native Option symbol on macOS", () => {
|
||||
const result = contextualFooterHints("active", 100, theme, "darwin")
|
||||
|
||||
expect(result.chunks.map(({ text }) => text).join(""))
|
||||
.toBe("enter steer · tab queue · ⌥↑ edit · ctrl+c stop")
|
||||
})
|
||||
|
||||
test("advertises Shift+Enter when the terminal can distinguish it", () => {
|
||||
const enhanced = contextualFooterHints("ready", 80, theme, "darwin", true)
|
||||
const legacy = contextualFooterHints("ready", 80, theme, "darwin", false)
|
||||
|
||||
expect(enhanced.chunks.map(({ text }) => text).join(""))
|
||||
.toBe("enter send · shift+enter newline · ctrl+c stop")
|
||||
expect(legacy.chunks.map(({ text }) => text).join(""))
|
||||
.toBe("enter send · ctrl+j newline · ctrl+c stop")
|
||||
})
|
||||
})
|
||||
|
||||
+16
-6
@@ -1,5 +1,7 @@
|
||||
import { RGBA, StyledText, TextAttributes, type TextChunk } from "@opentui/core"
|
||||
|
||||
import { optionArrowUp } from "./platform-keys"
|
||||
|
||||
export interface FooterHint {
|
||||
key: string
|
||||
label: string
|
||||
@@ -27,8 +29,10 @@ export function contextualFooterHints(
|
||||
mode: FooterMode,
|
||||
width: number,
|
||||
theme: FooterHintTheme,
|
||||
platform: string = process.platform,
|
||||
shiftedEnter = false,
|
||||
): StyledText {
|
||||
return footerHints(hintsFor(mode, width), theme)
|
||||
return footerHints(hintsFor(mode, width, platform, shiftedEnter), theme)
|
||||
}
|
||||
|
||||
/** Give shortcuts visual hierarchy without turning the footer into a toolbar. */
|
||||
@@ -43,12 +47,17 @@ export function footerHints(hints: readonly FooterHint[], theme: FooterHintTheme
|
||||
return new StyledText(chunks)
|
||||
}
|
||||
|
||||
function hintsFor(mode: FooterMode, width: number): FooterHint[] {
|
||||
function hintsFor(
|
||||
mode: FooterMode,
|
||||
width: number,
|
||||
platform: string,
|
||||
shiftedEnter: boolean,
|
||||
): FooterHint[] {
|
||||
if (mode === "mention") return width >= 64
|
||||
? [hint("↑↓", "choose"), hint("tab/enter", "insert"), hint("esc", "close")]
|
||||
: [hint("enter", "insert"), hint("esc", "close")]
|
||||
if (mode === "active") return width >= 96
|
||||
? [hint("enter", "steer"), hint("tab", "queue"), hint("alt+↑", "edit"), stopHint()]
|
||||
? [hint("enter", "steer"), hint("tab", "queue"), hint(optionArrowUp(platform), "edit"), stopHint()]
|
||||
: width >= 64 ? [hint("enter", "steer"), hint("tab", "queue"), stopHint()] : []
|
||||
if (mode === "branch") return width >= 64
|
||||
? [hint("type", "filter"), hint("↑↓", "choose"), hint("enter", "branch"), hint("esc", "close")]
|
||||
@@ -63,15 +72,16 @@ function hintsFor(mode: FooterMode, width: number): FooterHint[] {
|
||||
if (mode === "history") return width >= 72
|
||||
? [hint("ctrl+end", "latest"), hint("pgup/pgdn", "scroll")]
|
||||
: width >= 48 ? [hint("ctrl+end", "latest")] : []
|
||||
const newline = shiftedEnter ? "shift+enter" : "ctrl+j"
|
||||
if (width >= 112) return [
|
||||
hint("enter", "send"),
|
||||
hint("ctrl+j", "newline"),
|
||||
hint(newline, "newline"),
|
||||
hint("pgup/pgdn", "scroll"),
|
||||
hint("ctrl+o", "tools"),
|
||||
stopHint(),
|
||||
]
|
||||
if (width >= 72) return [hint("enter", "send"), hint("ctrl+j", "newline"), stopHint()]
|
||||
return width >= 48 ? [hint("enter", "send"), hint("ctrl+j", "newline")] : []
|
||||
if (width >= 72) return [hint("enter", "send"), hint(newline, "newline"), stopHint()]
|
||||
return width >= 48 ? [hint("enter", "send"), hint(newline, "newline")] : []
|
||||
}
|
||||
|
||||
function hint(key: string, label: string): FooterHint {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function optionArrowUp(platform: string = process.platform): string {
|
||||
return platform === "darwin" ? "⌥↑" : "alt+↑"
|
||||
}
|
||||
@@ -38,11 +38,25 @@ class FakeSocket {
|
||||
describe("gateway protocol", () => {
|
||||
test("represents lifecycle frames without browser state", () => {
|
||||
const events: InboundEvent[] = [
|
||||
{
|
||||
event: "user_message",
|
||||
chat_id: "one",
|
||||
text: "question",
|
||||
turn_id: "turn-one",
|
||||
active_turn_id: "turn-one",
|
||||
starts_turn: true,
|
||||
media_urls: [{ kind: "file", url: "/api/media/sig/file", name: "report.pdf" }],
|
||||
},
|
||||
{ event: "delta", chat_id: "one", text: "hello" },
|
||||
{ event: "stream_end", chat_id: "one", resuming: false },
|
||||
{ event: "turn_end", chat_id: "one", latency_ms: 12 },
|
||||
]
|
||||
expect(events.map((event) => event.event)).toEqual(["delta", "stream_end", "turn_end"])
|
||||
expect(events.map((event) => event.event)).toEqual([
|
||||
"user_message",
|
||||
"delta",
|
||||
"stream_end",
|
||||
"turn_end",
|
||||
])
|
||||
})
|
||||
|
||||
test("attaches and sends turns through the gateway envelope", () => {
|
||||
@@ -139,6 +153,13 @@ describe("gateway protocol", () => {
|
||||
if (!socket) throw new Error("socket was not created")
|
||||
socket.emit("message", { data: "[]" })
|
||||
socket.emit("message", { data: JSON.stringify({ event: "delta", chat_id: "one" }) })
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({
|
||||
event: "user_message",
|
||||
chat_id: "one",
|
||||
text: "missing lifecycle",
|
||||
}),
|
||||
})
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({
|
||||
event: "message",
|
||||
@@ -153,6 +174,15 @@ describe("gateway protocol", () => {
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({ event: "session_updated", chat_id: "one", scope: 42 }),
|
||||
})
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({
|
||||
event: "user_message",
|
||||
chat_id: "one",
|
||||
text: "bad media",
|
||||
starts_turn: false,
|
||||
media_urls: [{ kind: "archive", url: "/api/media/sig/file" }],
|
||||
}),
|
||||
})
|
||||
socket.emit("message", {
|
||||
data: JSON.stringify({ event: "attached", chat_id: "one", model_preset: 42 }),
|
||||
})
|
||||
@@ -162,7 +192,7 @@ describe("gateway protocol", () => {
|
||||
socket.emit("message", { data: JSON.stringify({ event: "future_gateway_event" }) })
|
||||
socket.emit("message", { data: JSON.stringify({ event: "error", detail: "global failure" }) })
|
||||
expect(statuses).toContain("error:gateway sent an invalid event")
|
||||
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(6)
|
||||
expect(statuses.filter((status) => status.includes("invalid event"))).toHaveLength(8)
|
||||
expect(events).toContainEqual({
|
||||
event: "session_updated",
|
||||
chat_id: "one",
|
||||
@@ -274,7 +304,7 @@ describe("gateway protocol", () => {
|
||||
requested = String(input)
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "user", content: "hello", turnId: "turn-1" },
|
||||
{
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
@@ -293,7 +323,7 @@ describe("gateway protocol", () => {
|
||||
const history = await fetchHistory("http://nanobot.test", "token", "chat", "newer-page")
|
||||
expect(history).toEqual({
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{ role: "user", content: "hello", turnId: "turn-1" },
|
||||
{
|
||||
role: "activity",
|
||||
content: "read_file",
|
||||
|
||||
+56
-3
@@ -36,6 +36,12 @@ export interface FileDiff {
|
||||
text?: string
|
||||
}
|
||||
|
||||
export interface MediaAttachment {
|
||||
kind: "image" | "video" | "file"
|
||||
url: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| {
|
||||
@@ -44,7 +50,24 @@ export type InboundEvent =
|
||||
model_preset?: string | null
|
||||
usage?: TokenUsage
|
||||
}
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| {
|
||||
event: "message_accepted"
|
||||
chat_id: string
|
||||
turn_id: string
|
||||
starts_turn?: boolean
|
||||
active_turn_id?: string
|
||||
started_at?: number
|
||||
}
|
||||
| {
|
||||
event: "user_message"
|
||||
chat_id: string
|
||||
text: string
|
||||
turn_id?: string
|
||||
active_turn_id?: string
|
||||
starts_turn: boolean
|
||||
started_at?: number
|
||||
media_urls?: MediaAttachment[]
|
||||
}
|
||||
| {
|
||||
event: "message"
|
||||
chat_id: string
|
||||
@@ -119,6 +142,7 @@ export interface ClientOptions {
|
||||
export interface HistoryMessage {
|
||||
role: "user" | "assistant" | "activity"
|
||||
content: string
|
||||
turnId?: string
|
||||
toolEvents?: ToolProgressEvent[]
|
||||
fileEdits?: FileEditEvent[]
|
||||
forkIndex?: number
|
||||
@@ -213,6 +237,7 @@ const SLASH_COMMAND_LIFECYCLES = new Set([
|
||||
const CHAT_EVENTS = new Set([
|
||||
"attached",
|
||||
"message_accepted",
|
||||
"user_message",
|
||||
"message",
|
||||
"file_edit",
|
||||
"delta",
|
||||
@@ -283,6 +308,13 @@ function isTokenUsage(value: unknown): value is TokenUsage {
|
||||
].every((key) => optional(value[key], "number"))
|
||||
}
|
||||
|
||||
function isMediaAttachment(value: unknown): value is MediaAttachment {
|
||||
return isRecord(value)
|
||||
&& (value.kind === "image" || value.kind === "video" || value.kind === "file")
|
||||
&& typeof value.url === "string"
|
||||
&& optional(value.name, "string")
|
||||
}
|
||||
|
||||
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||
if (!isRecord(value)) return null
|
||||
const record = value
|
||||
@@ -315,9 +347,26 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||
&& typeof record.model_preset !== "string")
|
||||
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|
||||
) return null
|
||||
if (["message", "delta", "reasoning_delta"].includes(name) && typeof record.text !== "string") {
|
||||
if (
|
||||
["user_message", "message", "delta", "reasoning_delta"].includes(name)
|
||||
&& typeof record.text !== "string"
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
["message_accepted", "user_message"].includes(name)
|
||||
&& (
|
||||
(name === "user_message" && typeof record.starts_turn !== "boolean")
|
||||
|| !optional(record.starts_turn, "boolean")
|
||||
|| !optional(record.active_turn_id, "string")
|
||||
|| !optional(record.started_at, "number")
|
||||
)
|
||||
) return null
|
||||
if (
|
||||
name === "user_message"
|
||||
&& record.media_urls !== undefined
|
||||
&& (!Array.isArray(record.media_urls) || !record.media_urls.every(isMediaAttachment))
|
||||
) return null
|
||||
if (
|
||||
name === "message"
|
||||
&& record.tool_events !== undefined
|
||||
@@ -411,7 +460,11 @@ export async function fetchHistory(
|
||||
}
|
||||
if (role === "user") {
|
||||
userIndex += 1
|
||||
messages.push({ role: "user", content })
|
||||
messages.push({
|
||||
role: "user",
|
||||
content,
|
||||
...(typeof message.turnId === "string" ? { turnId: message.turnId } : {}),
|
||||
})
|
||||
} else {
|
||||
messages.push({ role: "assistant", content, forkIndex: userIndex })
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("QueuePreview", () => {
|
||||
accent: "#EF8E30",
|
||||
muted: "#A1A1AA",
|
||||
faint: "#71717A",
|
||||
})
|
||||
}, "darwin")
|
||||
setup.renderer.root.add(preview.root)
|
||||
|
||||
preview.update(["first\nline", "second", "third", "fourth"])
|
||||
@@ -25,6 +25,7 @@ describe("QueuePreview", () => {
|
||||
const frame = setup.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("Queued next 4")
|
||||
expect(frame).toContain("⌥↑ edit last")
|
||||
expect(frame).not.toContain("first line")
|
||||
expect(frame).toContain("↳ second")
|
||||
expect(frame).toContain("↳ fourth")
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type TextChunk,
|
||||
} from "@opentui/core"
|
||||
|
||||
import { optionArrowUp } from "./platform-keys"
|
||||
|
||||
export interface QueuePreviewTheme {
|
||||
accent: string
|
||||
muted: string
|
||||
@@ -21,11 +23,13 @@ export class QueuePreview {
|
||||
readonly root: BoxRenderable
|
||||
private readonly header: TextRenderable
|
||||
private readonly rows: TextRenderable[]
|
||||
private readonly editKey: string
|
||||
private theme: QueuePreviewTheme
|
||||
private messages: readonly string[] = []
|
||||
|
||||
constructor(renderer: CliRenderer, theme: QueuePreviewTheme) {
|
||||
constructor(renderer: CliRenderer, theme: QueuePreviewTheme, platform: string = process.platform) {
|
||||
this.theme = theme
|
||||
this.editKey = optionArrowUp(platform)
|
||||
this.root = new BoxRenderable(renderer, {
|
||||
id: "nanobot-tui-queue-preview",
|
||||
width: "100%",
|
||||
@@ -64,7 +68,7 @@ export class QueuePreview {
|
||||
this.header.content = new StyledText([
|
||||
chunk("Queued next", this.theme.accent, true),
|
||||
chunk(` ${messages.length}`, this.theme.faint),
|
||||
chunk(" · alt+↑ edit last", this.theme.faint),
|
||||
chunk(` · ${this.editKey} edit last`, this.theme.faint),
|
||||
])
|
||||
this.rows.forEach((row, index) => {
|
||||
const message = visible[index]
|
||||
|
||||
@@ -59,6 +59,7 @@ export class Transcript {
|
||||
private readonly activities = new Set<Activity>()
|
||||
private readonly frames = new Set<BoxRenderable>()
|
||||
private readonly userRows = new Set<BoxRenderable>()
|
||||
private readonly userTurnIds = new Set<string>()
|
||||
private wrote = false
|
||||
private nextId = 0
|
||||
private navigation: TranscriptNavigation = { awayFromBottom: false, unseenOutput: false }
|
||||
@@ -150,6 +151,7 @@ export class Transcript {
|
||||
this.activities.clear()
|
||||
this.frames.clear()
|
||||
this.userRows.clear()
|
||||
this.userTurnIds.clear()
|
||||
this.wrote = false
|
||||
this.nextId = 0
|
||||
this.navigation = { awayFromBottom: false, unseenOutput: false }
|
||||
@@ -160,7 +162,7 @@ export class Transcript {
|
||||
|
||||
history(messages: HistoryMessage[]): void {
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") this.user(message.content)
|
||||
if (message.role === "user") this.user(message.content, message.turnId)
|
||||
else if (message.role === "assistant") this.assistant(message.content)
|
||||
else if (message.fileEdits?.length) this.fileEdits(message.fileEdits)
|
||||
else this.progress(message.content, message.toolEvents)
|
||||
@@ -175,7 +177,9 @@ export class Transcript {
|
||||
let index = 1 // Keep the launch header first.
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
if (message.turnId && this.userTurnIds.has(message.turnId)) continue
|
||||
this.writeRole("›", message.content, "user", index++)
|
||||
if (message.turnId) this.userTurnIds.add(message.turnId)
|
||||
} else if (message.role === "assistant") {
|
||||
this.writeMarkdown(message.content, false, index++)
|
||||
} else {
|
||||
@@ -200,10 +204,13 @@ export class Transcript {
|
||||
return this.root.scrollTop <= 0
|
||||
}
|
||||
|
||||
user(content: string): void {
|
||||
user(content: string, turnId?: string): boolean {
|
||||
if (turnId && this.userTurnIds.has(turnId)) return false
|
||||
this.noteOutput()
|
||||
this.finishActivity()
|
||||
this.writeRole("›", content, "user")
|
||||
if (turnId) this.userTurnIds.add(turnId)
|
||||
return true
|
||||
}
|
||||
|
||||
assistant(content: string): void {
|
||||
|
||||
@@ -709,6 +709,37 @@ export function useNanobotStream(
|
||||
setMessages((prev) => transitionTurnDelivery(prev, turnId, "accepted"));
|
||||
}
|
||||
if (ev.event === "message_accepted") return;
|
||||
if (ev.event === "user_message") {
|
||||
setMessages((prev) => {
|
||||
if (ev.turn_id && prev.some((message) => (
|
||||
message.role === "user" && message.turnId === ev.turn_id
|
||||
))) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content: ev.text,
|
||||
...(ev.turn_id ? { turnId: ev.turn_id } : {}),
|
||||
turnPhase: "user",
|
||||
turnSeq: 0,
|
||||
deliveryStatus: "accepted",
|
||||
createdAt: Date.now(),
|
||||
...(ev.media_urls?.length ? { media: ev.media_urls } : {}),
|
||||
...(ev.cli_apps?.length ? { cliApps: ev.cli_apps } : {}),
|
||||
...(ev.mcp_presets?.length ? { mcpPresets: ev.mcp_presets } : {}),
|
||||
...(ev.session_mentions?.length
|
||||
? { sessionMentions: ev.session_mentions }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
if (ev.active_turn_id || ev.starts_turn) {
|
||||
setIsStreaming(true);
|
||||
if (typeof ev.started_at === "number") setRunStartedAt(ev.started_at);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const sideChannelEvent = isSideChannelEvent(ev);
|
||||
if (
|
||||
streamEndTimerRef.current !== null
|
||||
|
||||
@@ -589,6 +589,34 @@ export class NanobotClient {
|
||||
pending.state = "accepted";
|
||||
}
|
||||
|
||||
private recordCanonicalTurnOwnership(
|
||||
ev: Extract<InboundEvent, { event: "message_accepted" | "user_message" }>,
|
||||
): void {
|
||||
const activeTurnId = ev.active_turn_id;
|
||||
if (!activeTurnId) return;
|
||||
|
||||
// Two clients can optimistically submit while the chat still looks idle.
|
||||
// The gateway admits exactly one owner and classifies the other message as
|
||||
// steering. Replace the local guess before its ACK can preserve the wrong
|
||||
// run identity.
|
||||
if (ev.turn_id && ev.turn_id !== activeTurnId) {
|
||||
const pending = this.pendingMessageSends.get(this.runSendKey(ev.chat_id, ev.turn_id));
|
||||
if (pending?.startsNewRun) this.settleRunTurn(ev.chat_id, ev.turn_id);
|
||||
}
|
||||
if (this.latestRunTurnIdByChatId.get(ev.chat_id) !== activeTurnId) {
|
||||
this.advanceRunGeneration(ev.chat_id, activeTurnId);
|
||||
}
|
||||
if (typeof ev.started_at === "number") {
|
||||
this.runStartedAtByTurnKey.set(
|
||||
this.runSendKey(ev.chat_id, activeTurnId),
|
||||
ev.started_at,
|
||||
);
|
||||
const previous = this.runStartedAtByChatId.get(ev.chat_id);
|
||||
this.runStartedAtByChatId.set(ev.chat_id, ev.started_at);
|
||||
if (previous !== ev.started_at) this.emitRunStatus(ev.chat_id, ev.started_at);
|
||||
}
|
||||
}
|
||||
|
||||
private recordRunRejection(chatId: string, turnId?: string): void {
|
||||
if (!turnId) return;
|
||||
const rejectedLatest = this.latestRunTurnIdByChatId.get(chatId) === turnId;
|
||||
@@ -1097,6 +1125,9 @@ export class NanobotClient {
|
||||
const turnId = "turn_id" in parsed && typeof parsed.turn_id === "string"
|
||||
? parsed.turn_id
|
||||
: null;
|
||||
if (parsed.event === "message_accepted" || parsed.event === "user_message") {
|
||||
this.recordCanonicalTurnOwnership(parsed);
|
||||
}
|
||||
if (parsed.event === "message_accepted") {
|
||||
this.recordRunAcceptance(parsed.chat_id, parsed.turn_id);
|
||||
if (!isSystemCommandTurnId(turnId)) {
|
||||
|
||||
+21
-1
@@ -1207,7 +1207,27 @@ export interface InboundTurnMetadata {
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string; temporary?: boolean }
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| {
|
||||
event: "message_accepted";
|
||||
chat_id: string;
|
||||
turn_id: string;
|
||||
starts_turn?: boolean;
|
||||
active_turn_id?: string;
|
||||
started_at?: number;
|
||||
}
|
||||
| {
|
||||
event: "user_message";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
turn_id?: string;
|
||||
active_turn_id?: string;
|
||||
starts_turn: boolean;
|
||||
started_at?: number;
|
||||
media_urls?: UIMediaAttachment[];
|
||||
cli_apps?: UICliAppAttachment[];
|
||||
mcp_presets?: UIMcpPresetAttachment[];
|
||||
session_mentions?: SessionMention[];
|
||||
}
|
||||
| ({
|
||||
event: "message";
|
||||
chat_id: string;
|
||||
|
||||
@@ -71,6 +71,35 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("reconciles simultaneous client submissions to the gateway-owned turn", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
const socket = lastSocket();
|
||||
socket.fakeOpen();
|
||||
|
||||
client.sendMessage("shared-chat", "from terminal B", undefined, {
|
||||
turnId: "turn-b",
|
||||
});
|
||||
expect(client.getRunTurnId("shared-chat")).toBe("turn-b");
|
||||
|
||||
socket.fakeMessage({
|
||||
event: "message_accepted",
|
||||
chat_id: "shared-chat",
|
||||
turn_id: "turn-b",
|
||||
active_turn_id: "turn-a",
|
||||
starts_turn: false,
|
||||
started_at: 1_700_000_000,
|
||||
});
|
||||
|
||||
expect(client.getRunTurnId("shared-chat")).toBe("turn-a");
|
||||
expect(client.getRunStartedAt("shared-chat")).toBe(1_700_000_000);
|
||||
expect(client.hasUnsettledRun("shared-chat")).toBe(true);
|
||||
});
|
||||
|
||||
it("correlates successful WebUI mutation replies by request id", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -1869,6 +1869,56 @@ describe("useNanobotStream", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects a user turn submitted from another attached client exactly once", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-shared", EMPTY_MESSAGES),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
const event: InboundEvent = {
|
||||
event: "user_message",
|
||||
chat_id: "chat-shared",
|
||||
text: "hello from terminal A",
|
||||
turn_id: "remote-turn",
|
||||
active_turn_id: "remote-turn",
|
||||
starts_turn: true,
|
||||
started_at: 1_700_000_000,
|
||||
media_urls: [{ kind: "file", url: "/api/media/sig/report", name: "report.pdf" }],
|
||||
cli_apps: [{ name: "drawio", display_name: "Draw.io" }],
|
||||
mcp_presets: [{ name: "github", display_name: "GitHub" }],
|
||||
session_mentions: [{
|
||||
name: "pricing",
|
||||
session_key: "websocket:pricing",
|
||||
title: "Pricing",
|
||||
}],
|
||||
};
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-shared", event);
|
||||
fake.emit("chat-shared", event);
|
||||
});
|
||||
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: "hello from terminal A",
|
||||
turnId: "remote-turn",
|
||||
deliveryStatus: "accepted",
|
||||
createdAt: expect.any(Number),
|
||||
media: [{ kind: "file", url: "/api/media/sig/report", name: "report.pdf" }],
|
||||
cliApps: [{ name: "drawio", display_name: "Draw.io" }],
|
||||
mcpPresets: [{ name: "github", display_name: "GitHub" }],
|
||||
sessionMentions: [{
|
||||
name: "pricing",
|
||||
session_key: "websocket:pricing",
|
||||
title: "Pricing",
|
||||
}],
|
||||
}),
|
||||
]);
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.runStartedAt).toBe(1_700_000_000);
|
||||
});
|
||||
|
||||
it("marks only the optimistic turn named by a correlated rejection as failed", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(
|
||||
|
||||
Reference in New Issue
Block a user