Compare commits

...
Author SHA1 Message Date
Xubin Ren 2f1a4e5af8 style(tui): frame recovery decisions 2026-08-23 23:53:40 +08:00
Xubin Ren 080f80dfba refactor(tui): simplify session rows 2026-08-23 23:45:48 +08:00
Xubin Ren ac7fbe7aef feat(tui): refine session status navigation 2026-08-23 23:28:01 +08:00
Xubin Ren 43480141ed feat(tui): surface session activity states 2026-08-23 23:20:17 +08:00
Xubin Ren e329127722 fix(tui): clarify interrupted task actions 2026-08-23 23:04:08 +08:00
Xubin Ren f194395e01 fix(runtime): preserve interrupted turns on gateway exit 2026-08-23 22:42:58 +08:00
Xubin Ren 6fc5794dc2 fix(runtime): normalize recovery session routing 2026-08-23 21:59:11 +08:00
Xubin Ren 476bc7f4dc fix(tui): allow switching active sessions 2026-08-23 21:48:20 +08:00
Xubin Ren 0f4c9956a8 docs(readme): simplify terminal quick start 2026-08-23 21:48:20 +08:00
Xubin Ren 009a1a6e6f feat(runtime): add user-controlled turn recovery 2026-08-23 21:48:20 +08:00
chengyongruandchengyongru ffa58aa5ef fix(webui): keep answer text outside reasoning shell 2026-08-23 21:29:42 +08:00
Xubin Ren 1e9d46fb36 fix(cli): name roles through legacy launchers 2026-08-23 21:13:14 +08:00
Xubin Ren ab7351be63 fix(cli): skip process title dependency on Windows 2026-08-23 21:13:14 +08:00
Xubin Ren cfc872fb52 fix(cli): harden process identity portability 2026-08-23 21:13:14 +08:00
Xubin Ren cfc1fae8b5 feat(cli): expose nanobot process identities 2026-08-23 21:13:14 +08:00
chengyongruandchengyongru 9807e9cf37 test: remove stale Mattermost config input 2026-08-23 19:08:00 +08:00
chengyongruandchengyongru 961b1fdd7d refactor: remove remaining dead code 2026-08-23 19:08:00 +08:00
113 changed files with 5206 additions and 559 deletions
+7 -1
View File
@@ -209,7 +209,13 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
nanobot agent
```
This opens the native terminal client with the configured model and tools, using the launch directory as its workspace. 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. Each launch starts a new session; `--session` selects an existing WebSocket session, while `--workspace` overrides the launch directory. Use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. `nanobot agent` and `nanobot webui` share one on-demand local gateway: either command can start it, each launcher releases only its own client, and the last interactive launcher to exit stops it. Use `/detach` to close the TUI while keeping the gateway and any active agent turn running in the background; after the terminal is restored, nanobot prints the exact `nanobot gateway stop` command for that config and workspace. Use `nanobot gateway --background` to start persistently before opening a client. Type `exit` or press `Ctrl+C` when you are done; after the terminal is restored, nanobot prints a ready-to-run `nanobot agent --session ...` command that resumes the session. Use `nanobot agent --classic` for the legacy Python prompt.
This opens the native terminal client with the launch directory as its workspace. It shares saved conversations and the local gateway with the WebUI.
- Type `/` to discover commands, `/sessions` to switch conversations, or `@` to mention an app, MCP server, or saved session.
- Press `Enter` to send or steer, `Tab` to queue a follow-up, and `Shift+Enter` to add a newline (`Ctrl+J` works in terminals that cannot distinguish modified Enter keys).
- Use `/detach` to leave the current task running, or start with `nanobot gateway --background` when nanobot should stay online after all local clients exit.
Each launch starts a new session by default. Use `--session` to resume one and `--workspace` to choose another workspace. See the [CLI reference](./docs/cli-reference.md#agent-cli) for session branching, diffs, history, shortcuts, gateway lifecycle, and compatibility options.
For one request and an immediate exit, use:
+112 -143
View File
@@ -79,6 +79,15 @@ from nanobot.session.model_selection import (
SESSION_MODEL_PRESET_METADATA_KEY,
model_preset_from_metadata,
)
from nanobot.session.recovery import (
PENDING_FOLLOWUP_ID_KEY,
RECOVERY_INBOUND_METADATA_KEY,
RecoveryAdmission,
acknowledge_pending_followups,
record_pending_followup,
restore_pending_interruption,
restore_runtime_checkpoint,
)
from nanobot.session.summary import SessionSummary
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
from nanobot.utils.cancellation import task_is_cancelling
@@ -291,12 +300,14 @@ class AgentLoop:
restart_mode: str = "auto",
local_trigger_store: LocalTriggerStore | None = None,
idle_compact_check_interval_seconds: int = 0,
recovery_admission: RecoveryAdmission | None = None,
):
from nanobot.config.schema import ToolsConfig
_tc = tools_config or ToolsConfig()
defaults = AgentDefaults()
self.bus = bus
self._recovery_admission = recovery_admission
if turn_delivery_factory is not None:
if turn_delivery_factory.bus is not bus:
raise ValueError("turn delivery factory must use the agent message bus")
@@ -409,6 +420,7 @@ class AgentLoop:
# When a session has an active task, new messages for that session
# are routed here instead of creating a new task.
self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
self._preserve_inflight_turns_on_shutdown = False
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
self._cron_turns = CronTurnCoordinator(
publish_inbound=self.bus.publish_inbound,
@@ -726,6 +738,9 @@ class AgentLoop:
extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
session.add_message("user", text, **extra)
self._mark_pending_user_turn(session)
followup_id = msg.metadata.get(PENDING_FOLLOWUP_ID_KEY)
if isinstance(followup_id, str) and followup_id:
acknowledge_pending_followups(session, [followup_id])
self.sessions.save(session)
return True
return False
@@ -1061,6 +1076,9 @@ class AgentLoop:
row["subagent_task_id"] = task_id
row[HIDDEN_HISTORY_META] = subagent_marker
row["injected_event"] = "subagent_result"
followup_id = metadata.get(PENDING_FOLLOWUP_ID_KEY)
if isinstance(followup_id, str) and followup_id:
row[PENDING_FOLLOWUP_ID_KEY] = followup_id
return row
items: list[dict[str, Any]] = []
@@ -1285,6 +1303,23 @@ class AgentLoop:
break
if deferred:
continue
routed_msg = msg
if effective_key != msg.session_key:
routed_msg = dataclasses.replace(
msg,
session_key_override=effective_key,
)
# A newer WebUI message must supersede an explicit recovery
# before it is injected into that recovery's pending queue.
# Without this admission point, a recovered turn could finish
# first and only then observe the user's newer request.
if (
effective_key in self._pending_queues
and msg.channel == "websocket"
and self._recovery_admission is not None
and not await self._recovery_admission.admit(routed_msg)
):
continue
# If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn
# injection instead of creating a competing task.
@@ -1297,12 +1332,18 @@ class AgentLoop:
self.commands.dispatch,
)
continue
pending_msg = msg
if effective_key != msg.session_key:
pending_msg = routed_msg
session = self.sessions.get_or_create(effective_key)
followup_id = record_pending_followup(session, pending_msg)
if followup_id is not None:
pending_msg = dataclasses.replace(
msg,
session_key_override=effective_key,
pending_msg,
metadata={
**pending_msg.metadata,
PENDING_FOLLOWUP_ID_KEY: followup_id,
},
)
self.sessions.save(session)
try:
self._pending_queues[effective_key].put_nowait(pending_msg)
except asyncio.QueueFull:
@@ -1310,6 +1351,7 @@ class AgentLoop:
"Pending queue full for session {}, falling back to queued task",
effective_key,
)
msg = pending_msg
else:
logger.info(
"Routed follow-up message to pending queue for session {}",
@@ -1319,17 +1361,45 @@ class AgentLoop:
# Compute the effective session key before dispatching
# This ensures /stop command can find tasks correctly when unified session is enabled
task = asyncio.create_task(self._dispatch(msg))
active_tasks = self._active_tasks.setdefault(effective_key, set())
active_tasks: set[asyncio.Task[Any]] = self._active_tasks.setdefault(
effective_key,
set(),
)
active_tasks.add(task)
task.add_done_callback(active_tasks.discard)
finally:
await self.aclose()
def preserve_inflight_turns_on_shutdown(self) -> None:
"""Keep durable checkpoints when the owning gateway exits.
Normal cancellation intentionally materializes partial output so a
user-stopped turn leaves a readable conversation. Gateway lifecycle
shutdown is different: RecoveryCoordinator needs the checkpoint intact
to safely offer the unfinished turn for explicit continuation later.
"""
self._preserve_inflight_turns_on_shutdown = True
async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message: per-session serial, cross-session concurrent."""
session_key = self._effective_session_key(msg)
if session_key != msg.session_key:
msg = dataclasses.replace(msg, session_key_override=session_key)
recovery_task_registered = False
recovery_admission = self._recovery_admission
current_task: asyncio.Task[Any] | None = None
if recovery_admission is not None:
recovery_id = msg.metadata.get(RECOVERY_INBOUND_METADATA_KEY)
if isinstance(recovery_id, str) and recovery_id:
current_task = asyncio.current_task()
if current_task is not None:
recovery_admission.register_recovery_task(session_key, current_task)
recovery_task_registered = True
if not await recovery_admission.admit(msg):
logger.info("Skipped stale recovery for session {}", session_key)
if recovery_task_registered and current_task is not None:
recovery_admission.unregister_recovery_task(session_key, current_task)
return
lock = self._get_session_lock(session_key)
gate = self._concurrency_gate or nullcontext()
@@ -1373,14 +1443,14 @@ class AgentLoop:
session_key,
exc_info=True,
)
# Preserve partial context from the interrupted turn so
# the user does not lose tool results and assistant
# messages accumulated before /stop. The checkpoint was
# already persisted to session metadata by
# _emit_checkpoint during tool execution; materializing
# it into session history now makes it visible in the
# next conversation turn.
if session_key in self._discarding_sessions:
# An explicit turn stop materializes partial context so
# the next prompt can see completed tool results. Gateway
# shutdown keeps the durable checkpoint untouched instead,
# allowing RecoveryCoordinator to offer Continue safely.
if (
session_key in self._discarding_sessions
or self._preserve_inflight_turns_on_shutdown
):
raise
try:
key = self._effective_session_key(msg)
@@ -1437,6 +1507,12 @@ class AgentLoop:
await delivery.idle()
await self._publish_next_deferred_automation_turn(session_key)
finally:
if (
recovery_task_registered
and current_task is not None
and recovery_admission is not None
):
recovery_admission.unregister_recovery_task(session_key, current_task)
if pending is None:
await delivery.idle()
await self._publish_next_deferred_automation_turn(session_key)
@@ -1738,7 +1814,10 @@ class AgentLoop:
if self._restore_runtime_checkpoint(session):
self.sessions.save(session)
if self._restore_pending_user_turn(session):
if (
RECOVERY_INBOUND_METADATA_KEY not in msg.metadata
and restore_pending_interruption(session)
):
self.sessions.save(session)
async def _compact_session(self, ctx: TurnContext) -> None:
@@ -2093,8 +2172,21 @@ class AgentLoop:
if m.get("role") == "tool" and m.get("tool_call_id")
}
last_assistant_idx: int | None = None
saved_followup_ids: set[str] = set()
for m in messages[skip:]:
entry = dict(m)
followup_id_value = cast(object, entry.pop(PENDING_FOLLOWUP_ID_KEY, None))
followup_ids = (
[followup_id_value]
if isinstance(followup_id_value, str)
else [
followup_id
for followup_id in cast(list[object], followup_id_value)
if isinstance(followup_id, str)
]
if isinstance(followup_id_value, list)
else []
)
internal_meta = cast(object, entry.pop("_meta", None))
runtime_context_meta = (
cast(dict[str, Any], internal_meta).get(
@@ -2147,6 +2239,8 @@ class AgentLoop:
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
if role == "user":
saved_followup_ids.update(followup_id for followup_id in followup_ids if followup_id)
if role == "assistant":
last_assistant_idx = len(session.messages) - 1
declared_tool_call_ids.update(
@@ -2161,6 +2255,8 @@ class AgentLoop:
)
if turn_latency_ms is not None and last_assistant_idx is not None:
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
if saved_followup_ids:
acknowledge_pending_followups(session, saved_followup_ids)
session.updated_at = datetime.now()
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
@@ -2195,7 +2291,7 @@ class AgentLoop:
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
"""Persist the latest in-flight turn state into session metadata."""
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
self.sessions.save(session)
self.sessions.save_runtime_checkpoint(session)
def _mark_pending_user_turn(self, session: Session) -> None:
session.metadata[self._PENDING_USER_TURN_KEY] = True
@@ -2207,136 +2303,9 @@ class AgentLoop:
if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None)
@staticmethod
def _checkpoint_message_key(message: dict[str, Any]) -> tuple[Any, ...]:
return (
message.get("role"),
message.get("content"),
message.get("tool_call_id"),
message.get("name"),
message.get("tool_calls"),
message.get("reasoning_content"),
message.get("thinking_blocks"),
)
def _restore_runtime_checkpoint(self, session: Session) -> bool:
"""Materialize an unfinished turn into session history before a new request."""
from datetime import datetime
checkpoint = cast(
object,
session.metadata.get(self._RUNTIME_CHECKPOINT_KEY),
)
if not isinstance(checkpoint, dict):
return False
checkpoint_data = cast(dict[str, Any], checkpoint)
assistant_message = cast(object, checkpoint_data.get("assistant_message"))
completed_tool_results = cast(
Iterable[object],
checkpoint_data.get("completed_tool_results") or [],
)
pending_tool_calls = cast(
Iterable[object],
checkpoint_data.get("pending_tool_calls") or [],
)
restored_messages: list[dict[str, Any]] = []
if isinstance(assistant_message, dict):
restored = dict(cast(dict[str, Any], assistant_message))
restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored)
for message in completed_tool_results:
if isinstance(message, dict):
restored = dict(cast(dict[str, Any], message))
restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored)
for tool_call in pending_tool_calls:
if not isinstance(tool_call, dict):
continue
tool_call_data = cast(dict[str, Any], tool_call)
tool_id = tool_call_data.get("id")
function_data = cast(
dict[str, Any],
tool_call_data.get("function") or {},
)
name = function_data.get("name") or "tool"
restored_messages.append(
{
"role": "tool",
"tool_call_id": tool_id,
"name": name,
"content": "Error: Task interrupted before this tool finished.",
"timestamp": datetime.now().isoformat(),
}
)
overlap = 0
max_overlap = min(len(session.messages), len(restored_messages))
for size in range(max_overlap, 0, -1):
existing = session.messages[-size:]
restored = restored_messages[:size]
if all(
self._checkpoint_message_key(left) == self._checkpoint_message_key(right)
for left, right in zip(existing, restored)
):
overlap = size
break
appended_messages = restored_messages[overlap:]
session.messages.extend(appended_messages)
assistant_message_data = (
cast(dict[str, Any], assistant_message)
if isinstance(assistant_message, dict)
else None
)
provider_state_is_synchronized = (
checkpoint_data.get(self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY)
== self._PROVIDER_STATE_CHECKPOINT_VERSION
)
phase = checkpoint_data.get("phase")
exact_final_response = (
phase == "final_response"
and assistant_message_data is not None
and assistant_message_data.get("role") == "assistant"
and not bool(checkpoint_data.get("completed_tool_results"))
and not bool(checkpoint_data.get("pending_tool_calls"))
)
exact_completed_tools = (
phase == "tools_completed"
and assistant_message_data is not None
and assistant_message_data.get("role") == "assistant"
and not bool(checkpoint_data.get("pending_tool_calls"))
)
if not (
provider_state_is_synchronized
and (exact_final_response or exact_completed_tools)
):
session.provider_state = None
self._clear_pending_user_turn(session)
self._clear_runtime_checkpoint(session)
return True
def _restore_pending_user_turn(self, session: Session) -> bool:
"""Close a turn that only persisted the user message before crashing."""
from datetime import datetime
if not session.metadata.get(self._PENDING_USER_TURN_KEY):
return False
if session.messages and session.messages[-1].get("role") == "user":
session.messages.append(
{
"role": "assistant",
"content": "Error: Task interrupted before a response was generated.",
"timestamp": datetime.now().isoformat(),
}
)
session.provider_state = None
session.updated_at = datetime.now()
self._clear_pending_user_turn(session)
return True
return restore_runtime_checkpoint(session)
async def process_direct(
self,
+18
View File
@@ -37,6 +37,7 @@ from nanobot.runtime_context import (
reattach_runtime_context,
)
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
build_assistant_message,
@@ -234,6 +235,23 @@ class AgentRunner:
merged.get("content"),
injection.get("content"),
)
followup_id = injection.get(PENDING_FOLLOWUP_ID_KEY)
if isinstance(followup_id, str) and followup_id:
existing = cast(object, merged.get(PENDING_FOLLOWUP_ID_KEY))
followup_ids = (
[existing]
if isinstance(existing, str)
else [
item
for item in cast(list[object], existing)
if isinstance(item, str)
]
if isinstance(existing, list)
else []
)
if followup_id not in followup_ids:
followup_ids.append(followup_id)
merged[PENDING_FOLLOWUP_ID_KEY] = followup_ids
messages[-1] = merged
continue
messages.append(injection)
+9
View File
@@ -62,6 +62,15 @@ class TurnEndEvent(OutboundEvent):
context_window_tokens: int | None = None
@dataclass(frozen=True)
class RecoveryStateEvent(OutboundEvent):
status: str
recovery_id: str
reason: str | None = None
attempts: int = 0
can_continue: bool | None = None
@dataclass(frozen=True)
class GoalStatusEvent(OutboundEvent):
status: str
@@ -145,14 +145,9 @@ class _FakeChannel:
class _FakeInteractionResponse:
def __init__(self) -> None:
self.messages: list[dict] = []
self._done = False
async def send_message(self, content: str, *, ephemeral: bool = False) -> None:
self.messages.append({"content": content, "ephemeral": ephemeral})
self._done = True
def is_done(self) -> bool:
return self._done
def _make_interaction(
+11
View File
@@ -104,6 +104,9 @@ class ChannelManager:
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
webui_mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
webui_skill_state_action: Callable[[set[str]], None] | None = None,
webui_recovery_action: (
Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None
) = None,
config_path: Path | None = None,
):
if config_path is None:
@@ -126,6 +129,7 @@ class ChannelManager:
self._webui_mcp_runtime_status = webui_mcp_runtime_status
self._webui_mcp_reload = webui_mcp_reload
self._webui_skill_state_action = webui_skill_state_action
self._webui_recovery_action = webui_recovery_action
self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {}
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
@@ -197,6 +201,7 @@ class ChannelManager:
mcp_runtime_status=self._webui_mcp_runtime_status,
mcp_reload=self._webui_mcp_reload,
skill_state_action=self._webui_skill_state_action,
recovery_action=self._webui_recovery_action,
logger=logger,
)
kwargs["gateway"] = gateway
@@ -615,6 +620,12 @@ class ChannelManager:
if target is None:
logger.warning("Restart notice target channel is not enabled: {}", notice.channel)
return
if notice.channel == "websocket":
# Reconnect and recovery are already represented by WebSocket
# protocol state. A generic restart-complete notice must not
# masquerade as a recovery transition and overwrite a real
# awaiting-user checkpoint in connected clients.
return
while not target.is_running:
remaining = deadline - loop.time()
-3
View File
@@ -53,7 +53,6 @@ class MattermostConfig(Base):
include_thread_context: bool = True
thread_context_limit: int = 20
streaming: bool = True
streaming_max_chars: int = 16000
react_emoji: str = "eyes"
done_emoji: str = "white_check_mark"
send_progress: bool = True
@@ -106,7 +105,6 @@ class MattermostChannel(BaseChannel):
self._ws_task: asyncio.Task[None] | None = None
self._self_id: str | None = None
self._self_username: str | None = None
self._self_email: str | None = None
self._usernames: dict[str, str] = {}
self._user_emails: dict[str, str] = {}
self._channel_types: dict[str, str] = {}
@@ -138,7 +136,6 @@ class MattermostChannel(BaseChannel):
me = cast(dict[str, Any], resp.json())
self._self_id = me.get("id")
self._self_username = me.get("username")
self._self_email = me.get("email", "")
self.logger.info("bot @{} connected", self._self_username)
except Exception as e:
self.logger.error("Failed to identify bot user: {}", e)
@@ -31,8 +31,6 @@ class _FakeHTTPClient:
self.delete_calls: list[dict[str, Any]] = []
self._get_responses: dict[str, Any] = {}
self._post_responses: dict[str, Any] = {}
self._put_responses: dict[str, Any] = {}
self._delete_status: int | None = None
def _req(self, method: str, path: str) -> httpx.Request:
return httpx.Request(method, f"https://chat.example.com{path}")
@@ -46,12 +44,6 @@ class _FakeHTTPClient:
def set_post_response(self, path: str, data: Any) -> None:
self._post_responses[path] = data
def set_put_response(self, path: str, data: Any) -> None:
self._put_responses[path] = data
def set_delete_status(self, status: int) -> None:
self._delete_status = status
async def get(self, path: str, **kwargs) -> httpx.Response:
self.get_calls.append({"path": path, **kwargs})
data = self._get_responses.get(path, {"id": "resp_" + path.split("/")[-1]})
@@ -71,13 +63,11 @@ class _FakeHTTPClient:
async def put(self, path: str, *, json: dict[str, Any] | None = None, **kwargs) -> httpx.Response:
self.put_calls.append({"path": path, "json": json})
data = self._put_responses.get(path, {"id": path.split("/")[-1]})
return self._resp(200, data, "PUT", path)
return self._resp(200, {"id": path.split("/")[-1]}, "PUT", path)
async def delete(self, path: str, **kwargs) -> httpx.Response:
self.delete_calls.append({"path": path})
status = self._delete_status if self._delete_status is not None else 200
return self._resp(status, {}, "DELETE", path)
return self._resp(200, {}, "DELETE", path)
async def aclose(self) -> None:
pass
@@ -119,7 +109,6 @@ def test_config_defaults():
assert config.server_url == ""
assert config.token == ""
assert config.streaming is True
assert config.streaming_max_chars == 16000
assert config.send_tool_hints is True
assert config.dm.enabled is True
assert config.dm.policy == "open"
@@ -150,7 +139,6 @@ def test_config_camelcase_aliases():
"serverUrl": "https://mm.example.com",
"token": "abc123",
"allowFromMatchMode": "username",
"streamingMaxChars": 8000,
"replyInThread": False,
"sendToolHints": False,
}
@@ -158,7 +146,6 @@ def test_config_camelcase_aliases():
assert config.server_url == "https://mm.example.com"
assert config.token == "abc123"
assert config.allow_from_match_mode == "username"
assert config.streaming_max_chars == 8000
assert config.reply_in_thread is False
assert config.send_tool_hints is False
@@ -194,7 +181,6 @@ async def test_start_identifies_bot():
assert channel._self_id == "botuserid123"
assert channel._self_username == "nanobot"
assert channel._self_email == "bot@example.com"
assert not start_task.done()
user_me_calls = [c for c in fake.get_calls[calls_before:] if "/api/v4/users/me" in c["path"]]
assert len(user_me_calls) == 1
@@ -674,7 +660,7 @@ async def test_stream_end_adds_done_emoji():
@pytest.mark.asyncio
async def test_stream_chunk_boundary_finalizes_and_creates_new():
channel, fake = _make_channel({"streamingMaxChars": 10})
channel, fake = _make_channel()
channel._self_id = "bot_id"
fake.set_post_response("/api/v4/posts", {"id": "post_1"})
+4 -4
View File
@@ -277,7 +277,7 @@ class MochatChannel(BaseChannel):
self.config: MochatConfig = config
self._http: httpx.AsyncClient | None = None
self._socket: Any = None
self._ws_connected = self._ws_ready = False
self._ws_ready = False
self._state_dir = get_runtime_subdir("mochat")
self._cursor_path = self._state_dir / "session_cursors.json"
@@ -346,7 +346,7 @@ class MochatChannel(BaseChannel):
if self._http:
await self._http.aclose()
self._http = None
self._ws_connected = self._ws_ready = False
self._ws_ready = False
async def send(self, msg: OutboundMessage) -> None:
"""Send outbound message to session or panel."""
@@ -422,7 +422,7 @@ class MochatChannel(BaseChannel):
)
async def connect() -> None:
self._ws_connected, self._ws_ready = True, False
self._ws_ready = False
self.logger.info("websocket connected")
subscribed = await self._subscribe_all()
self._ws_ready = subscribed
@@ -431,7 +431,7 @@ class MochatChannel(BaseChannel):
async def disconnect() -> None:
if not self._running:
return
self._ws_connected = self._ws_ready = False
self._ws_ready = False
self.logger.warning("websocket disconnected")
await self._ensure_fallback_workers()
@@ -363,13 +363,6 @@ def test_reported_daily_brief_pattern():
# ---------------------------------------------------------------------------
def _resolve_chunk_styles(text: str, max_len: int) -> tuple[list[str], list[list[str]]]:
"""Helper: full markdown → signal pipeline, including chunking."""
plain, styles = _markdown_to_signal(text)
chunks = split_message(plain, max_len) if plain else [""]
return chunks, _partition_styles(plain, chunks, styles)
def test_partition_styles_single_chunk_passthrough():
plain, styles = _markdown_to_signal("**bold** plain *it*")
parts = _partition_styles(plain, [plain], styles)
-1
View File
@@ -69,7 +69,6 @@ class SlackConfig(Base):
webhook_path: str = "/slack/events"
bot_token: str = ""
app_token: str = ""
user_token_read_only: bool = True
reply_in_thread: bool = True
react_emoji: str = "eyes"
done_emoji: str = "white_check_mark"
+30
View File
@@ -32,6 +32,7 @@ from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
ProgressEvent,
RecoveryStateEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
@@ -55,6 +56,7 @@ from nanobot.security.workspace_access import (
)
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.model_selection import model_preset_from_metadata
from nanobot.session.recovery import recovery_state_from_metadata
from nanobot.session.webui_turns import (
clear_websocket_turn_if_current,
clear_websocket_turns,
@@ -453,6 +455,9 @@ class WebSocketChannel(BaseChannel):
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
fields["model_preset"] = None
if isinstance(metadata, dict):
recovery_state = recovery_state_from_metadata(metadata)
if recovery_state is not None:
fields["recovery_state"] = recovery_state
usage = metadata.get("_last_usage")
if isinstance(usage, dict):
sanitized_usage: dict[str, int | float] = {}
@@ -1740,6 +1745,10 @@ class WebSocketChannel(BaseChannel):
provenance=event.provenance,
)
return
if isinstance(event, RecoveryStateEvent):
if conns:
await self.send_recovery_state(msg.chat_id, event)
return
if isinstance(event, GoalStateSyncEvent):
if conns:
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
@@ -2057,6 +2066,27 @@ class WebSocketChannel(BaseChannel):
for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_end ")
async def send_recovery_state(
self,
chat_id: str,
event: RecoveryStateEvent,
) -> None:
"""Publish one structured recovery transition without chat pollution."""
body: dict[str, Any] = {
"event": "recovery_state",
"chat_id": chat_id,
"status": event.status,
"recovery_id": event.recovery_id,
"attempts": event.attempts,
}
if event.reason:
body["reason"] = event.reason
if event.can_continue is not None:
body["can_continue"] = event.can_continue
raw = json.dumps(body, ensure_ascii=False)
for connection in list(self._subs.get(chat_id, ())):
await self._safe_send_to(connection, raw, label=" recovery_state ")
async def send_goal_state(self, chat_id: str, blob: dict[str, Any]) -> None:
"""Push persisted goal-state snapshot for *chat_id* (multi-chat isolation)."""
conns = list(self._subs.get(chat_id, ()))
@@ -27,6 +27,7 @@ from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
ProgressEvent,
RecoveryStateEvent,
RuntimeModelUpdatedEvent,
SessionUpdatedEvent,
TurnEndEvent,
@@ -2720,6 +2721,39 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
]
@pytest.mark.asyncio
async def test_recovery_state_is_a_structured_event_not_assistant_text() -> None:
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
event=RecoveryStateEvent(
status="awaiting_user",
recovery_id="recovery-1",
reason="tool_state_unknown",
attempts=1,
),
))
assert _sent_ws_payloads(mock_ws) == [{
"event": "recovery_state",
"chat_id": "chat-1",
"status": "awaiting_user",
"recovery_id": "recovery-1",
"reason": "tool_state_unknown",
"attempts": 1,
}]
@pytest.mark.asyncio
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
bus = MagicMock()
@@ -83,6 +83,7 @@ def _make_handler(
channel_feature_action: Any | None = None,
channel_runtime_status: Any | None = None,
mcp_reload: Any | None = None,
recovery_action: Any | None = None,
) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd()
@@ -103,6 +104,7 @@ def _make_handler(
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_reload=mcp_reload,
recovery_action=recovery_action,
)
@@ -121,6 +123,7 @@ def _ch(
channel_feature_action: Any | None = None,
channel_runtime_status: Any | None = None,
mcp_reload: Any | None = None,
recovery_action: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -145,6 +148,7 @@ def _ch(
channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status,
mcp_reload=mcp_reload,
recovery_action=recovery_action,
)
return InProcessHttpChannel(cfg, bus, gateway=gateway)
@@ -1244,39 +1248,6 @@ async def test_pairing_routes_require_token_and_approve_or_deny(
assert "Missing pairing code" in missing_code.text
def test_api_service_settings_read_api_key_from_webui_payload(bus: MagicMock) -> None:
channel = _ch(bus)
request = _FakeReq(path="/api/settings/api-service/start")
setattr(
request,
"_nanobot_webui_mutation_payload",
{"host": "0.0.0.0", "port": 8900, "timeout": 120, "api_key": "secret-token"},
)
query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
assert query == {
"host": ["0.0.0.0"],
"port": ["8900"],
"timeout": ["120"],
"api_key": ["secret-token"],
}
def test_api_service_settings_reject_non_string_api_key(bus: MagicMock) -> None:
from nanobot.webui.settings_api import WebUISettingsError
channel = _ch(bus)
request = _FakeReq(path="/api/settings/api-service/start")
setattr(
request,
"_nanobot_webui_mutation_payload",
{"host": "127.0.0.1", "api_key": 123},
)
with pytest.raises(WebUISettingsError, match="API key must be a string"):
channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
@pytest.mark.asyncio
async def test_nanobot_feature_remote_install_requires_opt_in(
bus: MagicMock,
@@ -3275,6 +3246,28 @@ async def _webui_mutate(
)
@pytest.mark.asyncio
async def test_recovery_mutation_uses_authenticated_websocket_action(bus: MagicMock) -> None:
recovery_action = AsyncMock(return_value={
"status": "resuming",
"recovery_id": "recovery-1",
})
channel = _ch(bus, recovery_action=recovery_action)
response = await _webui_mutate(
channel,
"recovery.continue",
{"chat_id": "chat-1", "recovery_id": "recovery-1"},
)
assert response.status_code == 200
assert response.json()["status"] == "resuming"
recovery_action.assert_awaited_once_with(
"continue",
{"chat_id": "chat-1", "recovery_id": "recovery-1"},
)
@pytest.mark.asyncio
async def test_workspace_folder_picker_is_local_authenticated_mutation(
bus: MagicMock,
@@ -202,12 +202,6 @@ class WsTestClient:
assert msg.event == "delta", f"Expected 'delta' event, got '{msg.event}'"
return msg
async def recv_stream_end(self, timeout: float = 10.0) -> WsMessage:
"""Receive and validate a 'stream_end' event."""
msg = await self.recv(timeout)
assert msg.event == "stream_end", f"Expected 'stream_end' event, got '{msg.event}'"
return msg
async def collect_stream(self, timeout: float = 10.0) -> list[WsMessage]:
"""Collect all deltas and the final stream_end into a list."""
messages: list[WsMessage] = []
@@ -232,10 +226,6 @@ class WsTestClient:
"""Send a JSON frame."""
await self.ws.send(json.dumps(data, ensure_ascii=False))
async def send_content(self, content: str) -> None:
"""Send content in the preferred JSON format ``{"content": ...}``."""
await self.send_json({"content": content})
# -- Connection introspection -----------------------------------------
@property
+7 -1
View File
@@ -56,6 +56,7 @@ from nanobot.cli.agent import agent # noqa: E402
from nanobot.cli.gateway import create_gateway_app # noqa: E402
from nanobot.cli.gateway_runtime import _run_gateway # noqa: E402
from nanobot.cli.log_control import _set_nanobot_logs # noqa: E402
from nanobot.cli.process_identity import set_cli_process_identity # noqa: E402
from nanobot.cli.provider import provider_app # noqa: E402
from nanobot.cli.runtime_config import ( # noqa: E402
_load_inspection_config,
@@ -99,12 +100,17 @@ def version_callback(value: bool):
@app.callback()
def main(
ctx: typer.Context,
version: bool = typer.Option(
None, "--version", "-v", callback=version_callback, is_eager=True
),
):
"""nanobot - Personal AI Assistant."""
pass
# Editable/source installs can retain an older generated console script that
# imports this Typer app directly instead of ``nanobot.cli.entry``. Keep the
# role identity correct until that launcher is regenerated.
command = ctx.invoked_subcommand
set_cli_process_identity([command] if command else sys.argv[1:])
# ============================================================================
+3
View File
@@ -6,6 +6,8 @@ import os
import sys
from contextlib import suppress
from nanobot.cli.process_identity import set_cli_process_identity
def _native_tui_candidate(args: list[str]) -> bool:
"""Return whether ``agent`` can start without the classic agent stack."""
@@ -34,6 +36,7 @@ def _configure_windows_console() -> None:
def main() -> None:
"""Dispatch native TUI startup without importing the complete CLI graph."""
set_cli_process_identity(sys.argv[1:])
_configure_windows_console()
if _native_tui_candidate(sys.argv[1:]):
import typer
+24
View File
@@ -322,6 +322,7 @@ def _run_gateway(
from nanobot.providers.fallback_provider import FallbackProvider
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
from nanobot.session.recovery import RecoveryCoordinator
from nanobot.session.webui_turns import (
WebuiTurnCoordinator,
WebuiTurnRoutePolicy,
@@ -422,6 +423,12 @@ def _run_gateway(
tools = ToolRegistry()
mcp_provider = MCPProvider.from_config(config, tools)
recovery = RecoveryCoordinator(
sessions=session_manager,
bus=bus,
unified_session=config.agents.defaults.unified_session,
)
# Create agent with cron service
agent = AgentLoop.from_config(
config, bus,
@@ -440,6 +447,7 @@ def _run_gateway(
local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook],
tool_registry=tools,
recovery_admission=recovery,
)
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
@@ -448,6 +456,7 @@ def _run_gateway(
bus=bus,
sessions=session_manager,
schedule_background=_schedule_webui_background,
recovery=recovery,
)
webui_turn_coordinator.subscribe(runtime_events)
from nanobot.bus.events import OutboundMessage
@@ -683,6 +692,7 @@ def _run_gateway(
webui_mcp_runtime_status=mcp_provider.runtime_status,
webui_mcp_reload=mcp_provider.reload,
webui_skill_state_action=_webui_skill_state_action,
webui_recovery_action=recovery.handle_action,
config_path=Path(config_path),
)
@@ -849,6 +859,7 @@ def _run_gateway(
tasks: list[asyncio.Task[Any]] = []
shutdown_task: asyncio.Task[Any] | None = None
runtime_tasks: asyncio.Future[list[Any]] | None = None
startup_complete = False
shutdown_event = asyncio.Event()
cli_terminal._ensure_interactive_tty_mode()
restore_shutdown_handlers = _install_gateway_shutdown_handlers(
@@ -861,6 +872,10 @@ def _run_gateway(
await cron.start()
# Re-read once on first admission to close the watcher subscription window.
agent.runtime_resolver.invalidate()
# Recovery must finish before WebSocket and other channels begin
# accepting new input. That makes a new user message reliably
# supersede an old recoverable turn instead of racing its queue.
await recovery.scan()
async def _run_agent() -> None:
try:
await mcp_provider.connect()
@@ -915,6 +930,7 @@ def _run_gateway(
name="nanobot-webui-dev-server",
))
runtime_tasks = asyncio.gather(*tasks)
startup_complete = True
shutdown_task = asyncio.create_task(
shutdown_event.wait(),
name="nanobot-gateway-shutdown",
@@ -936,6 +952,10 @@ def _run_gateway(
console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
console.print(traceback.format_exc())
if not startup_complete:
# Do not report a successful gateway command when startup
# failed before any runtime task or listener was created.
raise typer.Exit(1)
finally:
try:
if shutdown_task and not shutdown_task.done():
@@ -943,6 +963,10 @@ def _run_gateway(
with suppress(asyncio.CancelledError):
await shutdown_task
cron.stop()
# A gateway exit interrupts ownership of active turns; it is
# not the same as the user stopping a turn. Keep checkpoints
# so the next gateway can offer an explicit Continue action.
agent.preserve_inflight_turns_on_shutdown()
agent.stop()
# Cancel runtime tasks first, then deterministically close
# exec/MCP resources while the event loop is still alive.
+52
View File
@@ -0,0 +1,52 @@
"""Give nanobot processes recognizable operating-system names."""
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from typing import Final
_ROLES: Final = {"agent", "gateway", "webui"}
def _set_process_title(title: str) -> None:
# Process titles are short; do not trade Linux /proc environment visibility for
# extra title storage. setproctitle reads this switch when it is imported.
os.environ.setdefault("SPT_NOENV", "1")
from setproctitle import setproctitle
setproctitle(title)
def set_cli_process_identity(args: list[str]) -> None:
"""Name this CLI process after the nanobot role it is running."""
if os.name == "nt":
# Windows process managers use the console launcher's executable name,
# which packaging already generates as ``nanobot.exe``.
return
role = args[0] if args and args[0] in _ROLES else None
_set_process_title(f"nanobot-{role}" if role else "nanobot")
def named_executable(executable: str, *, name: str, directory: Path) -> str:
"""Return a stable POSIX symlink whose basename identifies a child process."""
if os.name == "nt":
return executable
try:
target = Path(executable).resolve(strict=True)
digest = hashlib.sha256(os.fsencode(target)).hexdigest()[:12]
link_dir = directory / digest
link = link_dir / name
link_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
if link.is_symlink() and link.resolve(strict=False) == target:
return str(link)
if link.exists():
return executable
pending = link.with_name(f".{name}.{os.getpid()}")
pending.unlink(missing_ok=True)
pending.symlink_to(target)
os.replace(pending, link)
except OSError:
return executable
return str(link)
+7 -1
View File
@@ -17,6 +17,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from nanobot import __version__
from nanobot.cli.process_identity import named_executable
from nanobot.cli.runtime_config import _model_display
from nanobot.cli.webui_support import (
_gateway_health_ready,
@@ -229,7 +230,12 @@ def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
detail = (install.stderr or install.stdout).strip().splitlines()
suffix = f": {detail[-1]}" if detail else ""
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
return [bun, str(source_dir / "src" / "index.ts")]
executable = named_executable(
bun,
name="nanobot-tui",
directory=get_data_dir() / "run" / "executables",
)
return [executable, str(source_dir / "src" / "index.ts")]
def _download_release_tui(asset: str) -> Path | None:
-16
View File
@@ -96,22 +96,6 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
# it; poll() both reaps it and reports the real lifecycle state.
self._owned_process: Any | None = None
@classmethod
def refresh_state_pid(cls, *, paths: ProcessRuntimePaths) -> None:
"""Update a managed state file after the recorded process restarts."""
if not paths.state_path.exists():
return
try:
state = json.loads(paths.state_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
state["pid"] = os.getpid()
runtime = cls(paths=paths)
state.pop("stable_identity", None)
state.update(runtime.process_identity_record(os.getpid()))
state["started_at"] = _utc_now()
runtime._write_state(state)
def start_background(self, options: _StartOptionsT) -> ProcessResult:
"""Start the configured command as a detached process."""
with self._lifecycle_lock():
+135 -2
View File
@@ -48,15 +48,21 @@ _SESSION_PREVIEW_MAX_CHARS = 120
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
_RUNTIME_CHECKPOINT_DATA_ERRORS = (OSError, *_SESSION_DATA_ERRORS)
_PROVIDER_STATE_RECORD_TYPE = "provider_state"
_PROVIDER_STATE_RECORD_PREFIX_RE = re.compile(
r'^\s*\{\s*"_type"\s*:\s*"provider_state"\s*(?:,|\})'
)
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_RUNTIME_CHECKPOINT_VERSION = 1
_RUNTIME_CHECKPOINT_SUFFIX = ".checkpoint.json"
_FORK_VOLATILE_METADATA_KEYS = {
"goal_state",
"pending_user_turn",
"pending_user_followups",
"runtime_checkpoint",
"session_handle",
"webui_recovery",
"thread_goal",
"title",
"title_user_edited",
@@ -1001,6 +1007,9 @@ class JsonlSessionStore:
def get_session_path(self, key: str) -> Path:
return self.sessions_dir / f"{self.storage_key(key)}.jsonl"
def get_runtime_checkpoint_path(self, key: str) -> Path:
return self.sessions_dir / f"{self.storage_key(key)}{_RUNTIME_CHECKPOINT_SUFFIX}"
def get_legacy_lossy_path(self, key: str) -> Path:
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
@@ -1066,7 +1075,7 @@ class JsonlSessionStore:
else:
messages.append(data)
return Session(
session = Session(
key=key,
messages=messages,
created_at=created_at or datetime.now(),
@@ -1075,6 +1084,8 @@ class JsonlSessionStore:
last_consolidated=last_consolidated,
provider_state=provider_state,
)
self._overlay_runtime_checkpoint_unlocked(session, path)
return session
except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to load session {}: {}", key, e)
repaired = self._repair_unlocked(key)
@@ -1159,7 +1170,7 @@ class JsonlSessionStore:
if not messages and not metadata and provider_state is None:
return None
return Session(
session = Session(
key=key,
messages=messages,
created_at=created_at or datetime.now(),
@@ -1168,6 +1179,8 @@ class JsonlSessionStore:
last_consolidated=last_consolidated,
provider_state=provider_state,
)
self._overlay_runtime_checkpoint_unlocked(session, path)
return session
except _SESSION_DATA_ERRORS as e:
logger.warning("Repair failed for session {}: {}", key, e)
return None
@@ -1186,6 +1199,105 @@ class JsonlSessionStore:
with self._session_files_lock:
self._save_unlocked(session, fsync=fsync)
def save_runtime_checkpoint(self, session: Session) -> None:
"""Atomically persist only the volatile in-flight turn state.
A checkpoint is written several times during a tool-heavy turn. Keeping it
beside the append history avoids copying the full transcript at each safe
recovery boundary.
"""
with self._session_files_lock:
path = self.get_session_path(session.key)
if not path.exists():
# A user turn normally creates the session first. Internal callers
# may checkpoint a fresh session, so establish the durable base once.
self._save_unlocked(session)
return
checkpoint = session.metadata.get(_RUNTIME_CHECKPOINT_KEY)
if not isinstance(checkpoint, dict):
self.get_runtime_checkpoint_path(session.key).unlink(missing_ok=True)
return
payload: dict[str, Any] = {
"version": _RUNTIME_CHECKPOINT_VERSION,
"session_key": session.key,
"base_updated_at": session.updated_at.isoformat(),
"base_message_count": len(session.messages),
"checkpoint": checkpoint,
"provider_state": (
session.provider_state.to_private_record()
if session.provider_state is not None
else None
),
}
target = self.get_runtime_checkpoint_path(session.key)
tmp = target.with_name(f".{target.name}.{secrets.token_hex(8)}.tmp")
try:
with open(tmp, "x", encoding="utf-8") as handle:
os.chmod(tmp, 0o600)
json.dump(
payload,
handle,
ensure_ascii=False,
separators=(",", ":"),
)
os.replace(tmp, target)
finally:
tmp.unlink(missing_ok=True)
def _overlay_runtime_checkpoint_unlocked(self, session: Session, main_path: Path) -> None:
checkpoint_path = self.get_runtime_checkpoint_path(session.key)
try:
checkpoint_stat = checkpoint_path.lstat()
if not stat.S_ISREG(checkpoint_stat.st_mode):
logger.warning(
"Ignoring non-regular runtime checkpoint for session {}",
session.key,
)
return
# A complete session save supersedes an older sidecar. This comparison
# closes the small crash window between replacing the JSONL and unlinking
# its previous checkpoint.
if main_path.stat().st_mtime_ns > checkpoint_stat.st_mtime_ns:
checkpoint_path.unlink(missing_ok=True)
return
raw = _json_object(json.loads(checkpoint_path.read_text(encoding="utf-8")))
if (
raw.get("version") != _RUNTIME_CHECKPOINT_VERSION
or raw.get("session_key") != session.key
or raw.get("base_updated_at") != session.updated_at.isoformat()
or raw.get("base_message_count") != len(session.messages)
or not isinstance(raw.get("checkpoint"), dict)
):
checkpoint_path.unlink(missing_ok=True)
return
provider_record = raw.get("provider_state")
provider_state = (
None
if provider_record is None
else ProviderConversationState.from_private_record(provider_record)
)
if provider_record is not None and provider_state is None:
raise ValueError("invalid checkpoint provider state")
session.metadata[_RUNTIME_CHECKPOINT_KEY] = cast(
dict[str, Any], raw["checkpoint"]
)
session.provider_state = provider_state
except FileNotFoundError:
return
except _RUNTIME_CHECKPOINT_DATA_ERRORS as exc:
logger.warning(
"Ignoring invalid runtime checkpoint for session {}: {}",
session.key,
exc,
)
# Atomic writes mean a malformed target cannot become valid later.
# Remove it once so future loads do not repeatedly parse and log it.
with suppress(OSError):
if checkpoint_path.is_file() and not checkpoint_path.is_symlink():
checkpoint_path.unlink()
def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
path = self.get_session_path(session.key)
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
@@ -1215,6 +1327,10 @@ class JsonlSessionStore:
os.replace(tmp_path, path)
# The full record now contains the authoritative checkpoint state (or
# its removal), so an older volatile overlay is no longer needed.
self.get_runtime_checkpoint_path(session.key).unlink(missing_ok=True)
if fsync:
with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY)
@@ -1278,6 +1394,7 @@ class JsonlSessionStore:
def _delete_unlocked(self, key: str) -> bool:
paths = [
self.get_session_path(key),
self.get_runtime_checkpoint_path(key),
self.get_legacy_lossy_path(key),
self.get_legacy_session_path(key),
]
@@ -1585,6 +1702,10 @@ class SessionManager:
"""Get the collision-resistant workspace path for a session."""
return self._jsonl_store.get_session_path(key)
def _get_runtime_checkpoint_path(self, key: str) -> Path:
"""Get the private in-flight checkpoint path for a session."""
return self._jsonl_store.get_runtime_checkpoint_path(key)
def _get_legacy_lossy_path(self, key: str) -> Path:
"""Previous workspace session path using lossy ':' to '_' replacement."""
return self._jsonl_store.get_legacy_lossy_path(key)
@@ -1653,6 +1774,18 @@ class SessionManager:
self._store.save(session, fsync=fsync)
self._remember(session)
def save_runtime_checkpoint(self, session: Session) -> None:
"""Persist volatile recovery state without rewriting long history."""
if not session.policy.persist:
return
if self._store is self._jsonl_store:
self._jsonl_store.save_runtime_checkpoint(session)
self._remember(session)
return
# Third-party stores keep their existing all-or-nothing semantics until
# they opt into a dedicated checkpoint primitive.
self.save(session)
def rename_model_preset(self, old_name: str, new_name: str) -> int:
"""Rename a session-scoped model preset across durable and live sessions."""
if old_name == new_name:
+939
View File
@@ -0,0 +1,939 @@
"""Durable, side-effect-safe recovery for interrupted WebUI turns.
The coordinator owns restart policy. AgentLoop only exposes checkpoint
materialization and an admission hook, so transport code never has to guess
whether an interrupted tool call is safe to replay.
"""
from __future__ import annotations
import asyncio
import dataclasses
import json
from collections.abc import Iterable, Mapping
from datetime import datetime
from typing import Any, Protocol, cast
from uuid import uuid4
from loguru import logger
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import (
RecoveryStateEvent,
SessionUpdatedEvent,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.session import turn_continuation
from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
PENDING_USER_TURN_KEY = "pending_user_turn"
RECOVERY_METADATA_KEY = "webui_recovery"
RECOVERY_INBOUND_METADATA_KEY = "_webui_recovery_id"
PENDING_FOLLOWUPS_KEY = "pending_user_followups"
PENDING_FOLLOWUP_ID_KEY = "_recovery_followup_id"
PROVIDER_STATE_CHECKPOINT_VERSION_KEY = "provider_state_checkpoint_version"
PROVIDER_STATE_CHECKPOINT_VERSION = "v1"
_RECOVERY_STATUSES = frozenset({"resuming", "awaiting_user", "recovered", "failed"})
_UNCERTAIN_TOOL_PHASES = frozenset({"awaiting_tools"})
_KNOWN_CHECKPOINT_PHASES = frozenset(
{"final_response", "tools_completed", "awaiting_tools", "error"}
)
class RecoveryActionError(ValueError):
"""A stale or malformed recovery action from an authenticated WebUI."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.status = status
class RecoveryAdmission(Protocol):
"""Narrow AgentLoop boundary for explicit recovery validation."""
async def admit(self, message: InboundMessage) -> bool: ...
def register_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None: ...
def unregister_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None: ...
def record_pending_followup(session: Session, message: InboundMessage) -> str | None:
"""Durably journal a WebUI follow-up before injecting it into a live turn."""
if message.channel != "websocket":
return None
try:
metadata_value: object = json.loads(json.dumps(message.metadata))
except (TypeError, ValueError):
logger.warning("Skipping non-serializable WebUI follow-up for recovery")
return None
if not isinstance(metadata_value, dict):
return None
metadata = cast(dict[str, Any], metadata_value)
existing_id = metadata.pop(PENDING_FOLLOWUP_ID_KEY, None)
followup_id = (
existing_id
if isinstance(existing_id, str) and existing_id
else uuid4().hex
)
records = _pending_followup_records(session)
if any(record.get("id") == followup_id for record in records):
return followup_id
records.append(
{
"id": followup_id,
"sender_id": message.sender_id,
"chat_id": message.chat_id,
"content": message.content,
"media": list(message.media or []),
"metadata": metadata,
}
)
# This journal is the recovery source of truth, not a mirror of the
# bounded in-memory injection queue. A queued turn can receive more
# follow-ups than the live queue accepts; dropping older journal entries
# would make those acknowledged user messages unrecoverable after a
# gateway restart. Entries are removed only once their user rows are
# committed by ``acknowledge_pending_followups``.
session.metadata[PENDING_FOLLOWUPS_KEY] = records
session.updated_at = datetime.now()
return followup_id
def pending_followups(session: Session) -> list[InboundMessage]:
"""Decode still-unacknowledged follow-ups from durable session metadata."""
messages: list[InboundMessage] = []
for record in _pending_followup_records(session):
followup_id = cast(object, record.get("id"))
sender_id = cast(object, record.get("sender_id"))
chat_id = cast(object, record.get("chat_id"))
content = cast(object, record.get("content"))
metadata = cast(object, record.get("metadata"))
if (
not isinstance(followup_id, str)
or not followup_id
or not isinstance(sender_id, str)
or not sender_id
or not isinstance(chat_id, str)
or not chat_id
):
continue
if not isinstance(content, str) or not isinstance(metadata, dict):
continue
media_value = cast(object, record.get("media"))
media = (
[item for item in cast(list[object], media_value) if isinstance(item, str)]
if isinstance(media_value, list)
else []
)
messages.append(
InboundMessage(
channel="websocket",
sender_id=sender_id,
chat_id=chat_id,
content=content,
media=media,
metadata={**cast(dict[str, Any], metadata), PENDING_FOLLOWUP_ID_KEY: followup_id},
session_key_override=session.key,
require_existing_session=True,
)
)
return messages
def acknowledge_pending_followups(session: Session, followup_ids: Iterable[str]) -> None:
"""Remove journal entries whose user rows were committed to history."""
acknowledged = set(followup_ids)
if not acknowledged:
return
records = [record for record in _pending_followup_records(session) if record.get("id") not in acknowledged]
if records:
session.metadata[PENDING_FOLLOWUPS_KEY] = records
else:
session.metadata.pop(PENDING_FOLLOWUPS_KEY, None)
def _pending_followup_records(session: Session) -> list[dict[str, Any]]:
raw = cast(object, session.metadata.get(PENDING_FOLLOWUPS_KEY))
if not isinstance(raw, list):
return []
values = cast(list[object], raw)
return [cast(dict[str, Any], value) for value in values if isinstance(value, dict)]
def _checkpoint_message_key(message: Mapping[str, Any]) -> tuple[Any, ...]:
return (
message.get("role"),
message.get("content"),
message.get("tool_call_id"),
message.get("name"),
message.get("tool_calls"),
message.get("reasoning_content"),
message.get("thinking_blocks"),
)
def _checkpoint_tool_call_ids(
value: object,
*,
result_rows: bool = False,
) -> list[str] | None:
"""Validate checkpoint tool rows and return their stable IDs."""
if not isinstance(value, list):
return None
ids: list[str] = []
for raw in cast(list[object], value):
if not isinstance(raw, dict):
return None
row = cast(dict[str, Any], raw)
id_key = "tool_call_id" if result_rows else "id"
call_id = cast(object, row.get(id_key))
if not isinstance(call_id, str) or not call_id:
return None
if result_rows:
if row.get("role") != "tool":
return None
else:
function_value = cast(object, row.get("function"))
if not isinstance(function_value, dict):
return None
function = cast(dict[str, Any], function_value)
name = cast(object, function.get("name"))
if not isinstance(name, str) or not name:
return None
ids.append(call_id)
return ids if len(ids) == len(set(ids)) else None
def _runtime_checkpoint_is_well_formed(checkpoint: Mapping[str, Any]) -> bool:
"""Return whether a checkpoint is safe to offer for continuation.
Restoration stays tolerant so Dismiss can always clear corrupt state.
Continue is stricter: silently dropping a malformed tool result could make
the model repeat an external side effect.
"""
assistant_value = cast(object, checkpoint.get("assistant_message"))
if not isinstance(assistant_value, dict):
return False
assistant = cast(dict[str, Any], assistant_value)
if assistant.get("role") != "assistant":
return False
completed_ids = _checkpoint_tool_call_ids(
cast(object, checkpoint.get("completed_tool_results")),
result_rows=True,
)
pending_ids = _checkpoint_tool_call_ids(
cast(object, checkpoint.get("pending_tool_calls")),
)
if completed_ids is None or pending_ids is None:
return False
assistant_calls_value = cast(object, assistant.get("tool_calls"))
assistant_call_ids = (
[]
if assistant_calls_value is None
else _checkpoint_tool_call_ids(assistant_calls_value)
)
if assistant_call_ids is None:
return False
phase = checkpoint.get("phase")
if phase == "final_response":
content = cast(object, assistant.get("content"))
return (
isinstance(content, str)
and bool(content.strip())
and not assistant_call_ids
and not completed_ids
and not pending_ids
)
if phase == "awaiting_tools":
return (
bool(assistant_call_ids)
and not completed_ids
and len(assistant_call_ids) == len(pending_ids)
and set(assistant_call_ids) == set(pending_ids)
)
if phase == "tools_completed":
return (
bool(assistant_call_ids)
and not pending_ids
and len(assistant_call_ids) == len(completed_ids)
and set(assistant_call_ids) == set(completed_ids)
)
# Error checkpoints have no current producer contract. Treat legacy or
# future instances as review-only until their exact persisted shape is
# specified; guessing here could make a partial side effect repeat.
return False
def restore_runtime_checkpoint(session: Session) -> bool:
"""Materialize the durable checkpoint exactly once and clear it.
Pending tool calls become explicit interrupted tool results. They are
never executed here. Provider-native state is retained only for the two
checkpoint shapes known to be synchronized with persisted history.
"""
checkpoint = cast(object, session.metadata.get(RUNTIME_CHECKPOINT_KEY))
if not isinstance(checkpoint, dict):
return False
data = cast(dict[str, Any], checkpoint)
assistant = cast(object, data.get("assistant_message"))
completed_value = cast(object, data.get("completed_tool_results"))
pending_value = cast(object, data.get("pending_tool_calls"))
completed = cast(list[object], completed_value) if isinstance(completed_value, list) else []
pending = cast(list[object], pending_value) if isinstance(pending_value, list) else []
restored: list[dict[str, Any]] = []
if isinstance(assistant, dict):
assistant_row = cast(dict[str, Any], assistant)
else:
assistant_row = {}
if assistant_row.get("role") == "assistant":
row = dict(assistant_row)
row.setdefault("timestamp", datetime.now().isoformat())
restored.append(row)
for value in completed:
if not isinstance(value, dict):
continue
tool_result = cast(dict[str, Any], value)
if tool_result.get("role") != "tool":
continue
row = dict(tool_result)
row.setdefault("timestamp", datetime.now().isoformat())
restored.append(row)
for value in pending:
if not isinstance(value, dict):
continue
tool_call = cast(dict[str, Any], value)
tool_call_id = tool_call.get("id")
function_value = cast(object, tool_call.get("function"))
if not isinstance(tool_call_id, str) or not tool_call_id:
continue
function = (
cast(dict[str, Any], function_value)
if isinstance(function_value, dict)
else {}
)
name = function.get("name")
restored.append(
{
"role": "tool",
"tool_call_id": tool_call_id,
"name": name if isinstance(name, str) and name else "tool",
"content": "Error: Task interrupted before this tool finished.",
"timestamp": datetime.now().isoformat(),
"_recovery_interrupted": True,
}
)
overlap = 0
for size in range(min(len(session.messages), len(restored)), 0, -1):
if all(
_checkpoint_message_key(left) == _checkpoint_message_key(right)
for left, right in zip(session.messages[-size:], restored[:size])
):
overlap = size
break
session.messages.extend(restored[overlap:])
assistant_data = cast(dict[str, Any], assistant) if isinstance(assistant, dict) else None
synchronized = (
data.get(PROVIDER_STATE_CHECKPOINT_VERSION_KEY)
== PROVIDER_STATE_CHECKPOINT_VERSION
)
phase = data.get("phase")
exact_final = (
phase == "final_response"
and assistant_data is not None
and assistant_data.get("role") == "assistant"
and not data.get("completed_tool_results")
and not data.get("pending_tool_calls")
)
exact_tools = (
phase == "tools_completed"
and assistant_data is not None
and assistant_data.get("role") == "assistant"
and not data.get("pending_tool_calls")
)
if not (synchronized and (exact_final or exact_tools)):
session.provider_state = None
session.metadata.pop(PENDING_USER_TURN_KEY, None)
session.metadata.pop(RUNTIME_CHECKPOINT_KEY, None)
session.updated_at = datetime.now()
return True
def _discard_runtime_checkpoint(session: Session) -> bool:
"""Drop checkpoint state that cannot be projected into valid history."""
if RUNTIME_CHECKPOINT_KEY not in session.metadata:
return False
session.metadata.pop(RUNTIME_CHECKPOINT_KEY, None)
session.provider_state = None
session.updated_at = datetime.now()
return True
def restore_pending_interruption(session: Session, *, superseded: bool = False) -> bool:
"""Close a persisted user-only turn without pretending it was answered."""
if not session.metadata.get(PENDING_USER_TURN_KEY):
return False
if session.messages and session.messages[-1].get("role") == "user":
content = (
"Task recovery was superseded by a newer message."
if superseded
else "Error: Task interrupted before a response was generated."
)
session.messages.append(
{
"role": "assistant",
"content": content,
"timestamp": datetime.now().isoformat(),
"_recovery_interrupted": True,
}
)
session.provider_state = None
session.updated_at = datetime.now()
session.metadata.pop(PENDING_USER_TURN_KEY, None)
return True
def append_recovery_interruption(session: Session, *, superseded: bool = False) -> None:
"""Close a restored partial turn whose last durable row is not the user message."""
if session.messages and session.messages[-1].get("_recovery_interrupted") is True:
return
session.messages.append(
{
"role": "assistant",
"content": (
"Task recovery was superseded by a newer message."
if superseded
else "Error: Task recovery was interrupted before completion."
),
"timestamp": datetime.now().isoformat(),
"_recovery_interrupted": True,
}
)
session.provider_state = None
session.updated_at = datetime.now()
def recovery_state_from_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return a sanitized recovery state suitable for the WebSocket wire."""
value = metadata.get(RECOVERY_METADATA_KEY) if metadata else None
if not isinstance(value, dict):
return None
state = cast(dict[str, Any], value)
status = state.get("status")
recovery_id = state.get("recovery_id")
if status not in _RECOVERY_STATUSES or not isinstance(recovery_id, str):
return None
payload: dict[str, Any] = {"status": status, "recovery_id": recovery_id}
reason = state.get("reason")
if isinstance(reason, str) and reason:
payload["reason"] = reason
attempts = state.get("attempts")
if isinstance(attempts, int) and attempts >= 0:
payload["attempts"] = attempts
can_continue = state.get("can_continue")
if isinstance(can_continue, bool):
payload["can_continue"] = can_continue
return payload
@dataclasses.dataclass(slots=True)
class RecoveryCoordinator:
"""Classify, announce, and gate durable WebUI turn recovery."""
sessions: SessionManager
bus: MessageBus
unified_session: bool = False
_active_recovery_tasks: dict[str, asyncio.Task[Any]] = dataclasses.field(
default_factory=dict,
init=False,
repr=False,
)
def register_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None:
"""Track the task that owns an explicit recovery continuation."""
self._active_recovery_tasks[session_key] = task
def unregister_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None:
"""Drop a recovery task without removing a newer task for the same session."""
if self._active_recovery_tasks.get(session_key) is task:
self._active_recovery_tasks.pop(session_key, None)
async def _cancel_active_recovery(self, session_key: str) -> None:
"""Stop an explicit continuation before accepting newer user input."""
task = self._active_recovery_tasks.get(session_key)
if task is None or task is asyncio.current_task() or task.done():
return
task.cancel()
# AgentLoop's cancellation path materializes any partial checkpoint and
# releases its pending queue. Wait for that ownership to be released
# before the newer message is routed.
await asyncio.gather(task, return_exceptions=True)
async def scan(self) -> None:
"""Recover every interrupted WebUI session once at gateway startup."""
for key in self._recovery_candidates():
metadata_payload = self.sessions.read_session_metadata(key)
raw_metadata = metadata_payload.get("metadata") if metadata_payload else None
metadata = cast(dict[str, Any], raw_metadata) if isinstance(raw_metadata, dict) else {}
route = self._websocket_route_for(key, metadata)
if route is None:
continue
unfinished = self._has_unfinished_webui_transcript(key)
if not self._needs_recovery(metadata) and not unfinished:
continue
session = self.sessions.get_or_create(key)
try:
await self._recover_session(session, route[1])
await self._requeue_pending_followups(session)
except Exception:
logger.exception("failed to recover interrupted WebUI session {}", session.key)
state = recovery_state_from_metadata(session.metadata)
failed = self._set_state(
session,
status="failed",
recovery_id=cast(str, state["recovery_id"]) if state else uuid4().hex,
attempts=cast(int, state.get("attempts", 0)) if state else 0,
reason="recovery_failed",
can_continue=False,
)
self.sessions.save(session)
await self._publish(route[1], failed)
def _recovery_candidates(self) -> list[str]:
"""Discover canonical and transcript-only WebUI sessions cheaply."""
candidates = dict.fromkeys(
key
for item in self.sessions.list_sessions()
if isinstance((key := item.get("key")), str)
)
try:
# Imported lazily because the sidebar index also projects recovery
# metadata. The index is the owner of transcript-only discovery;
# duplicating its filename and migration rules here would drift.
from nanobot.webui.session_list_index import list_webui_sessions
for item in list_webui_sessions(self.sessions):
key = item.get("key")
if isinstance(key, str):
candidates.setdefault(key, None)
except Exception:
# Canonical checkpoint recovery remains available even if the
# optional display-history index is corrupt or unavailable.
logger.exception("failed to discover transcript-only WebUI sessions")
return list(candidates)
@staticmethod
def _needs_recovery(metadata: Mapping[str, Any]) -> bool:
if metadata.get(PENDING_USER_TURN_KEY) is True:
return True
if isinstance(metadata.get(RUNTIME_CHECKPOINT_KEY), dict):
return True
followups = metadata.get(PENDING_FOLLOWUPS_KEY)
if isinstance(followups, list) and len(cast(list[object], followups)) > 0:
return True
state = recovery_state_from_metadata(metadata)
return bool(state and state["status"] in {"resuming", "awaiting_user", "failed"})
async def admit(self, message: InboundMessage) -> bool:
"""Reject stale queued recoveries and let new user input supersede them."""
recovery_id = message.metadata.get(RECOVERY_INBOUND_METADATA_KEY)
if isinstance(recovery_id, str):
session = self.sessions.get_or_create(message.session_key)
state = recovery_state_from_metadata(session.metadata)
return bool(
state
and state["status"] == "resuming"
and state["recovery_id"] == recovery_id
)
if message.channel != "websocket":
return True
session = self.sessions.get_or_create(message.session_key)
state = recovery_state_from_metadata(session.metadata)
if state and state["status"] in {"resuming", "awaiting_user", "failed"}:
await self._cancel_active_recovery(message.session_key)
restore_runtime_checkpoint(session)
if not restore_pending_interruption(session, superseded=True):
append_recovery_interruption(session, superseded=True)
recovered = self._set_state(
session,
status="recovered",
recovery_id=cast(str, state["recovery_id"]),
attempts=cast(int, state.get("attempts", 0)),
reason="superseded",
)
self.sessions.save(session)
await self._publish(message.chat_id, recovered)
return True
async def turn_completed(self, session_key: str) -> None:
"""Resolve a resuming state after the recovered turn commits."""
session = self.sessions.get_or_create(session_key)
state = recovery_state_from_metadata(session.metadata)
if not state or state["status"] != "resuming":
return
route = self._websocket_route(session)
if route is None:
return
recovered = self._set_state(
session,
status="recovered",
recovery_id=cast(str, state["recovery_id"]),
attempts=cast(int, state.get("attempts", 0)),
reason="continued",
)
self.sessions.save(session)
await self._publish(route[1], recovered)
async def handle_action(self, action: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Apply an authenticated continue/dismiss operation."""
chat_id = payload.get("chat_id")
recovery_id = payload.get("recovery_id")
if not isinstance(chat_id, str) or not chat_id:
raise RecoveryActionError("missing chat_id")
if not isinstance(recovery_id, str) or not recovery_id:
raise RecoveryActionError("missing recovery_id")
session = self.sessions.get_or_create(self._session_key(chat_id))
state = recovery_state_from_metadata(session.metadata)
if not state or state["recovery_id"] != recovery_id:
raise RecoveryActionError("recovery state is stale", status=409)
if action == "dismiss":
restore_runtime_checkpoint(session)
restore_pending_interruption(session)
next_state = self._set_state(
session,
status="recovered",
recovery_id=recovery_id,
attempts=cast(int, state.get("attempts", 0)),
reason="dismissed",
)
self.sessions.save(session)
await self._publish(chat_id, next_state)
return next_state
if action != "continue":
raise RecoveryActionError("unknown recovery action")
if state["status"] not in {"awaiting_user", "failed"}:
raise RecoveryActionError("recovery is not waiting for confirmation", status=409)
if state.get("can_continue") is False:
raise RecoveryActionError("recovery context is unavailable", status=409)
next_state = self._set_state(
session,
status="resuming",
recovery_id=recovery_id,
attempts=cast(int, state.get("attempts", 0)) + 1,
reason="user_confirmed",
resume_message_count=len(session.messages),
)
self.sessions.save(session)
await self._publish(chat_id, next_state)
await self._queue_continuation(session, chat_id, next_state)
return next_state
async def _recover_session(self, session: Session, chat_id: str) -> None:
checkpoint_value = cast(object, session.metadata.get(RUNTIME_CHECKPOINT_KEY))
checkpoint = (
cast(dict[str, Any], checkpoint_value)
if isinstance(checkpoint_value, dict)
else None
)
pending = session.metadata.get(PENDING_USER_TURN_KEY) is True
state = recovery_state_from_metadata(session.metadata)
if not pending and checkpoint is None:
if state and state["status"] == "resuming":
resume_count = self._resume_message_count(session)
if resume_count is not None and len(session.messages) > resume_count:
next_state = self._set_state(
session,
status="recovered",
recovery_id=cast(str, state["recovery_id"]),
attempts=cast(int, state.get("attempts", 0)),
reason="committed",
)
else:
next_state = self._set_state(
session,
status="awaiting_user",
recovery_id=cast(str, state["recovery_id"]),
attempts=cast(int, state.get("attempts", 1)),
reason="loop_guard",
)
self.sessions.save(session)
await self._publish(chat_id, next_state)
elif self._has_unfinished_webui_transcript(session.key):
# A normal last-client shutdown can materialize the checkpoint
# before the process exits. In that path there is no pending
# marker left to classify, but the append-only transcript still
# contains an activity row without a turn_end. Treat it as an
# interrupted turn instead of letting the UI resurrect it as a
# forever-running spinner.
can_continue = self._has_saved_continuation_context(session)
waiting = self._set_state(
session,
status="awaiting_user",
recovery_id=uuid4().hex,
attempts=0,
reason=(
"interrupted_with_saved_context"
if can_continue
else "interrupted_without_checkpoint"
),
can_continue=can_continue,
)
self.sessions.save(session)
await self._publish(chat_id, waiting)
return
if state and state["status"] in {"awaiting_user", "failed"}:
await self._publish(chat_id, state)
return
if state and state["status"] == "resuming":
restore_runtime_checkpoint(session)
restore_pending_interruption(session)
waiting = self._set_state(
session,
status="awaiting_user",
recovery_id=cast(str, state["recovery_id"]),
attempts=cast(int, state.get("attempts", 1)),
reason="loop_guard",
)
self.sessions.save(session)
await self._publish(chat_id, waiting)
return
recovery_id = uuid4().hex
phase = checkpoint.get("phase") if checkpoint is not None else None
pending_calls = checkpoint.get("pending_tool_calls") if checkpoint is not None else None
if checkpoint is not None and phase not in _KNOWN_CHECKPOINT_PHASES:
_discard_runtime_checkpoint(session)
restore_pending_interruption(session)
waiting = self._set_state(
session,
status="awaiting_user",
recovery_id=recovery_id,
attempts=0,
reason="checkpoint_unknown",
can_continue=False,
)
self.sessions.save(session)
await self._publish(chat_id, waiting)
return
if checkpoint is not None and not _runtime_checkpoint_is_well_formed(checkpoint):
_discard_runtime_checkpoint(session)
restore_pending_interruption(session)
waiting = self._set_state(
session,
status="awaiting_user",
recovery_id=recovery_id,
attempts=0,
reason="checkpoint_invalid",
can_continue=False,
)
self.sessions.save(session)
await self._publish(chat_id, waiting)
return
if phase == "final_response":
restore_runtime_checkpoint(session)
recovered = self._set_state(
session,
status="recovered",
recovery_id=recovery_id,
attempts=0,
reason="answer_restored",
)
self.sessions.save(session)
await self._publish(chat_id, recovered)
return
if phase in _UNCERTAIN_TOOL_PHASES or pending_calls:
restore_runtime_checkpoint(session)
waiting = self._set_state(
session,
status="awaiting_user",
recovery_id=recovery_id,
attempts=0,
reason="tool_state_unknown",
)
self.sessions.save(session)
await self._publish(chat_id, waiting)
return
# A gateway restart is a lifecycle boundary. Never enqueue model work
# implicitly: even a synchronized checkpoint may sit next to an
# external side effect that the user should review first. The final
# answer path above only restores persisted output; it never executes.
restore_runtime_checkpoint(session)
waiting = self._set_state(
session,
status="awaiting_user",
recovery_id=recovery_id,
attempts=0,
reason="restart_requires_confirmation",
)
self.sessions.save(session)
await self._publish(chat_id, waiting)
async def _queue_continuation(
self,
session: Session,
chat_id: str,
state: Mapping[str, Any],
) -> None:
recovery_id = cast(str, state["recovery_id"])
await self.bus.publish_inbound(
InboundMessage(
channel="websocket",
sender_id="system:recovery",
chat_id=chat_id,
content=(
"Continue the interrupted request from the saved conversation context. "
"Do not repeat completed work or mention the restart unless it affects the answer."
),
metadata={
"webui": True,
"_wants_stream": True,
WEBUI_TURN_METADATA_KEY: f"recovery:{recovery_id}",
RECOVERY_INBOUND_METADATA_KEY: recovery_id,
turn_continuation.INTERNAL_CONTINUATION_META: True,
turn_continuation.SKIP_USER_PERSIST_META: True,
},
session_key_override=session.key,
require_existing_session=True,
)
)
async def _requeue_pending_followups(self, session: Session) -> None:
"""Return durable live-turn follow-ups to the bus after a restart."""
for message in pending_followups(session):
await self.bus.publish_inbound(message)
@staticmethod
def _resume_message_count(session: Session) -> int | None:
raw_value = cast(object, session.metadata.get(RECOVERY_METADATA_KEY))
value = cast(dict[str, Any], raw_value) if isinstance(raw_value, dict) else None
if value is None:
return None
count = value.get("resume_message_count")
return count if isinstance(count, int) and count >= 0 else None
async def _publish(
self,
chat_id: str,
state: Mapping[str, Any],
) -> None:
"""Publish the recovery state and invalidate its sidebar projection."""
await self.bus.publish_outbound(
outbound_message_for_event(
channel="websocket",
chat_id=chat_id,
event=RecoveryStateEvent(
status=cast(str, state["status"]),
recovery_id=cast(str, state["recovery_id"]),
reason=cast(str | None, state.get("reason")),
attempts=cast(int, state.get("attempts", 0)),
can_continue=cast(bool | None, state.get("can_continue")),
),
)
)
await self.bus.publish_outbound(
outbound_message_for_event(
channel="websocket",
chat_id=chat_id,
event=SessionUpdatedEvent(scope="thread"),
)
)
@staticmethod
def _set_state(
session: Session,
*,
status: str,
recovery_id: str,
attempts: int,
reason: str,
resume_message_count: int | None = None,
can_continue: bool = True,
) -> dict[str, Any]:
state = {
"status": status,
"recovery_id": recovery_id,
"attempts": max(0, attempts),
"reason": reason,
"updated_at": datetime.now().isoformat(),
}
if not can_continue:
state["can_continue"] = False
if resume_message_count is not None:
state["resume_message_count"] = max(0, resume_message_count)
session.metadata[RECOVERY_METADATA_KEY] = state
session.updated_at = datetime.now()
return state
def _session_key(self, chat_id: str) -> str:
return UNIFIED_SESSION_KEY if self.unified_session else f"websocket:{chat_id}"
@staticmethod
def _has_unfinished_webui_transcript(session_key: str) -> bool:
"""Detect a stale WebUI activity tail after an unclean gateway stop.
The transcript is intentionally consulted only as a last-resort signal:
a durable pending turn or runtime checkpoint always takes precedence.
This keeps browser disconnects harmless while preventing a materialized
partial turn from being presented as active forever after a restart.
"""
try:
from nanobot.webui.transcript import has_unfinished_transcript_tail
return has_unfinished_transcript_tail(session_key)
except (OSError, ValueError, TypeError):
# Recovery must fail closed if the optional display transcript is
# corrupt or unavailable; the normal checkpoint path still applies.
return False
@staticmethod
def _has_saved_continuation_context(session: Session) -> bool:
"""Whether an interrupted turn left model-visible context to continue from."""
last_user = next(
(
index
for index in range(len(session.messages) - 1, -1, -1)
if session.messages[index].get("role") == "user"
),
None,
)
if last_user is None:
return False
tail = session.messages[last_user + 1 :]
return bool(tail) and (
tail[-1].get("role") == "tool"
or any(message.get("_recovery_interrupted") is True for message in tail)
or any(
message.get("role") == "assistant" and bool(message.get("tool_calls"))
for message in tail
)
)
@staticmethod
def _websocket_route(session: Session) -> tuple[str, str] | None:
return RecoveryCoordinator._websocket_route_for(session.key, session.metadata)
@staticmethod
def _websocket_route_for(
session_key: str,
metadata: Mapping[str, Any],
) -> tuple[str, str] | None:
if session_key.startswith("websocket:"):
chat_id = session_key.split(":", 1)[1]
return ("websocket", chat_id) if chat_id else None
if session_key == UNIFIED_SESSION_KEY:
route = last_channel_from_metadata(metadata)
if route and route[0] == "websocket":
return route
return None
+4 -9
View File
@@ -43,6 +43,7 @@ from nanobot.runtime_context import public_history_message
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import Session, SessionManager
from nanobot.session.recovery import RecoveryCoordinator
from nanobot.session.session_handles import session_handle_for_name
from nanobot.session.session_messages import (
SessionMessageEnvelope,
@@ -511,6 +512,7 @@ class WebuiTurnCoordinator:
bus: MessageBus
sessions: SessionManager
schedule_background: Callable[[Awaitable[None]], None]
recovery: RecoveryCoordinator | None = None
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
"""Subscribe this coordinator to runtime events."""
@@ -654,6 +656,8 @@ class WebuiTurnCoordinator:
event.runtime.context_window_tokens if event.runtime is not None else None
),
)
if self.recovery is not None:
await self.recovery.turn_completed(event.context.session_key)
self._schedule_title_update_from_event(event)
async def _handle_goal_state_changed(self, event: GoalStateChanged) -> None:
@@ -685,15 +689,6 @@ class WebuiTurnCoordinator:
)
)
async def publish_run_status(
self,
msg: InboundMessage,
status: str,
*,
started_at: float | None = None,
) -> None:
await publish_turn_run_status(self.bus, msg, status, started_at=started_at)
async def handle_turn_end(
self,
msg: InboundMessage,
+2
View File
@@ -69,6 +69,7 @@ def build_gateway_services(
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None,
recovery_action: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None = None,
logger: Any = default_logger,
) -> GatewayServices:
settings = WebUISettingsServices.create(
@@ -131,6 +132,7 @@ def build_gateway_services(
mcp_runtime_status=mcp_runtime_status,
mcp_reload=mcp_reload,
skill_state_action=skill_state_action,
recovery_action=recovery_action,
log=logger,
)
return GatewayServices(
-3
View File
@@ -63,9 +63,6 @@ class GatewayTokenStore:
self.api_tokens[token_value] = expiry
return token_value
def take_issued_token_if_valid(self, token_value: str | None) -> bool:
return self.take_issued_token_audience(token_value) is not None
def take_issued_token_audience(
self,
token_value: str | None,
+6 -1
View File
@@ -31,8 +31,9 @@ from nanobot.session.manager import (
_metadata_title, # pyright: ignore[reportPrivateUsage]
)
from nanobot.session.model_selection import model_preset_from_metadata
from nanobot.session.recovery import recovery_state_from_metadata
_INDEX_VERSION = 7
_INDEX_VERSION = 8
_INDEX_FILENAME = ".webui_session_index.json"
_MODEL_PRESET_FIELD = "model_preset"
_ROW_SOURCE_FIELD = "_source"
@@ -245,6 +246,7 @@ def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dic
"title": row.get("title", ""),
"preview": row.get("preview", ""),
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
"recovery_state": row.get("recovery_state"),
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
"path": str(path),
@@ -485,6 +487,7 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
"title": _metadata_title(session.metadata),
"preview": _preview_from_messages(session.messages),
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
"recovery_state": recovery_state_from_metadata(session.metadata),
**_indexed_workspace_scope_fields(session.metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name,
@@ -601,6 +604,7 @@ def _scan_transcript_row(
"title": "",
"preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: None,
"recovery_state": None,
**_indexed_workspace_scope_fields({}),
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
"file": stem,
@@ -687,6 +691,7 @@ def _scan_session_row(
"title": _metadata_title(metadata),
"preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
"recovery_state": recovery_state_from_metadata(metadata),
**_indexed_workspace_scope_fields(metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name,
-61
View File
@@ -36,7 +36,6 @@ from nanobot.webui.nanobot_features_api import (
nanobot_features_payload,
)
from nanobot.webui.settings_api import (
WebUISettingsError,
complete_oauth_provider,
create_model_configuration,
create_provider_settings,
@@ -490,17 +489,6 @@ class WebUISettingsRouter:
lambda: request_image_generation_reload(self.bus),
)
async def _apply_image_generation_runtime_change(
self,
payload: dict[str, Any],
) -> dict[str, Any]:
updated, restart_cleared = (
await self._apply_image_generation_runtime_change_result(payload)
)
if restart_cleared:
self._restart_sections.discard("image")
return updated
async def _reload_mcp_runtime(self) -> dict[str, Any]:
if self._mcp_reload is None:
return {
@@ -531,47 +519,9 @@ class WebUISettingsRouter:
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
return self._query(request)
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
return self._query(request)
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
payload = _mutation_payload(request)
if payload is not None:
api_key = payload.get("api_key")
if api_key is not None and not isinstance(api_key, str):
raise WebUISettingsError("API service API key must be a string")
return self._query(request)
def _api_runtime(self) -> ApiRuntime:
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
def _api_service_payload(
self,
*,
last_action: str | None = None,
) -> dict[str, Any]:
return capability_domain.api_service_payload(
self.settings,
self._api_runtime(),
last_action=last_action,
)
@staticmethod
def _masked_secret(value: str) -> str | None:
return capability_domain.masked_api_secret(value)
@staticmethod
def _api_runtime_message(message: str) -> str:
return capability_domain.api_runtime_message(message)
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
return self._system.parse_channel_values(
SettingsRequest(
query=self._query(request),
payload=_mutation_payload(request),
)
)
def _save_channel_config_values(
self,
name: str,
@@ -610,17 +560,6 @@ class WebUISettingsRouter:
allow_install=allow_install,
)
@staticmethod
def _feature_runtime_fallback(
payload: dict[str, Any],
*,
message: str,
) -> dict[str, Any]:
return system_domain.SystemSettingsHandler.feature_runtime_fallback(
payload,
message=message,
)
def _allow_feature_package_install(
self,
connection: Any,
+24 -16
View File
@@ -1870,7 +1870,14 @@ def replay_transcript_to_ui_messages(
return None
return str(last.get("id"))
def demote_interrupted_assistant(segment: str) -> None:
def close_interrupted_assistant() -> None:
"""Close an answer segment before tool activity without changing its semantics.
The wire protocol already marks answer, reasoning, and activity phases.
A later tool event does not turn previously emitted answer text into
reasoning; preserving ``content`` also keeps live and replay projections
equivalent.
"""
nonlocal buffer_message_id, buffer_parts
for i in range(len(messages) - 1, -1, -1):
candidate = messages[i]
@@ -1886,19 +1893,7 @@ def replay_transcript_to_ui_messages(
or candidate.get("media")
):
continue
reasoning_parts = [
part
for part in (candidate.get("reasoning"), content)
if isinstance(part, str) and part.strip()
]
messages[i] = {
**candidate,
"content": "",
"reasoning": "\n\n".join(reasoning_parts),
"reasoningStreaming": False,
"isStreaming": False,
"activitySegmentId": candidate.get("activitySegmentId") or segment,
}
messages[i] = {**candidate, "isStreaming": False}
if buffer_message_id == candidate.get("id"):
buffer_message_id = None
buffer_parts = []
@@ -2069,7 +2064,7 @@ def replay_transcript_to_ui_messages(
if not segment:
segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment
demote_interrupted_assistant(segment)
close_interrupted_assistant()
strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields)
target_index = find_file_edit_trace_index(segment, edits)
if target_index is not None:
@@ -2363,7 +2358,7 @@ def replay_transcript_to_ui_messages(
if not trace_lines:
continue
segment = _ensure_activity_segment()
demote_interrupted_assistant(segment)
close_interrupted_assistant()
last = messages[-1] if messages else None
if (
last
@@ -2546,6 +2541,19 @@ def has_pending_tool_calls(
return False
def has_unfinished_transcript_tail(session_key: str) -> bool:
"""Return whether the active transcript ends in an unfinished turn.
Recovery runs at gateway startup and only needs the newest, still-active
turn. Completed turns are rotated into immutable segment files, so reading
every historical segment here would make restart cost grow with the full
conversation history.
"""
return has_pending_tool_calls(
_read_transcript_file(webui_transcript_path(session_key))
)
def completed_turn_ids(lines: list[dict[str, Any]]) -> list[str]:
"""Return stable identities for turns with an explicitly persisted completion."""
completed: list[str] = []
+39
View File
@@ -29,6 +29,7 @@ from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob, CronSchedule
from nanobot.security.workspace_access import WorkspaceScope
from nanobot.session.manager import SessionManager
from nanobot.session.recovery import RecoveryActionError
from nanobot.session.session_handles import (
SessionHandleResolver,
)
@@ -145,6 +146,8 @@ _WEBUI_MUTATION_PATHS = {
"skill.delete": "/api/webui/skills/delete",
"sidebar.update": "/api/webui/sidebar-state/update",
"workspace.pick_folder": "/api/workspaces/pick-folder",
"recovery.continue": "/api/webui/recovery/continue",
"recovery.dismiss": "/api/webui/recovery/dismiss",
"settings.agent.update": "/api/settings/update",
"settings.model_configuration.create": "/api/settings/model-configurations/create",
"settings.model_configuration.update": "/api/settings/model-configurations/update",
@@ -323,6 +326,9 @@ class GatewayHTTPHandler:
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None,
recovery_action: (
Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None
) = None,
log: Any = logger,
) -> None:
self.config = config
@@ -340,6 +346,7 @@ class GatewayHTTPHandler:
disabled_skills if disabled_skills is not None else set()
)
self.skill_state_action = skill_state_action
self.recovery_action = recovery_action
self._skill_install_lock = asyncio.Lock()
self._folder_picker_lock = asyncio.Lock()
self.cron_service = cron_service
@@ -454,6 +461,8 @@ class GatewayHTTPHandler:
return True
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
return True
if path in {"/api/webui/recovery/continue", "/api/webui/recovery/dismiss"}:
return True
return path in {
"/api/webui/skills/install",
"/api/webui/skills/update",
@@ -507,6 +516,11 @@ class GatewayHTTPHandler:
if response is not None:
return response
# Recovery routes
response = await self._dispatch_recovery_route(request, got)
if response is not None:
return response
# Session routes
response = await self._dispatch_session_routes(request, got)
if response is not None:
@@ -700,6 +714,27 @@ class GatewayHTTPHandler:
return None
async def _dispatch_recovery_route(
self,
request: WsRequest,
path: str,
) -> Response | None:
match = re.fullmatch(r"/api/webui/recovery/(continue|dismiss)", path)
if match is None:
return None
if not getattr(request, _WEBUI_MUTATION_REQUEST_ATTR, False):
return _http_error(405, "WebUI recovery actions require an authenticated WebSocket")
if self.recovery_action is None:
return _http_error(503, "WebUI recovery is unavailable")
payload = _mutation_payload(request)
if payload is None:
return _http_error(400, "invalid recovery payload")
try:
result = await self.recovery_action(match.group(1), payload)
except RecoveryActionError as exc:
return _http_error(exc.status, str(exc))
return _http_json_response(result)
async def _handle_session_context_get(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request):
return _http_error(401, "Unauthorized")
@@ -746,6 +781,10 @@ class GatewayHTTPHandler:
for k, v in s.items()
if k != "path" and k not in WEBUI_SESSION_INDEX_INTERNAL_FIELDS
}
# Keep the additive recovery field absent for ordinary sessions so
# older clients and compact list responses stay unchanged.
if row.get("recovery_state") is None:
row.pop("recovery_state", None)
chat_id = key.split(":", 1)[1]
started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None:
+1 -1
View File
@@ -29,7 +29,6 @@ dependencies = [
"pydantic-settings>=2.12.0,<3.0.0",
# Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16.
"websockets>=15.0,<17.0",
"websocket-client>=1.9.0,<2.0.0",
"httpx[socks]>=0.28.0,<1.0.0",
"ddgs>=9.5.5,<10.0.0",
"oauth-cli-kit>=0.1.6,<1.0.0",
@@ -40,6 +39,7 @@ dependencies = [
"qrcode[pil]>=8.0",
"croniter>=6.0.0,<7.0.0",
"prompt-toolkit>=3.0.50,<4.0.0",
"setproctitle>=1.3.7,<2.0.0; sys_platform != 'win32'",
"questionary>=2.0.0,<3.0.0",
"mcp>=1.26.0,<2.0.0",
"json-repair>=0.57.0,<1.0.0",
+58
View File
@@ -36,6 +36,7 @@ from nanobot.session.keys import (
UNIFIED_SESSION_KEY,
)
from nanobot.session.manager import Session
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY, PENDING_FOLLOWUPS_KEY
from nanobot.session.turn_continuation import (
INTERNAL_CONTINUATION_META,
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
@@ -161,6 +162,35 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
assert message["cron_prompt_ref"] == prompt_ref
def test_persist_user_message_acknowledges_durable_followup(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
session = loop.sessions.get_or_create("websocket:chat")
session.metadata[PENDING_FOLLOWUPS_KEY] = [
{
"id": "followup-1",
"sender_id": "user",
"chat_id": "chat",
"content": "queued while busy",
"media": [],
"metadata": {},
}
]
persisted = loop._persist_user_message_early(
InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat",
content="queued while busy",
metadata={PENDING_FOLLOWUP_ID_KEY: "followup-1"},
),
session,
)
assert persisted is True
assert PENDING_FOLLOWUPS_KEY not in session.metadata
def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
session = loop.sessions.get_or_create("websocket:auto")
@@ -381,6 +411,34 @@ def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
assert public_history_message(session.messages[0])["content"] == []
def test_save_turn_acknowledges_every_merged_recovery_followup() -> None:
"""Persisting a merged injected row retires every durable follow-up ID."""
loop = _mk_loop()
session = Session(
key="test:recovery-followups",
metadata={
PENDING_FOLLOWUPS_KEY: [
{"id": "first"},
{"id": "second"},
]
},
)
loop._save_turn(
session,
[
{
"role": "user",
"content": "first\n\nsecond",
PENDING_FOLLOWUP_ID_KEY: ["first", "second"],
}
],
skip=0,
)
assert PENDING_FOLLOWUPS_KEY not in session.metadata
def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None:
loop = _mk_loop()
session = Session(key="test:image")
+131 -2
View File
@@ -26,7 +26,7 @@ def _make_injection_callback(queue: asyncio.Queue):
return inject_cb
def _make_loop(tmp_path):
def _make_loop(tmp_path, *, recovery_admission=None):
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
@@ -39,7 +39,12 @@ def _make_loop(tmp_path):
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
mock_sub_mgr.return_value.close = AsyncMock()
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
loop = AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
recovery_admission=recovery_admission,
)
return loop
@pytest.mark.asyncio
@@ -759,6 +764,20 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
)
def test_runner_merge_keeps_all_recovery_followup_ids() -> None:
"""Merged follow-ups stay acknowledged together after a later save."""
from nanobot.agent.runner import AgentRunner
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
messages = [{"role": "user", "content": "first", PENDING_FOLLOWUP_ID_KEY: "one"}]
AgentRunner._append_injected_messages(
messages,
[{"role": "user", "content": "second", PENDING_FOLLOWUP_ID_KEY: "two"}],
)
assert messages[-1][PENDING_FOLLOWUP_ID_KEY] == ["one", "two"]
def test_runner_merge_preserves_runtime_markers_with_media() -> None:
from nanobot.agent.runner import AgentRunner
from nanobot.runtime_context import (
@@ -967,6 +986,71 @@ async def test_followup_routed_to_pending_queue(tmp_path):
assert queued_msg.session_key == UNIFIED_SESSION_KEY
@pytest.mark.asyncio
async def test_websocket_followup_is_admitted_before_recovery_queue(tmp_path):
"""Recovery admission runs before a newer WebUI message is injected."""
from nanobot.bus.events import InboundMessage
admission = MagicMock()
admission.admit = AsyncMock(return_value=True)
loop = _make_loop(tmp_path, recovery_admission=admission)
loop._dispatch = AsyncMock() # type: ignore[method-assign]
session_key = "websocket:chat"
pending = asyncio.Queue(maxsize=20)
loop._pending_queues[session_key] = pending
run_task = asyncio.create_task(loop.run())
msg = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="chat",
content="new request",
)
await loop.bus.publish_inbound(msg)
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
admission.admit.assert_awaited_once_with(msg)
assert queued_msg.content == msg.content
assert queued_msg.metadata["_recovery_followup_id"]
loop.stop()
await asyncio.wait_for(run_task, timeout=2)
@pytest.mark.asyncio
async def test_unified_websocket_followup_admits_effective_session(tmp_path):
"""Recovery admission and the pending queue must use the same session key."""
from nanobot.bus.events import InboundMessage
from nanobot.session.keys import UNIFIED_SESSION_KEY
admission = MagicMock()
admission.admit = AsyncMock(return_value=True)
loop = _make_loop(tmp_path, recovery_admission=admission)
loop._unified_session = True
loop._dispatch = AsyncMock() # type: ignore[method-assign]
pending = asyncio.Queue(maxsize=20)
loop._pending_queues[UNIFIED_SESSION_KEY] = pending
run_task = asyncio.create_task(loop.run())
msg = InboundMessage(
channel="websocket",
sender_id="u",
chat_id="chat",
content="new request",
)
await loop.bus.publish_inbound(msg)
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
admitted_msg = admission.admit.await_args.args[0]
assert admitted_msg.session_key == UNIFIED_SESSION_KEY
assert queued_msg.session_key == UNIFIED_SESSION_KEY
loop.stop()
await asyncio.wait_for(run_task, timeout=2)
@pytest.mark.asyncio
async def test_mid_turn_subagent_result_does_not_resolve_a_new_turn_route(tmp_path):
"""Injected results stay inside the active turn instead of opening a side turn."""
@@ -1314,6 +1398,51 @@ async def test_pending_queue_full_falls_back_to_queued_task(tmp_path):
assert pending.qsize() == 1
@pytest.mark.asyncio
async def test_pending_queue_overflow_keeps_websocket_followup_durable(tmp_path):
"""Fallback dispatch must not acknowledge a WebUI message before it commits."""
from nanobot.bus.events import InboundMessage
from nanobot.session.manager import Session
from nanobot.session.recovery import pending_followups
loop = _make_loop(tmp_path)
dispatched = asyncio.Event()
release_dispatch = asyncio.Event()
async def _dispatch(_msg):
dispatched.set()
await release_dispatch.wait()
loop._dispatch = AsyncMock(side_effect=_dispatch) # type: ignore[method-assign]
session = Session(key="websocket:c")
loop.sessions.get_or_create.return_value = session
pending = asyncio.Queue(maxsize=1)
pending.put_nowait(
InboundMessage(channel="websocket", sender_id="u", chat_id="c", content="already queued")
)
loop._pending_queues["websocket:c"] = pending
run_task = asyncio.create_task(loop.run())
await loop.bus.publish_inbound(
InboundMessage(
channel="websocket",
sender_id="u",
chat_id="c",
content="durable follow-up",
metadata={"webui": True},
)
)
await asyncio.wait_for(dispatched.wait(), timeout=2)
assert [message.content for message in pending_followups(session)] == ["durable follow-up"]
dispatched_msg = loop._dispatch.await_args.args[0]
assert dispatched_msg.metadata["_recovery_followup_id"]
release_dispatch.set()
loop.stop()
await asyncio.wait_for(run_task, timeout=2)
@pytest.mark.asyncio
async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
"""Messages left in the pending queue after _dispatch are re-published to the bus.
@@ -161,3 +161,25 @@ async def test_dispatch_cancellation_restores_checkpoint():
"Checkpoint metadata should be cleared after restore"
assert loop.sessions.save.called, \
"Session should be persisted so the restored state survives process restart"
@pytest.mark.asyncio
async def test_dispatch_cancellation_keeps_checkpoint_for_gateway_shutdown(tmp_path: Path) -> None:
"""Gateway shutdown preserves the checkpoint; an explicit stop restores it."""
loop = _make_loop(tmp_path)
loop.preserve_inflight_turns_on_shutdown()
loop._restore_runtime_checkpoint = MagicMock() # type: ignore[method-assign]
async def _cancel(*_args: object, **_kwargs: object) -> None:
raise asyncio.CancelledError()
loop._process_message = _cancel # type: ignore[method-assign]
from nanobot.bus.events import InboundMessage
with pytest.raises(asyncio.CancelledError):
await loop._dispatch(
InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="work")
)
loop._restore_runtime_checkpoint.assert_not_called()
+21
View File
@@ -3721,6 +3721,27 @@ async def test_notify_restart_done_waits_until_channel_starts():
assert sent_msg.content.startswith("Restart completed")
@pytest.mark.asyncio
async def test_websocket_restart_notice_does_not_overwrite_recovery_state():
"""WebSocket attach/recovery events already own reconnect state."""
fake_config = SimpleNamespace(
channels=ChannelsConfig(),
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
)
mgr = ChannelManager.__new__(ChannelManager)
mgr.config = fake_config
mgr.bus = MessageBus()
channel = _StartableChannel(fake_config, mgr.bus)
channel._running = True
mgr.channels = {"websocket": channel}
mgr._send_with_retry = AsyncMock()
notice = RestartNotice(channel="websocket", chat_id="chat", started_at_raw="100.0")
await mgr._send_restart_notice_when_started(notice)
mgr._send_with_retry.assert_not_awaited()
@pytest.mark.asyncio
async def test_restart_notice_retries_until_running_channel_accepts_delivery():
"""A running flag must not make an early transport failure final."""
+24 -7
View File
@@ -102,6 +102,19 @@ class _GatewayAgentContractStub:
) -> OutboundMessage | None:
return None
def preserve_inflight_turns_on_shutdown(self) -> None:
return None
class _EmptyGatewaySessionManager:
"""Minimal session-manager contract for gateway assembly tests."""
def list_sessions(self) -> list[dict[str, object]]:
return []
def flush_all(self) -> int:
return 0
def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
class _FakeLoop:
@@ -2756,7 +2769,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
@@ -2823,7 +2836,7 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path:
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
cron_service=_StopCron,
)
@@ -3329,7 +3342,7 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
cron_service=_StopCron,
get_cron_dir=lambda: legacy_dir,
)
@@ -3368,7 +3381,7 @@ def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
cron_service=_StopCron,
get_cron_dir=lambda: legacy_dir,
)
@@ -3569,7 +3582,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
)
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
@@ -3702,6 +3715,9 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
async def aclose(self) -> None:
seen["agent_closed"] = True
def preserve_inflight_turns_on_shutdown(self) -> None:
seen["inflight_turns_preserved"] = True
def stop(self) -> None:
seen["agent_stopped"] = True
@@ -3771,7 +3787,7 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
)
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider)
@@ -3783,6 +3799,7 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
assert result.exit_code == 0
assert seen["agent_stopped"] is True
assert seen["inflight_turns_preserved"] is True
assert seen["agent_closed"] is True
assert seen["agent_task_cleaned_up"] is True
assert seen["channels_stopped"] is True
@@ -3894,7 +3911,7 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
monkeypatch,
config,
message_bus=lambda: object(),
session_manager=lambda _workspace: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
)
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import os
from pathlib import Path
import pytest
from typer.testing import CliRunner
from nanobot.cli.commands import app
from nanobot.cli.process_identity import named_executable, set_cli_process_identity
@pytest.mark.parametrize(
("args", "expected"),
[
(["agent"], "nanobot-agent"),
(["gateway", "--background"], "nanobot-gateway"),
(["webui"], "nanobot-webui"),
(["status"], "nanobot"),
([], "nanobot"),
],
)
def test_cli_process_identity_uses_product_and_role(
monkeypatch: pytest.MonkeyPatch,
args: list[str],
expected: str,
) -> None:
titles: list[str] = []
monkeypatch.setattr("nanobot.cli.process_identity.os.name", "posix")
monkeypatch.setattr("nanobot.cli.process_identity._set_process_title", titles.append)
set_cli_process_identity(args)
assert titles == [expected]
def test_cli_process_identity_keeps_windows_launcher_name(
monkeypatch: pytest.MonkeyPatch,
) -> None:
titles: list[str] = []
monkeypatch.setattr("nanobot.cli.process_identity.os.name", "nt")
monkeypatch.setattr("nanobot.cli.process_identity._set_process_title", titles.append)
set_cli_process_identity(["agent"])
assert titles == []
def test_legacy_console_entrypoint_still_sets_subcommand_identity(
monkeypatch: pytest.MonkeyPatch,
) -> None:
commands: list[list[str]] = []
monkeypatch.setattr("nanobot.cli.commands.set_cli_process_identity", commands.append)
result = CliRunner().invoke(app, ["webui", "--help"])
assert result.exit_code == 0
assert commands == [["webui"]]
def test_named_executable_creates_stable_role_symlink(
tmp_path: Path,
) -> None:
if os.name == "nt":
pytest.skip("POSIX symlink naming is not used on Windows")
executable = tmp_path / "bun"
executable.write_text("runtime", encoding="utf-8")
first = Path(
named_executable(executable.as_posix(), name="nanobot-tui", directory=tmp_path / "run")
)
second = Path(
named_executable(executable.as_posix(), name="nanobot-tui", directory=tmp_path / "run")
)
assert first == second
assert first.name == "nanobot-tui"
assert first.is_symlink()
assert first.resolve() == executable
def test_named_executable_uses_original_on_windows(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr("nanobot.cli.process_identity.os.name", "nt")
assert (
named_executable("bun.exe", name="nanobot-tui", directory=tmp_path / "run")
== "bun.exe"
)
+5 -1
View File
@@ -467,9 +467,13 @@ def test_source_checkout_refreshes_locked_tui_dependencies(
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
monkeypatch.setattr(
"nanobot.cli.tui_launcher.named_executable",
lambda executable, **_kwargs: f"{executable}-named",
)
assert _resolve_source_tui_command(source_dir, bun) == [
bun,
f"{bun}-named",
str(source_dir / "src" / "index.ts"),
]
+28
View File
@@ -18,6 +18,7 @@ from nanobot.gateway import (
GatewayRuntimePaths,
GatewayStartOptions,
GatewayStatus,
RuntimeResult,
)
from nanobot.gateway.runtime import monitor_gateway_clients
from nanobot.process_runtime import process_is_running
@@ -454,6 +455,33 @@ def test_restart_does_not_detach_a_foreground_gateway(tmp_path, monkeypatch):
assert result.message == "gateway_foreground_restart_required"
def test_restart_stops_then_starts_the_background_gateway(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
status = GatewayStatus(
running=True,
pid=12345,
state_path=runtime.paths.state_path,
log_path=runtime.paths.log_path,
launch_mode="background",
)
monkeypatch.setattr(runtime, "status", lambda **_kwargs: status)
def stop(*, timeout_s: int):
assert timeout_s == 20
return SimpleNamespace(ok=True, message="gateway_stopped", status=status)
monkeypatch.setattr(runtime, "_stop", stop)
monkeypatch.setattr(
runtime,
"_start_background",
lambda _options: RuntimeResult(True, "gateway_started_background", status),
)
result = runtime.restart(GatewayStartOptions(port=18790))
assert result.ok is True
def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
+773
View File
@@ -0,0 +1,773 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import RecoveryStateEvent, SessionUpdatedEvent
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import Session, SessionManager
from nanobot.session.recovery import (
PENDING_FOLLOWUPS_KEY,
PENDING_USER_TURN_KEY,
RECOVERY_METADATA_KEY,
RUNTIME_CHECKPOINT_KEY,
RecoveryActionError,
RecoveryCoordinator,
acknowledge_pending_followups,
pending_followups,
record_pending_followup,
)
from nanobot.webui import session_list_index, transcript
def _persist(manager: SessionManager, session: Session) -> None:
session.metadata["webui"] = True
manager.save(session)
def _coordinator(workspace: Path) -> tuple[RecoveryCoordinator, MessageBus, SessionManager]:
bus = MessageBus()
sessions = SessionManager(workspace)
return RecoveryCoordinator(sessions, bus), bus, sessions
@pytest.mark.asyncio
async def test_restart_before_model_call_waits_for_confirmation(tmp_path: Path) -> None:
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "finish this"})
session.metadata[PENDING_USER_TURN_KEY] = True
_persist(sessions, session)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
assert bus.inbound.empty()
restored = restarted.get_or_create("websocket:chat")
assert restored.metadata[PENDING_USER_TURN_KEY] is True
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "restart_requires_confirmation"
@pytest.mark.asyncio
async def test_stale_incomplete_transcript_waits_for_confirmation(tmp_path: Path, monkeypatch) -> None:
"""A materialized shutdown must not reappear as an endless Working state."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
_persist(sessions, session)
monkeypatch.setattr(
"nanobot.webui.transcript.has_unfinished_transcript_tail",
lambda _key: True,
)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
assert bus.inbound.empty()
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
assert state["status"] == "awaiting_user"
assert state["reason"] == "interrupted_without_checkpoint"
event = bus.outbound.get_nowait().event
assert isinstance(event, RecoveryStateEvent)
assert event.status == "awaiting_user"
@pytest.mark.asyncio
async def test_materialized_interruption_can_continue_from_saved_context(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Older shutdowns may have cleared the checkpoint after saving partial history."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.extend(
[
{"role": "user", "content": "research this"},
{"role": "assistant", "content": "I will check."},
{"role": "tool", "tool_call_id": "search-1", "content": "saved result"},
]
)
_persist(sessions, session)
monkeypatch.setattr(
"nanobot.webui.transcript.has_unfinished_transcript_tail",
lambda _key: True,
)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
assert state["status"] == "awaiting_user"
assert state["reason"] == "interrupted_with_saved_context"
assert "can_continue" not in state
event = bus.outbound.get_nowait().event
assert isinstance(event, RecoveryStateEvent)
assert event.can_continue is None
await coordinator.handle_action(
"continue",
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
)
continuation = bus.inbound.get_nowait()
assert continuation.session_key_override == "websocket:chat"
@pytest.mark.asyncio
async def test_transcript_only_interruption_is_discovered_without_materializing_completed_history(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
webui_dir = tmp_path / "webui"
webui_dir.mkdir()
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
monkeypatch.setattr(transcript, "get_webui_dir", lambda: webui_dir)
unfinished_key = "websocket:unfinished"
completed_key = "websocket:completed"
(webui_dir / f"{SessionManager.safe_key(unfinished_key)}.jsonl").write_text(
'{"event":"user","chat_id":"unfinished","text":"keep going"}\n'
'{"event":"message","chat_id":"unfinished","kind":"progress","text":"Working"}\n',
encoding="utf-8",
)
(webui_dir / f"{SessionManager.safe_key(completed_key)}.jsonl").write_text(
'{"event":"user","chat_id":"completed","text":"done"}\n'
'{"event":"message","chat_id":"completed","text":"finished"}\n'
'{"event":"turn_end","chat_id":"completed"}\n',
encoding="utf-8",
)
coordinator, bus, sessions = _coordinator(tmp_path / "workspace")
await coordinator.scan()
restored = sessions.get_or_create(unfinished_key)
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "interrupted_without_checkpoint"
assert restored.metadata[RECOVERY_METADATA_KEY]["can_continue"] is False
assert sessions.read_session_metadata(completed_key) is None
event = bus.outbound.get_nowait().event
assert isinstance(event, RecoveryStateEvent)
assert event.status == "awaiting_user"
assert event.can_continue is False
assert bus.outbound.get_nowait().event.scope == "thread"
assert bus.outbound.empty()
with pytest.raises(RecoveryActionError, match="context is unavailable"):
await coordinator.handle_action(
"continue",
{"chat_id": "unfinished", "recovery_id": event.recovery_id},
)
@pytest.mark.asyncio
async def test_scan_loads_only_sessions_that_need_webui_recovery(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
sessions = SessionManager(tmp_path)
for key in ("telegram:idle", "discord:pending", "websocket:idle"):
session = sessions.get_or_create(key)
if key == "discord:pending":
session.metadata[PENDING_USER_TURN_KEY] = True
_persist(sessions, session)
pending = sessions.get_or_create("websocket:pending")
pending.metadata[PENDING_USER_TURN_KEY] = True
_persist(sessions, pending)
coordinator, _, restarted = _coordinator(tmp_path)
loaded: list[str] = []
get_or_create = restarted.get_or_create
def tracked_get_or_create(key: str) -> Session:
loaded.append(key)
return get_or_create(key)
monkeypatch.setattr(restarted, "get_or_create", tracked_get_or_create)
monkeypatch.setattr(
"nanobot.webui.transcript.has_unfinished_transcript_tail",
lambda _key: False,
)
await coordinator.scan()
assert loaded == ["websocket:pending"]
@pytest.mark.asyncio
async def test_live_turn_followup_survives_restart_until_it_is_committed(tmp_path: Path) -> None:
"""A message injected mid-turn is not lost between checkpoints."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
_persist(sessions, session)
followup_id = record_pending_followup(
session,
InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat",
content="also check the logs",
metadata={"webui": True},
),
)
assert followup_id is not None
sessions.save(session)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
queued = bus.inbound.get_nowait()
assert queued.content == "also check the logs"
assert queued.metadata["_recovery_followup_id"] == followup_id
restored = restarted.get_or_create("websocket:chat")
assert len(pending_followups(restored)) == 1
acknowledge_pending_followups(restored, [followup_id])
assert PENDING_FOLLOWUPS_KEY not in restored.metadata
def test_followup_journal_keeps_every_uncommitted_message(tmp_path: Path) -> None:
"""A live queue limit must never truncate durable WebUI follow-ups."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
followup_ids = [
record_pending_followup(
session,
InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat",
content=f"follow-up-{index}",
metadata={"webui": True},
),
)
for index in range(21)
]
assert all(followup_ids)
sessions.save(session)
restarted = SessionManager(tmp_path)
restored = restarted.get_or_create("websocket:chat")
assert [message.content for message in pending_followups(restored)] == [
f"follow-up-{index}" for index in range(21)
]
def test_requeued_followup_preserves_its_journal_id(tmp_path: Path) -> None:
"""Routing a recovered follow-up into a live turn must remain idempotent."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
original_id = record_pending_followup(
session,
InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat",
content="also check the logs",
metadata={"webui": True},
),
)
assert original_id is not None
recovered = pending_followups(session)[0]
assert record_pending_followup(session, recovered) == original_id
assert [record["id"] for record in session.metadata[PENDING_FOLLOWUPS_KEY]] == [
original_id
]
@pytest.mark.asyncio
async def test_completed_tools_wait_for_confirmation_after_restart(tmp_path: Path) -> None:
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "inspect"})
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
"phase": "tools_completed",
"assistant_message": {
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call-1", "function": {"name": "read_file"}}],
},
"completed_tool_results": [
{
"role": "tool",
"tool_call_id": "call-1",
"name": "read_file",
"content": "saved result",
}
],
"pending_tool_calls": [],
}
_persist(sessions, session)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
assert bus.inbound.empty()
restored = restarted.get_or_create("websocket:chat")
assert restored.messages[-1]["content"] == "saved result"
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
@pytest.mark.asyncio
async def test_uncertain_tool_is_never_replayed(tmp_path: Path) -> None:
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "send it"})
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
"phase": "awaiting_tools",
"assistant_message": {
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call-1", "function": {"name": "send_email"}}],
},
"completed_tool_results": [],
"pending_tool_calls": [
{"id": "call-1", "function": {"name": "send_email"}}
],
}
_persist(sessions, session)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
assert bus.inbound.empty()
restored = restarted.get_or_create("websocket:chat")
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "tool_state_unknown"
assert restored.messages[-1]["_recovery_interrupted"] is True
event = bus.outbound.get_nowait().event
assert isinstance(event, RecoveryStateEvent)
assert event.status == "awaiting_user"
@pytest.mark.asyncio
async def test_unknown_checkpoint_waits_for_confirmation(tmp_path: Path) -> None:
"""Malformed or newer checkpoint phases fail closed pending confirmation."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "deploy it"})
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {"phase": "future_phase"}
_persist(sessions, session)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
assert bus.inbound.empty()
restored = restarted.get_or_create("websocket:chat")
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "checkpoint_unknown"
assert restored.metadata[RECOVERY_METADATA_KEY]["can_continue"] is False
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
assert PENDING_USER_TURN_KEY not in restored.metadata
assert [message["role"] for message in restored.messages] == ["user", "assistant"]
assert restored.messages[-1]["_recovery_interrupted"] is True
with pytest.raises(RecoveryActionError, match="context is unavailable"):
await coordinator.handle_action(
"continue",
{
"chat_id": "chat",
"recovery_id": restored.metadata[RECOVERY_METADATA_KEY]["recovery_id"],
},
)
dismissed = await coordinator.handle_action(
"dismiss",
{
"chat_id": "chat",
"recovery_id": restored.metadata[RECOVERY_METADATA_KEY]["recovery_id"],
},
)
assert dismissed["status"] == "recovered"
@pytest.mark.asyncio
async def test_malformed_checkpoint_can_always_be_dismissed(tmp_path: Path) -> None:
"""Corrupt private state must not trap the user in a failed recovery notice."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "deploy it"})
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
"phase": "future_phase",
"assistant_message": "invalid",
"completed_tool_results": 3,
"pending_tool_calls": [{"id": "call-1", "function": "invalid"}],
}
_persist(sessions, session)
coordinator, _, restarted = _coordinator(tmp_path)
await coordinator.scan()
restored = restarted.get_or_create("websocket:chat")
state = restored.metadata[RECOVERY_METADATA_KEY]
result = await coordinator.handle_action(
"dismiss",
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
)
assert result["status"] == "recovered"
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
@pytest.mark.asyncio
async def test_known_but_malformed_checkpoint_cannot_continue(tmp_path: Path) -> None:
"""A known phase does not make corrupt tool state safe to resume."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "send it"})
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
"phase": "tools_completed",
"assistant_message": {"role": "assistant", "content": "working"},
"completed_tool_results": "missing durable results",
"pending_tool_calls": [],
}
_persist(sessions, session)
coordinator, _, restarted = _coordinator(tmp_path)
await coordinator.scan()
restored = restarted.get_or_create("websocket:chat")
state = restored.metadata[RECOVERY_METADATA_KEY]
assert state["status"] == "awaiting_user"
assert state["reason"] == "checkpoint_invalid"
assert state["can_continue"] is False
with pytest.raises(RecoveryActionError, match="context is unavailable"):
await coordinator.handle_action(
"continue",
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
)
@pytest.mark.asyncio
async def test_malformed_final_response_is_not_reported_as_restored(tmp_path: Path) -> None:
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "answer"})
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
"phase": "final_response",
"assistant_message": "not an answer row",
"completed_tool_results": [],
"pending_tool_calls": [],
}
_persist(sessions, session)
coordinator, _, restarted = _coordinator(tmp_path)
await coordinator.scan()
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
assert state["status"] == "awaiting_user"
assert state["reason"] == "checkpoint_invalid"
assert state["can_continue"] is False
restored = restarted.get_or_create("websocket:chat")
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
assert PENDING_USER_TURN_KEY not in restored.metadata
assert [message["role"] for message in restored.messages] == ["user", "assistant"]
assert restored.messages[-1]["_recovery_interrupted"] is True
assert all("tool_calls" not in message for message in restored.messages)
@pytest.mark.asyncio
async def test_checkpoint_with_missing_tool_result_cannot_continue(tmp_path: Path) -> None:
"""Never resume when persisted results do not cover every requested tool."""
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "send both"})
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
"phase": "tools_completed",
"assistant_message": {
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "call-1", "function": {"name": "send_email"}},
{"id": "call-2", "function": {"name": "send_email"}},
],
},
"completed_tool_results": [
{
"role": "tool",
"tool_call_id": "call-1",
"name": "send_email",
"content": "sent",
}
],
"pending_tool_calls": [],
}
_persist(sessions, session)
coordinator, _, restarted = _coordinator(tmp_path)
await coordinator.scan()
restored = restarted.get_or_create("websocket:chat")
state = restored.metadata[RECOVERY_METADATA_KEY]
assert state["status"] == "awaiting_user"
assert state["reason"] == "checkpoint_invalid"
assert state["can_continue"] is False
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
assert PENDING_USER_TURN_KEY not in restored.metadata
assert [message["role"] for message in restored.messages] == ["user", "assistant"]
assert restored.messages[-1]["_recovery_interrupted"] is True
assert all("tool_calls" not in message for message in restored.messages)
@pytest.mark.asyncio
async def test_empty_final_response_is_not_reported_as_restored(tmp_path: Path) -> None:
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "answer"})
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
"phase": "final_response",
"assistant_message": {"role": "assistant", "content": ""},
"completed_tool_results": [],
"pending_tool_calls": [],
}
_persist(sessions, session)
coordinator, _, restarted = _coordinator(tmp_path)
await coordinator.scan()
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
assert state["status"] == "awaiting_user"
assert state["reason"] == "checkpoint_invalid"
assert state["can_continue"] is False
@pytest.mark.asyncio
async def test_final_answer_is_restored_without_model_call(tmp_path: Path) -> None:
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "answer"})
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
"phase": "final_response",
"assistant_message": {"role": "assistant", "content": "already finished"},
"completed_tool_results": [],
"pending_tool_calls": [],
}
_persist(sessions, session)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
assert bus.inbound.empty()
restored = restarted.get_or_create("websocket:chat")
assert restored.messages[-1]["content"] == "already finished"
first = bus.outbound.get_nowait().event
second = bus.outbound.get_nowait().event
assert isinstance(first, RecoveryStateEvent) and first.status == "recovered"
assert isinstance(second, SessionUpdatedEvent)
@pytest.mark.asyncio
async def test_explicit_recovery_continue_queues_once(tmp_path: Path) -> None:
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "continue"})
session.metadata[PENDING_USER_TURN_KEY] = True
_persist(sessions, session)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
assert bus.inbound.empty()
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
result = await coordinator.handle_action(
"continue",
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
)
assert result["status"] == "resuming"
assert bus.inbound.get_nowait().metadata["_webui_recovery_id"] == state["recovery_id"]
@pytest.mark.asyncio
async def test_new_user_message_supersedes_waiting_recovery(tmp_path: Path) -> None:
coordinator, bus, sessions = _coordinator(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.append({"role": "user", "content": "old request"})
session.metadata[PENDING_USER_TURN_KEY] = True
_persist(sessions, session)
await coordinator.scan()
newer = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat",
content="new request",
)
assert await coordinator.admit(newer) is True
restored = sessions.get_or_create("websocket:chat")
assert restored.messages[-1]["_recovery_interrupted"] is True
assert sum(
message.get("_recovery_interrupted") is True
for message in restored.messages
) == 1
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "superseded"
@pytest.mark.asyncio
async def test_new_user_message_cancels_active_recovery_task(tmp_path: Path) -> None:
coordinator, bus, sessions = _coordinator(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.metadata[RECOVERY_METADATA_KEY] = {
"status": "resuming",
"recovery_id": "active",
"attempts": 1,
}
_persist(sessions, session)
started = asyncio.Event()
async def _active_recovery() -> None:
started.set()
await asyncio.Event().wait()
task = asyncio.create_task(_active_recovery())
await started.wait()
coordinator.register_recovery_task("websocket:chat", task)
newer = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="chat",
content="new request",
)
assert await coordinator.admit(newer) is True
assert task.cancelled()
restored = sessions.get_or_create("websocket:chat")
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "superseded"
assert restored.messages[-1]["_recovery_interrupted"] is True
first = bus.outbound.get_nowait().event
second = bus.outbound.get_nowait().event
assert isinstance(first, RecoveryStateEvent)
assert isinstance(second, SessionUpdatedEvent)
assert bus.outbound.empty()
@pytest.mark.asyncio
async def test_recovery_action_rejects_stale_page_and_continues_current_state(
tmp_path: Path,
) -> None:
coordinator, bus, sessions = _coordinator(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.metadata[RECOVERY_METADATA_KEY] = {
"status": "awaiting_user",
"recovery_id": "current",
"attempts": 0,
}
_persist(sessions, session)
with pytest.raises(RecoveryActionError, match="stale"):
await coordinator.handle_action(
"continue",
{"chat_id": "chat", "recovery_id": "old"},
)
result = await coordinator.handle_action(
"continue",
{"chat_id": "chat", "recovery_id": "current"},
)
assert result["status"] == "resuming"
assert bus.inbound.get_nowait().metadata["_webui_recovery_id"] == "current"
@pytest.mark.asyncio
async def test_persisted_completion_wins_over_stale_resuming_marker(tmp_path: Path) -> None:
sessions = SessionManager(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.messages.extend(
[
{"role": "user", "content": "work"},
{"role": "assistant", "content": "done"},
]
)
session.metadata[RECOVERY_METADATA_KEY] = {
"status": "resuming",
"recovery_id": "recovery",
"attempts": 1,
"resume_message_count": 1,
}
_persist(sessions, session)
coordinator, bus, restarted = _coordinator(tmp_path)
await coordinator.scan()
assert bus.inbound.empty()
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
assert state["status"] == "recovered"
assert state["reason"] == "committed"
@pytest.mark.asyncio
async def test_dismiss_does_not_queue_work(tmp_path: Path) -> None:
coordinator, bus, sessions = _coordinator(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.metadata[RECOVERY_METADATA_KEY] = {
"status": "awaiting_user",
"recovery_id": "current",
"attempts": 0,
}
_persist(sessions, session)
result = await coordinator.handle_action(
"dismiss",
{"chat_id": "chat", "recovery_id": "current"},
)
assert result["status"] == "recovered"
assert bus.inbound.empty()
@pytest.mark.asyncio
async def test_scan_failure_is_visible_instead_of_aborting_other_sessions(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator, bus, sessions = _coordinator(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.metadata[PENDING_USER_TURN_KEY] = True
_persist(sessions, session)
async def fail(*_args: object, **_kwargs: object) -> None:
raise RuntimeError("boom")
monkeypatch.setattr(RecoveryCoordinator, "_recover_session", fail)
await coordinator.scan()
state = sessions.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
assert state["status"] == "failed"
assert state["can_continue"] is False
assert isinstance(bus.outbound.get_nowait().event, RecoveryStateEvent)
with pytest.raises(RecoveryActionError, match="context is unavailable"):
await coordinator.handle_action(
"continue",
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
)
@pytest.mark.asyncio
async def test_bus_remains_quiet_after_recovered_state(tmp_path: Path) -> None:
coordinator, bus, sessions = _coordinator(tmp_path)
session = sessions.get_or_create("websocket:chat")
session.metadata[RECOVERY_METADATA_KEY] = {
"status": "recovered",
"recovery_id": "done",
"attempts": 1,
}
_persist(sessions, session)
await coordinator.scan()
await asyncio.sleep(0)
assert bus.inbound.empty()
assert bus.outbound.empty()
+91
View File
@@ -1,6 +1,7 @@
from unittest.mock import MagicMock
import nanobot.session as session_api
from nanobot.providers.base import ProviderConversationState
from nanobot.session import Session, SessionManager
from nanobot.session.manager import SessionStore
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
@@ -123,3 +124,93 @@ def test_manager_preserves_full_session_before_store_save(tmp_path) -> None:
assert session.messages[0]["content"] == "0"
assert session.messages[-1]["content"] == "2000"
store.save.assert_called_once_with(session, fsync=False)
def test_runtime_checkpoint_does_not_rewrite_long_session(tmp_path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:long")
for index in range(256):
session.add_message("user", f"{index}:" + "x" * 4096)
manager.save(session)
main_path = manager._get_session_path(session.key)
main_before = main_path.read_bytes()
stat_before = main_path.stat()
session.metadata["runtime_checkpoint"] = {
"phase": "tools_completed",
"assistant_message": {"role": "assistant", "content": "working"},
"completed_tool_results": [],
"pending_tool_calls": [],
}
session.provider_state = ProviderConversationState(
kind="openai_responses",
provider="openai:test",
model="test-model",
version=1,
payload={"response_id": "private-response"},
)
manager.save_runtime_checkpoint(session)
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
assert main_path.read_bytes() == main_before
assert main_path.stat().st_ino == stat_before.st_ino
assert main_path.stat().st_mtime_ns == stat_before.st_mtime_ns
assert checkpoint_path.stat().st_size < len(main_before) // 100
restored = SessionManager(tmp_path).get_or_create(session.key)
assert restored.metadata["runtime_checkpoint"]["phase"] == "tools_completed"
assert restored.provider_state is not None
assert restored.provider_state.payload == {"response_id": "private-response"}
def test_completed_session_supersedes_stale_checkpoint(tmp_path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:completed")
session.add_message("user", "question")
manager.save(session)
session.metadata["runtime_checkpoint"] = {"phase": "awaiting_tools"}
manager.save_runtime_checkpoint(session)
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
stale_checkpoint = checkpoint_path.read_bytes()
session.metadata.pop("runtime_checkpoint")
session.add_message("assistant", "answer")
manager.save(session)
assert not checkpoint_path.exists()
# Emulate a process dying after the main record was committed but before an
# obsolete sidecar could be removed. The base fingerprint keeps it stale.
checkpoint_path.write_bytes(stale_checkpoint)
restored = SessionManager(tmp_path).get_or_create(session.key)
assert "runtime_checkpoint" not in restored.metadata
assert restored.messages[-1]["content"] == "answer"
assert not checkpoint_path.exists()
def test_delete_session_removes_runtime_checkpoint(tmp_path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:delete")
session.add_message("user", "question")
manager.save(session)
session.metadata["runtime_checkpoint"] = {"phase": "awaiting_tools"}
manager.save_runtime_checkpoint(session)
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
assert checkpoint_path.exists()
assert manager.delete_session(session.key) is True
assert not checkpoint_path.exists()
def test_invalid_runtime_checkpoint_is_discarded(tmp_path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:invalid-checkpoint")
session.add_message("user", "question")
manager.save(session)
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
checkpoint_path.write_text("{truncated", encoding="utf-8")
restored = SessionManager(tmp_path).get_or_create(session.key)
assert "runtime_checkpoint" not in restored.metadata
assert not checkpoint_path.exists()
+23 -3
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
from pathlib import Path
import nanobot.webui.transcript as transcript_module
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
from nanobot.webui.transcript import (
@@ -554,6 +556,22 @@ def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatc
assert out["completed_turn_ids"] == []
def test_recovery_tail_check_reads_only_the_active_transcript(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:recovery-tail"
active_path = transcript_module.webui_transcript_path(key)
reads: list[Path] = []
def read(path: Path) -> list[dict[str, object]]:
reads.append(path)
return [{"event": "message", "kind": "progress", "text": "running"}]
monkeypatch.setattr(transcript_module, "_read_transcript_file", read)
assert transcript_module.has_unfinished_transcript_tail(key) is True
assert reads == [active_path]
def test_thread_response_reports_active_registry_without_transcript(
tmp_path,
monkeypatch,
@@ -1485,7 +1503,7 @@ def test_replay_keeps_every_file_from_one_apply_patch_call() -> None:
assert [edit["path"] for edit in msgs[0]["fileEdits"]] == ["USER.md", "MEMORY.md"]
def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
def test_replay_keeps_interrupted_pre_tool_text_as_answer() -> None:
msgs = replay_transcript_to_ui_messages([
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
{"event": "stream_end", "chat_id": "t-stream"},
@@ -1504,8 +1522,10 @@ def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
assert len(msgs) == 3
assert msgs[0]["role"] == "assistant"
assert msgs[0]["content"] == ""
assert msgs[0]["reasoning"] == "I will inspect first."
assert msgs[0]["content"] == "I will inspect first."
assert msgs[0]["turnPhase"] == "answer"
assert "reasoning" not in msgs[0]
assert "activitySegmentId" not in msgs[0]
assert "isStreaming" not in msgs[0]
assert msgs[1]["kind"] == "trace"
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
+84
View File
@@ -15,6 +15,9 @@ import httpx
import pytest
import websockets
from nanobot.session.manager import SessionManager
from nanobot.session.recovery import PENDING_USER_TURN_KEY, RUNTIME_CHECKPOINT_KEY
_BOOTSTRAP_SECRET = "smoke-secret"
@@ -206,3 +209,84 @@ async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Pa
assert any("shell-ok" in text for text in contents)
finally:
_stop_gateway(process)
def test_gateway_restart_restores_a_completed_answer_without_replaying_model(
tmp_path: Path,
) -> None:
"""Exercise recovery through two real gateway processes and durable files."""
ws_port = _free_port()
gateway_port = _free_port()
workspace = tmp_path / "workspace"
workspace.mkdir()
config_path = tmp_path / "config.json"
first_log = tmp_path / "gateway-first.log"
second_log = tmp_path / "gateway-second.log"
_write_smoke_config(
config_path,
workspace=workspace,
ws_port=ws_port,
gateway_port=gateway_port,
)
base_url = f"http://127.0.0.1:{ws_port}"
first = _start_gateway(config_path, first_log)
try:
_wait_for_bootstrap(base_url, first, first_log)
finally:
_stop_gateway(first)
sessions_root = tmp_path / "sessions"
sessions = SessionManager(workspace, sessions_root=sessions_root)
session = sessions.get_or_create("websocket:recovery-smoke")
session.messages.append({"role": "user", "content": "recover this answer"})
session.metadata["webui"] = True
session.metadata[PENDING_USER_TURN_KEY] = True
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
"phase": "final_response",
"assistant_message": {
"role": "assistant",
"content": "restored without another model request",
},
"completed_tool_results": [],
"pending_tool_calls": [],
}
sessions.save(session, fsync=True)
second = _start_gateway(config_path, second_log)
try:
bootstrap = _wait_for_bootstrap(base_url, second, second_log)
deadline = time.monotonic() + 20
restored = None
while time.monotonic() < deadline:
restored = SessionManager(
workspace,
sessions_root=sessions_root,
).get_or_create("websocket:recovery-smoke")
if any(
message.get("content") == "restored without another model request"
for message in restored.messages
):
break
time.sleep(0.1)
else:
logs = second_log.read_text(encoding="utf-8", errors="replace")
raise AssertionError(f"answer was not recovered after restart\n{logs}")
assert restored is not None
assert PENDING_USER_TURN_KEY not in restored.metadata
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
assert restored.metadata["webui_recovery"]["reason"] == "answer_restored"
async def assert_attach_state() -> None:
ws_url = f'{bootstrap["ws_url"]}?token={bootstrap["token"]}&client_id=recovery-smoke'
async with websockets.connect(ws_url) as ws:
await _recv_until(ws, "ready")
await ws.send(json.dumps({"type": "attach", "chat_id": "recovery-smoke"}))
attached = await _recv_until(ws, "attached")
assert attached["recovery_state"]["status"] == "recovered"
assert attached["recovery_state"]["reason"] == "answer_restored"
asyncio.run(assert_attach_state())
finally:
_stop_gateway(second)
+25
View File
@@ -18,6 +18,7 @@ from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
from nanobot.session.manager import SessionManager
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
from nanobot.session.recovery import RECOVERY_METADATA_KEY
@pytest.fixture(autouse=True)
@@ -66,6 +67,30 @@ def test_webui_session_list_refreshes_after_model_preset_rename(tmp_path: Path)
assert list_webui_sessions(manager)[0]["model_preset"] == "Codex"
def test_webui_session_list_surfaces_pending_recovery_state(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:needs-attention")
session.add_message("user", "the interrupted task")
session.metadata[RECOVERY_METADATA_KEY] = {
"status": "awaiting_user",
"recovery_id": "recovery-123",
"reason": "uncertain_tool_state",
"attempts": 1,
# Private checkpoint details must never leak into the sidebar index.
"checkpoint": {"tool_args": "secret"},
}
manager.save(session)
row = list_webui_sessions(manager)[0]
assert row["recovery_state"] == {
"status": "awaiting_user",
"recovery_id": "recovery-123",
"reason": "uncertain_tool_state",
"attempts": 1,
}
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:unique-index-temp")
+225 -1
View File
@@ -7,7 +7,12 @@ import {
} from "@opentui/core/testing"
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
import type {
MessageOptions,
RecoveryState,
SlashCommand,
WorkspaceScopePayload,
} from "./protocol"
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
const options: AppOptions = {
@@ -91,6 +96,13 @@ function client(
setWorkspaceScope(scope: WorkspaceScopePayload) {
scopes.push(scope)
},
updateRecovery(
_action: "continue" | "dismiss",
_chatId: string,
recoveryId: string,
): Promise<RecoveryState> {
return Promise.resolve({ status: "recovered" as const, recovery_id: recoveryId })
},
}
}
@@ -555,6 +567,97 @@ describe("NanobotTui layout", () => {
}
})
test("switches away from a running session without losing its queued follow-ups", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch
globalThis.fetch = ((input: string | URL | Request) => {
const url = String(input)
if (url.endsWith("/api/sessions")) {
return Promise.resolve(new Response(JSON.stringify({
sessions: [
{ key: "websocket:chat", title: "Running chat", run_started_at: 1_700_000_000 },
{ key: "websocket:other", title: "Other chat" },
],
})))
}
if (url.endsWith("/api/webui/sidebar-state")) {
return Promise.resolve(new Response(JSON.stringify({})))
}
return Promise.resolve(new Response(JSON.stringify({
messages: [],
page: { has_more_before: false },
})))
}) as typeof fetch
const sent: string[] = []
const attached: string[] = []
let activeChatId = "chat"
const base = client(sent, attached)
const transport = {
...base,
get activeChatId() { return activeChatId },
attach(chatId: string) {
attached.push(chatId)
activeChatId = chatId
},
}
const app = NanobotTui.mount(
setup.renderer,
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
transport,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
const ui = app as unknown as {
ready: boolean
activeTurn: boolean
composer: TextareaRenderable
sessionMenu: { visible: boolean }
queuePreview: { root: { visible: boolean } }
status: { plainText: string }
}
try {
app.accept({ event: "attached", chat_id: "chat" })
await waitUntil(() => ui.ready)
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
ui.composer.setText("follow up in chat")
setup.mockInput.pressTab()
await waitUntil(() => ui.composer.plainText === "")
expect(ui.queuePreview.root.visible).toBe(true)
ui.composer.setText("/sessions")
ui.composer.submit()
await waitUntil(() => ui.sessionMenu.visible)
await Bun.sleep(120)
expect(ui.status.plainText).toContain("2 sessions")
ui.composer.setText("other")
ui.composer.submit()
await waitUntil(() => attached.at(-1) === "other")
app.accept({ event: "attached", chat_id: "other" })
await waitUntil(() => ui.ready)
expect(ui.activeTurn).toBe(false)
expect(ui.queuePreview.root.visible).toBe(false)
ui.composer.setText("/sessions")
ui.composer.submit()
await waitUntil(() => ui.sessionMenu.visible)
ui.composer.setText("running")
ui.composer.submit()
await waitUntil(() => attached.at(-1) === "chat")
app.accept({ event: "attached", chat_id: "chat" })
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
await waitUntil(() => ui.ready && ui.activeTurn)
expect(ui.queuePreview.root.visible).toBe(true)
expect(sent).toEqual([])
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" })
await waitUntil(() => sent.length === 1)
expect(sent).toEqual(["follow up in chat"])
} finally {
globalThis.fetch = original
}
})
test("refreshes expired API credentials before opening sessions", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch
@@ -903,6 +1006,127 @@ describe("NanobotTui layout", () => {
}
})
test("offers clickable recovery actions without letting a late response revive stale state", async () => {
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
const calls: Array<{ action: string; chatId: string; recoveryId: string }> = []
let deferredResolve: ((state: RecoveryState) => void) | undefined
const recoveryClient = client()
recoveryClient.updateRecovery = (action, chatId, recoveryId) => {
calls.push({ action, chatId, recoveryId })
if (recoveryId === "recovery-1") {
return Promise.resolve({ status: "resuming", recovery_id: recoveryId })
}
if (action === "dismiss") {
return Promise.resolve({ status: "recovered", recovery_id: recoveryId })
}
return new Promise((resolve) => { deferredResolve = resolve })
}
const app = NanobotTui.mount(
setup.renderer,
options,
recoveryClient,
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
)
app.accept({
event: "attached",
chat_id: "chat",
recovery_state: {
status: "awaiting_user",
recovery_id: "recovery-1",
reason: "tool execution interrupted",
},
})
const ui = app as unknown as {
activeTurn: boolean
composer: TextareaRenderable
recoveryNotice: {
visible: boolean
dismiss: TextRenderable
resume: TextRenderable
}
status: TextRenderable
}
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("⚠ Task interrupted")
expect(setup.captureCharFrame()).toContain("Tools will not replay automatically")
expect(ui.status.plainText).toContain("continue or dismiss")
expect(ui.activeTurn).toBe(false)
expect(ui.composer.focused).toBe(true)
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
await waitUntil(() => calls.length === 1 && ui.activeTurn)
expect(calls[0]).toEqual({
action: "continue",
chatId: "chat",
recoveryId: "recovery-1",
})
expect(ui.recoveryNotice.visible).toBe(false)
expect(ui.status.plainText).toContain("Continuing")
app.accept({
event: "recovery_state",
chat_id: "chat",
status: "awaiting_user",
recovery_id: "recovery-2",
})
await setup.renderOnce()
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
await waitUntil(() => calls.length === 2)
app.accept({
event: "recovery_state",
chat_id: "chat",
status: "recovered",
recovery_id: "recovery-2",
})
deferredResolve?.({ status: "resuming", recovery_id: "recovery-2" })
await Bun.sleep(1)
expect(ui.recoveryNotice.visible).toBe(false)
expect(ui.activeTurn).toBe(false)
expect(ui.composer.focused).toBe(true)
app.accept({
event: "recovery_state",
chat_id: "chat",
status: "awaiting_user",
recovery_id: "recovery-unavailable",
can_continue: false,
})
await setup.renderOnce()
const unavailableFrame = setup.captureCharFrame()
expect(unavailableFrame).toContain("cant be resumed safely")
expect(unavailableFrame).not.toContain("Continue")
expect(ui.status.plainText).toContain("dismiss to start a new message")
app.accept({
event: "recovery_state",
chat_id: "chat",
status: "awaiting_user",
recovery_id: "recovery-3",
})
await setup.renderOnce()
await setup.mockMouse.click(ui.recoveryNotice.dismiss.x + 1, ui.recoveryNotice.dismiss.y)
await waitUntil(() => calls.length === 3 && !ui.recoveryNotice.visible)
expect(calls[2]).toEqual({
action: "dismiss",
chatId: "chat",
recoveryId: "recovery-3",
})
app.accept({
event: "recovery_state",
chat_id: "chat",
status: "awaiting_user",
recovery_id: "recovery-4",
can_continue: false,
})
await setup.renderOnce()
expect(ui.recoveryNotice.resume.visible).toBe(false)
expect(ui.recoveryNotice.dismiss.visible).toBe(true)
})
test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const sent: string[] = []
+220 -20
View File
@@ -34,6 +34,7 @@ import {
type InboundEvent,
type MentionCandidate,
type MessageOptions,
type RecoveryState,
type SlashCommand,
type SessionSummary,
type TokenUsage,
@@ -70,6 +71,7 @@ import {
} from "./mention-menu"
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
import { RecoveryNotice, type RecoveryNoticeTheme } from "./recovery-notice"
import { RuntimeControls } from "./runtime-controls"
import {
contextualFooterHints,
@@ -107,6 +109,11 @@ interface ChatClient {
newChat(scope?: WorkspaceScopePayload): void
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
setWorkspaceScope(scope: WorkspaceScopePayload): void
updateRecovery(
action: "continue" | "dismiss",
chatId: string,
recoveryId: string,
): Promise<RecoveryState>
}
interface Palette {
@@ -118,6 +125,7 @@ interface Palette {
accent: string
link: string
success: string
warning: string
error: string
user: string
userBackground: string
@@ -134,6 +142,7 @@ const DARK: Palette = {
accent: "#EF8E30",
link: "#60A5FA",
success: "#5CC489",
warning: "#F5C451",
error: "#F87171",
user: "#EF8E30",
// Codex-style turn anchor: 12% white over the reference dark background.
@@ -151,6 +160,7 @@ const LIGHT: Palette = {
accent: "#B94D0B",
link: "#1D4ED8",
success: "#166534",
warning: "#A16207",
error: "#B91C1C",
user: "#B94D0B",
// Codex-style turn anchor: 4% black over the reference light background.
@@ -164,6 +174,7 @@ const ACTIVE_COMPOSER_PLACEHOLDER = "Steer this turn…"
const SHIMMER_PAUSE = 16
const SHIMMER_BAND = 4
const SHIMMER_INTERVAL_MS = 80
const SESSION_REFRESH_INTERVAL_MS = 1_000
const LOCAL_COMMANDS: TuiCommand[] = [
{
command: "/sessions",
@@ -252,6 +263,8 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme {
text: palette.text,
muted: palette.muted,
border: palette.border,
accent: palette.accent,
warning: palette.warning,
selectedBackground: palette.userBackground,
}
}
@@ -295,6 +308,17 @@ function queuePreviewTheme(palette: Palette): QueuePreviewTheme {
}
}
function recoveryNoticeTheme(palette: Palette): RecoveryNoticeTheme {
return {
text: palette.text,
muted: palette.muted,
border: palette.border,
accent: palette.accent,
warning: palette.warning,
error: palette.error,
}
}
function footerHintTheme(palette: Palette): FooterHintTheme {
return {
accent: palette.accent,
@@ -380,6 +404,7 @@ export class NanobotTui {
private readonly contextPanel: ContextPanel
private readonly diffViewer: DiffViewer
private readonly queuePreview: QueuePreview
private readonly recoveryNotice: RecoveryNotice
private readonly client: ChatClient
private readonly shell: BoxRenderable
private readonly title: BoxRenderable
@@ -390,7 +415,8 @@ export class NanobotTui {
private readonly meta: TextRenderable
private readonly host: TuiHost
private readonly draft = new ComposerDraft()
private readonly promptQueue = new PromptQueue()
private readonly promptQueues = new Map<string, PromptQueue>()
private currentChatId = ""
private palette: Palette
private activeThemeMode: ThemeMode
private backgroundKnown: boolean
@@ -436,6 +462,8 @@ export class NanobotTui {
private quitting = false
private sessionLoadId = 0
private sessionLoading = false
private sessionRefreshPending = false
private sessionRefreshTimer: ReturnType<typeof setInterval> | null = null
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
private readonly modelCommandTurns = new Set<string>()
private readonly silentCommandTurns = new Set<string>()
@@ -444,6 +472,8 @@ export class NanobotTui {
private currentTask = ""
private currentAction = ""
private hostBlocked = false
private recoveryState: RecoveryState | null = null
private recoveryPending = false
private hostWorkspace: string
private hostBranch: string
private readonly apiReauthenticator: ApiReauthenticator | undefined
@@ -495,6 +525,14 @@ export class NanobotTui {
treeSitterClient,
)
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
this.recoveryNotice = new RecoveryNotice(
renderer,
recoveryNoticeTheme(this.palette),
{
onContinue: () => void this.updateRecovery("continue"),
onDismiss: () => void this.updateRecovery("dismiss"),
},
)
this.client = client || new NanobotClient({
...(options.bootstrapUrl
? {
@@ -723,6 +761,7 @@ export class NanobotTui {
this.shell.add(this.runtimeControls.menuRoot)
if (!host.hosted) this.shell.add(this.title)
this.shell.add(this.queuePreview.root)
this.shell.add(this.recoveryNotice.root)
this.shell.add(this.composerFrame)
this.shell.add(statusRow)
this.shell.add(this.diffViewer.root)
@@ -831,6 +870,12 @@ export class NanobotTui {
this.quit()
return
}
if (["/continue", "/dismiss"].includes(visibleContent.toLowerCase())) {
this.clearComposer()
this.commandMenu.hide()
void this.updateRecovery(visibleContent.toLowerCase() === "/continue" ? "continue" : "dismiss")
return
}
const completion = this.commandMenu.completion(visibleContent)
if (completion) {
this.setComposer(completion)
@@ -925,6 +970,8 @@ export class NanobotTui {
accept(event: InboundEvent): void {
if (event.event === "attached") {
const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id)
this.currentChatId = event.chat_id
this.host.reportSession(event.chat_id)
if (event.usage) this.lastUsage = event.usage
if (event.model_preset !== undefined) {
@@ -946,7 +993,11 @@ export class NanobotTui {
}
const hydrationId = ++this.hydrationId
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
if (hydrationId === this.hydrationId) this.flushPendingEvents()
if (hydrationId !== this.hydrationId) return
this.applyRecoveryState(event.recovery_state ?? null)
this.flushPendingEvents()
this.syncQueuePreview()
if (switchedSession) this.sendNextFollowUp()
})
return
}
@@ -1083,6 +1134,9 @@ export class NanobotTui {
this.applyHostGoalState(event.goal_state)
if (!this.activeTurn) this.reportHostResting()
return
case "recovery_state":
this.applyRecoveryState(event)
return
case "turn_model_updated":
if (typeof event.context_window_tokens === "number") {
this.contextWindowTokens = event.context_window_tokens
@@ -1191,6 +1245,87 @@ export class NanobotTui {
for (const event of events || []) this.accept(event)
}
private clearRecoveryState(): void {
this.recoveryState = null
this.recoveryPending = false
this.recoveryNotice.hide()
}
private applyRecoveryState(state: RecoveryState | null): void {
if (!state) {
this.clearRecoveryState()
return
}
this.recoveryState = state
this.recoveryPending = false
if (state.status === "resuming") {
this.recoveryNotice.hide()
this.hostBlocked = false
this.activeLabel = "Continuing"
this.setCurrentAction("Continuing interrupted task")
this.setActive(true)
this.reportHostWorking()
return
}
if (state.status === "awaiting_user" || state.status === "failed") {
this.activeTurnId = null
this.setActive(false)
this.hostBlocked = true
this.recoveryNotice.show(state)
const detail = state.reason || (state.status === "failed"
? "Recovery failed"
: "Task interrupted")
this.setCurrentAction(detail)
this.status.content = state.can_continue === false
? "Interrupted · dismiss to start a new message"
: "Interrupted · continue or dismiss"
this.host.reportState("blocked", detail)
this.composer.focus()
return
}
this.clearRecoveryState()
this.activeTurnId = null
this.hostBlocked = false
this.setActive(false)
if (this.ready) this.status.content = this.readyStatus()
this.reportHostResting()
}
private async updateRecovery(action: "continue" | "dismiss"): Promise<void> {
const state = this.recoveryState
if (
!state
|| (state.status !== "awaiting_user" && state.status !== "failed")
|| (action === "continue" && state.can_continue === false)
) {
this.status.content = "No interrupted task"
this.composer.focus()
return
}
if (this.recoveryPending) return
this.recoveryPending = true
this.recoveryNotice.setBusy(true)
this.status.content = action === "continue" ? "Continuing…" : "Dismissing…"
try {
const next = await this.client.updateRecovery(
action,
this.client.activeChatId,
state.recovery_id,
)
if (this.recoveryState?.recovery_id === state.recovery_id) {
this.applyRecoveryState(next)
}
} catch (error) {
if (this.recoveryState?.recovery_id !== state.recovery_id) return
this.recoveryPending = false
this.recoveryNotice.setBusy(false)
this.status.content = error instanceof Error ? error.message : String(error)
this.host.reportState("blocked", state.reason || "Task interrupted")
} finally {
this.composer.focus()
}
}
private updateGatewayApiConnection(apiUrl: string, apiToken: string): void {
this.options.apiUrl = apiUrl
this.options.apiToken = apiToken
@@ -1282,6 +1417,7 @@ export class NanobotTui {
}
private renderActiveStatus(): void {
if (this.sessionLoading || this.sessionMenu.visible) return
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
@@ -1311,6 +1447,16 @@ export class NanobotTui {
this.sendPrompt(prompt)
}
private get promptQueue(): PromptQueue {
const chatId = this.currentChatId || this.client.activeChatId
let queue = this.promptQueues.get(chatId)
if (!queue) {
queue = new PromptQueue()
this.promptQueues.set(chatId, queue)
}
return queue
}
private restoreQueuedPrompts(): void {
const queued = this.promptQueue.restore()
if (!queued.length) return
@@ -1576,6 +1722,7 @@ export class NanobotTui {
this.contextPanel.setTheme(contextPanelTheme(this.palette))
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
this.queuePreview.setTheme(queuePreviewTheme(this.palette))
this.recoveryNotice.setTheme(recoveryNoticeTheme(this.palette))
this.updateComposerAppearance()
this.composer.textColor = this.palette.text
this.composer.focusedTextColor = this.palette.text
@@ -1858,7 +2005,7 @@ export class NanobotTui {
private closeTransientMenus(): void {
this.commandMenu.hide()
this.sessionMenu.hide()
this.hideSessionMenu()
this.mentionMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
@@ -1912,7 +2059,7 @@ export class NanobotTui {
return
}
this.commandMenu.hide()
this.sessionMenu.hide()
this.hideSessionMenu()
this.contextPanel.hide()
this.clearComposer()
this.status.content = "Loading branch points…"
@@ -1974,10 +2121,6 @@ export class NanobotTui {
}
private async openSessions(): Promise<void> {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
this.commandMenu.hide()
this.dismissRuntimeControls()
this.mentionMenu.hide()
@@ -2001,10 +2144,17 @@ export class NanobotTui {
this.sessionTitle = sessionLabel(current)
this.applySessionModel(current)
this.applySessionScope(current)
this.applyRecoveryState(current.recoveryState ?? null)
this.updateTitle()
}
const limit = this.renderer.height >= 20 ? 8 : 4
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
this.sessionMenu.open(
sessions,
this.client.activeChatId,
limit,
this.defaultModelPreset,
)
this.startSessionRefresh()
this.renderTitleColor()
this.sessionMenu.update(this.composer.plainText, limit)
this.syncComposerPlaceholder()
@@ -2019,17 +2169,14 @@ export class NanobotTui {
}
private switchSession(session: SessionSummary): void {
if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
this.sessionMenu.markRead(session.chatId)
if (session.chatId === this.client.activeChatId) {
this.sessionTitle = sessionLabel(session)
this.applySessionModel(session)
this.applySessionScope(session)
this.applyRecoveryState(session.recoveryState ?? null)
this.updateTitle()
this.closeSessions()
this.status.content = this.readyStatus()
return
}
if (!this.ready) {
@@ -2039,7 +2186,10 @@ export class NanobotTui {
this.closeSessions()
try {
this.ready = false
this.clearPromptQueue()
this.activeTurnId = null
this.setActive(false)
this.clearRecoveryState()
this.queuePreview.update([])
this.sessionMetadataId += 1
this.clearHostContext()
this.sessionTitle = sessionLabel(session)
@@ -2066,13 +2216,14 @@ export class NanobotTui {
return
}
this.commandMenu.hide()
this.sessionMenu.hide()
this.hideSessionMenu()
this.mentionMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
this.clearComposer()
try {
this.ready = false
this.clearRecoveryState()
this.clearPromptQueue()
this.sessionMetadataId += 1
this.clearHostContext()
@@ -2173,18 +2324,65 @@ export class NanobotTui {
private closeSessions(): void {
this.sessionLoadId += 1
this.sessionLoading = false
this.sessionMenu.hide()
this.hideSessionMenu()
this.renderTitleColor()
this.clearComposer()
this.syncComposerPlaceholder()
this.composer.focus()
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
if (this.activeTurn) this.renderActiveStatus()
else if (this.ready) this.status.content = this.readyStatus()
this.updateMeta()
}
private hideSessionMenu(): void {
this.stopSessionRefresh()
this.sessionMenu.hide()
}
private startSessionRefresh(): void {
if (this.sessionRefreshTimer) return
this.sessionRefreshTimer = setInterval(() => {
if (!this.sessionMenu.visible) {
this.stopSessionRefresh()
return
}
void this.refreshSessionMenu()
}, SESSION_REFRESH_INTERVAL_MS)
;(this.sessionRefreshTimer as unknown as { unref?: () => void }).unref?.()
}
private stopSessionRefresh(): void {
if (this.sessionRefreshTimer) clearInterval(this.sessionRefreshTimer)
this.sessionRefreshTimer = null
}
private async refreshSessionMenu(): Promise<void> {
if (this.sessionRefreshPending || !this.sessionMenu.visible || this.quitting) return
this.sessionRefreshPending = true
const loadId = this.sessionLoadId
try {
const sessions = await fetchSessions(
this.options.apiUrl,
this.options.apiToken,
this.apiReauthenticator,
)
if (this.quitting || loadId !== this.sessionLoadId || !this.sessionMenu.visible) return
this.sessionMenu.replace(
sessions,
this.client.activeChatId,
this.defaultModelPreset,
)
this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions"
} catch {
// Keep the existing picker usable during a transient refresh failure.
} finally {
this.sessionRefreshPending = false
}
}
private async openContext(): Promise<void> {
this.commandMenu.hide()
this.sessionMenu.hide()
this.hideSessionMenu()
this.mentionMenu.hide()
this.branchMenu.hide()
this.clearComposer()
@@ -2256,7 +2454,7 @@ export class NanobotTui {
private openDiff(): void {
this.commandMenu.hide()
this.sessionMenu.hide()
this.hideSessionMenu()
this.mentionMenu.hide()
this.branchMenu.hide()
this.contextPanel.hide()
@@ -2320,6 +2518,7 @@ export class NanobotTui {
this.quitting = true
this.submitGeneration += 1
this.submitPending = false
this.stopSessionRefresh()
this.host.release()
this.client.close()
this.renderer.destroy()
@@ -2330,6 +2529,7 @@ export class NanobotTui {
private handleDestroy = (): void => {
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.stopSessionRefresh()
this.transcript.destroy()
this.diffViewer.destroy()
this.host.release()
+1 -1
View File
@@ -5,7 +5,7 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
export type CommandMenuTheme = PickerMenuTheme
export type TuiCommandAction =
type TuiCommandAction =
| "sessions"
| "new-chat"
| "context"
+46 -3
View File
@@ -1,22 +1,27 @@
import {
BoxRenderable,
RGBA,
StyledText,
TextAttributes,
TextRenderable,
type CliRenderer,
type TextChunk,
} from "@opentui/core"
export interface PickerMenuTheme {
text: string
muted: string
border: string
accent?: string
warning?: string
selectedBackground?: string
}
interface PickerMenuOptions<T> {
id: string
key?: (item: T) => string
searchText: (item: T) => string
render: (item: T) => string
render: (item: T, selected: boolean) => string | TextChunk[]
emptyText?: string
maxWidth?: number
onSelect?: (item: T) => void
@@ -68,9 +73,21 @@ export class PickerMenu<T> {
this.update(query, limit)
}
replace(items: T[]): void {
if (!this.visible) return
this.items = items
this.update(this.query, this.limit)
}
redraw(): void {
if (this.visible) this.render()
}
update(query: string, limit = this.limit): void {
if (!this.visible) return
const changed = query !== this.query
const previous = this.matches[this.selected]
const previousKey = previous === undefined ? null : this.options.key?.(previous)
this.query = query
this.limit = Math.max(1, limit)
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
@@ -80,7 +97,18 @@ export class PickerMenu<T> {
return words.every((word) => haystack.includes(word))
})
.slice(0, this.limit)
this.selected = changed ? 0 : Math.min(this.selected, Math.max(0, this.matches.length - 1))
if (changed) {
this.selected = 0
} else {
const preserved = previous === undefined
? -1
: previousKey === null || previousKey === undefined
? this.matches.indexOf(previous)
: this.matches.findIndex((item) => this.options.key?.(item) === previousKey)
this.selected = preserved >= 0
? preserved
: Math.min(this.selected, Math.max(0, this.matches.length - 1))
}
this.render()
}
@@ -125,9 +153,16 @@ export class PickerMenu<T> {
}
for (const [index, item] of this.matches.entries()) {
const selected = index === this.selected
const rendered = this.options.render(item, selected)
const content = typeof rendered === "string"
? `${selected ? "" : " "} ${rendered}`
: new StyledText([
chunk(`${selected ? "" : " "} `, selected ? this.theme.text : this.theme.muted),
...rendered,
])
this.root.add(new TextRenderable(this.renderer, {
id: `${this.options.id}-${index}`,
content: `${selected ? "" : " "} ${this.options.render(item)}`,
content,
width: "100%",
height: 1,
wrapMode: "none",
@@ -161,3 +196,11 @@ export class PickerMenu<T> {
}
}
}
function chunk(text: string, color: string): TextChunk {
return {
__isChunk: true,
text,
fg: RGBA.fromHex(color),
}
}
+56
View File
@@ -595,6 +595,62 @@ describe("gateway protocol", () => {
}
})
test("sends stale-safe recovery mutations and validates their response", async () => {
const original = globalThis.WebSocket
let socket: FakeSocket | undefined
Object.defineProperty(globalThis, "WebSocket", {
configurable: true,
value: class extends FakeSocket {
constructor() {
super()
socket = this
}
},
})
try {
const statuses: string[] = []
const client = new NanobotClient({
url: "ws://nanobot.test/ws",
onEvent: () => undefined,
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
})
client.connect()
if (!socket) throw new Error("socket was not created")
const result = client.updateRecovery("continue", "chat", "recovery-1")
const request = JSON.parse(socket.sent.at(-1) || "{}") as {
request_id: string
action: string
payload: Record<string, string>
}
expect(request).toMatchObject({
type: "webui_request",
action: "recovery.continue",
payload: { chat_id: "chat", recovery_id: "recovery-1" },
})
socket.emit("message", { data: JSON.stringify({
event: "webui_response",
request_id: request.request_id,
ok: true,
result: { status: "resuming", recovery_id: "recovery-1", attempts: 1 },
}) })
expect(await result).toEqual({
status: "resuming",
recovery_id: "recovery-1",
attempts: 1,
})
expect(statuses).not.toContain("error:gateway sent an invalid event")
const interrupted = client.updateRecovery("dismiss", "chat", "recovery-2")
socket.emit("close")
await expect(interrupted).rejects.toThrow("gateway connection closed")
client.close()
} finally {
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
}
})
test("reports when the bounded history snapshot omits earlier turns", async () => {
const original = globalThis.fetch
let requested = ""
+135 -4
View File
@@ -29,14 +29,14 @@ export interface FileEditEvent {
diff?: FileDiff
}
export interface FileDiff {
interface FileDiff {
format: "unified" | string
context?: number
truncated?: boolean
text?: string
}
export interface MediaAttachment {
interface MediaAttachment {
kind: "image" | "video" | "file"
url: string
name?: string
@@ -54,6 +54,16 @@ export interface RuntimeControls {
canUseFullAccess: boolean
}
export type RecoveryStatus = "resuming" | "awaiting_user" | "recovered" | "failed"
export interface RecoveryState {
status: RecoveryStatus
recovery_id: string
reason?: string
attempts?: number
can_continue?: boolean
}
export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string }
| {
@@ -61,6 +71,7 @@ export type InboundEvent =
chat_id: string
model_preset?: string | null
usage?: TokenUsage
recovery_state?: RecoveryState
}
| {
event: "message_accepted"
@@ -118,6 +129,7 @@ export type InboundEvent =
turn_id?: string
}
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
| ({ event: "recovery_state"; chat_id: string } & RecoveryState)
| {
event: "session_updated"
chat_id: string
@@ -139,6 +151,12 @@ type OutboundEvent =
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| {
type: "webui_request"
request_id: string
action: string
payload: Record<string, unknown>
}
| {
type: "message"
chat_id: string
@@ -225,7 +243,7 @@ export interface SessionContextSnapshot {
lastUsage: TokenUsage | null
}
export interface SessionMention {
interface SessionMention {
name: string
session_key: string
title?: string
@@ -271,6 +289,7 @@ export interface SessionSummary {
updatedAt: string | null
runStartedAt: number | null
modelPreset: string | null
recoveryState?: RecoveryState | null
workspaceScope?: WorkspaceScopePayload | null
pinned: boolean
archived: boolean
@@ -297,6 +316,7 @@ const CHAT_EVENTS = new Set([
"turn_end",
"goal_status",
"goal_state",
"recovery_state",
"session_updated",
"turn_model_updated",
"error",
@@ -377,6 +397,34 @@ function isWorkspaceScope(value: unknown): value is WorkspaceScopePayload {
&& optional(value.restrict_to_workspace, "boolean")
}
function isRecoveryState(value: unknown): value is RecoveryState {
return isRecord(value)
&& ["resuming", "awaiting_user", "recovered", "failed"].includes(String(value.status))
&& typeof value.recovery_id === "string"
&& optional(value.reason, "string")
&& optional(value.attempts, "number")
&& optional(value.can_continue, "boolean")
}
interface WebUIResponseEvent {
event: "webui_response"
request_id: string
ok: boolean
result?: unknown
error?: { status: number; message: string }
}
function decodeWebUIResponse(value: unknown): WebUIResponseEvent | null | undefined {
if (!isRecord(value) || value.event !== "webui_response") return undefined
if (typeof value.request_id !== "string" || typeof value.ok !== "boolean") return null
if (value.ok) return value as unknown as WebUIResponseEvent
return isRecord(value.error)
&& typeof value.error.status === "number"
&& typeof value.error.message === "string"
? value as unknown as WebUIResponseEvent
: null
}
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (!isRecord(value)) return null
const record = value
@@ -407,7 +455,8 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
&& ((record.model_preset !== undefined
&& record.model_preset !== null
&& typeof record.model_preset !== "string")
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|| (record.usage !== undefined && !isTokenUsage(record.usage))
|| (record.recovery_state !== undefined && !isRecoveryState(record.recovery_state)))
) return null
if (
["user_message", "message", "delta", "reasoning_delta"].includes(name)
@@ -452,6 +501,7 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
if (name === "goal_state" && !isRecord(record.goal_state)) return null
if (name === "recovery_state" && !isRecoveryState(record)) return null
if (
name === "session_updated"
&& (!optional(record.scope, "string")
@@ -700,6 +750,9 @@ export async function fetchSessions(
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
? value.model_preset.trim()
: null,
...(isRecoveryState(value.recovery_state)
? { recoveryState: value.recovery_state }
: {}),
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
pinned: pinned.has(value.key),
archived: archived.has(value.key),
@@ -855,6 +908,11 @@ export class NanobotClient {
private closedByClient = false
private opening = false
private connectedOnce = false
private readonly pendingMutations = new Map<string, {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}>()
constructor(private readonly options: ClientOptions) {}
@@ -916,6 +974,7 @@ export class NanobotClient {
socket.addEventListener("close", () => {
if (this.socket !== socket) return
this.socket = null
this.rejectPendingMutations("gateway connection closed")
if (this.closedByClient) {
this.options.onStatus("closed")
return
@@ -931,6 +990,7 @@ export class NanobotClient {
const socket = this.socket
this.socket = null
socket?.close()
this.rejectPendingMutations("gateway connection closed")
}
send(content: string, options: MessageOptions = {}): string {
@@ -975,6 +1035,63 @@ export class NanobotClient {
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope })
}
updateRecovery(
action: "continue" | "dismiss",
chatId: string,
recoveryId: string,
): Promise<RecoveryState> {
return this.requestMutation<unknown>(`recovery.${action}`, {
chat_id: chatId,
recovery_id: recoveryId,
}).then((result) => {
if (!isRecoveryState(result)) throw new Error("gateway returned an invalid recovery state")
return result
})
}
private requestMutation<T>(
action: string,
payload: Record<string, unknown> = {},
timeoutMs = 20_000,
): Promise<T> {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway connection is not open"))
}
const requestId = crypto.randomUUID()
const frame = JSON.stringify({
type: "webui_request",
request_id: requestId,
action,
payload,
} satisfies OutboundEvent)
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingMutations.delete(requestId)
reject(new Error(`gateway request timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingMutations.set(requestId, {
resolve: (value) => resolve(value as T),
reject,
timer,
})
try {
this.socket?.send(frame)
} catch {
clearTimeout(timer)
this.pendingMutations.delete(requestId)
reject(new Error("could not send gateway request"))
}
})
}
private rejectPendingMutations(message: string): void {
for (const pending of this.pendingMutations.values()) {
clearTimeout(pending.timer)
pending.reject(new Error(message))
}
this.pendingMutations.clear()
}
private handleMessage(raw: string): void {
let value: unknown
try {
@@ -983,6 +1100,20 @@ export class NanobotClient {
this.options.onStatus("error", "gateway sent invalid JSON")
return
}
const response = decodeWebUIResponse(value)
if (response === null) {
this.options.onStatus("error", "gateway sent an invalid event")
return
}
if (response) {
const pending = this.pendingMutations.get(response.request_id)
if (!pending) return
clearTimeout(pending.timer)
this.pendingMutations.delete(response.request_id)
if (response.ok) pending.resolve(response.result)
else pending.reject(new Error(response.error?.message || "gateway request failed"))
return
}
const event = decodeInboundEvent(value)
if (event === undefined) return
if (event === null) {
+177
View File
@@ -0,0 +1,177 @@
import {
BoxRenderable,
RGBA,
StyledText,
TextAttributes,
TextRenderable,
type CliRenderer,
type TextChunk,
} from "@opentui/core"
import type { RecoveryState } from "./protocol"
export interface RecoveryNoticeTheme {
text: string
muted: string
border: string
accent: string
warning: string
error: string
}
interface RecoveryNoticeOptions {
onContinue: () => void
onDismiss: () => void
}
/** A quiet action surface for a gateway-owned interrupted turn. */
export class RecoveryNotice {
readonly root: BoxRenderable
private readonly title: TextRenderable
private readonly detail: TextRenderable
private readonly dismiss: TextRenderable
private readonly resume: TextRenderable
private state: RecoveryState | null = null
private busy = false
constructor(
renderer: CliRenderer,
private theme: RecoveryNoticeTheme,
options: RecoveryNoticeOptions,
) {
this.root = new BoxRenderable(renderer, {
id: "nanobot-tui-recovery-notice",
width: "100%",
height: 4,
flexShrink: 0,
flexDirection: "column",
border: true,
borderStyle: "rounded",
borderColor: theme.border,
paddingLeft: 1,
paddingRight: 1,
visible: false,
backgroundColor: RGBA.defaultBackground(),
})
const header = new BoxRenderable(renderer, {
id: "nanobot-tui-recovery-header",
width: "100%",
height: 1,
flexDirection: "row",
alignItems: "center",
gap: 2,
})
this.title = new TextRenderable(renderer, {
id: "nanobot-tui-recovery-title",
width: "auto",
minWidth: 0,
flexGrow: 1,
height: 1,
truncate: true,
selectable: false,
})
this.detail = new TextRenderable(renderer, {
id: "nanobot-tui-recovery-detail",
width: "100%",
height: 1,
truncate: true,
selectable: false,
})
this.dismiss = this.action(renderer, "dismiss", "Dismiss", options.onDismiss)
this.resume = this.action(renderer, "continue", "Continue", options.onContinue, true)
header.add(this.title)
header.add(this.dismiss)
header.add(this.resume)
this.root.add(header)
this.root.add(this.detail)
}
get visible(): boolean {
return this.root.visible
}
show(state: RecoveryState): void {
this.state = state
this.busy = false
this.root.visible = true
this.render()
}
hide(): void {
this.state = null
this.busy = false
this.root.visible = false
}
setBusy(busy: boolean): void {
this.busy = busy
if (this.visible) this.render()
}
setTheme(theme: RecoveryNoticeTheme): void {
this.theme = theme
this.root.borderColor = theme.border
if (this.visible) this.render()
}
private action(
renderer: CliRenderer,
id: string,
label: string,
callback: () => void,
primary = false,
): TextRenderable {
return new TextRenderable(renderer, {
id: `nanobot-tui-recovery-${id}`,
content: label,
width: label.length,
height: 1,
flexShrink: 0,
selectable: false,
onMouseOver: () => {
if (this.busy) return
const target = primary ? this.resume : this.dismiss
target.attributes = TextAttributes.BOLD | TextAttributes.UNDERLINE
},
onMouseOut: () => this.render(),
onMouseDown: (event) => {
if (event.button !== 0 || this.busy) return
event.preventDefault()
event.stopPropagation()
renderer.clearSelection()
callback()
},
})
}
private render(): void {
if (!this.state) return
const failed = this.state.status === "failed"
const contextUnavailable = this.state.can_continue === false
const title = failed ? "Recovery failed" : "Task interrupted"
const detail = failed
? "Review the saved task before continuing."
: contextUnavailable
? "This task cant be resumed safely. Dismiss to start a new message."
: "Review the saved context. Tools will not replay automatically."
this.title.content = new StyledText([
chunk("⚠ ", failed ? this.theme.error : this.theme.warning),
chunk(title, this.theme.text, true),
])
this.detail.content = new StyledText([chunk(` ${detail}`, this.theme.muted)])
this.dismiss.fg = RGBA.fromHex(this.busy ? this.theme.muted : this.theme.text)
this.resume.visible = !contextUnavailable
this.resume.fg = RGBA.fromHex(this.busy ? this.theme.muted : this.theme.accent)
this.dismiss.attributes = 0
this.resume.attributes = TextAttributes.BOLD
}
}
function chunk(text: string, color: string, bold = false): TextChunk {
return {
__isChunk: true,
text,
fg: RGBA.fromHex(color),
attributes: bold ? TextAttributes.BOLD : 0,
}
}
+108 -2
View File
@@ -24,6 +24,11 @@ const sessions: SessionSummary[] = [
updatedAt: "2026-08-12T10:00:00Z",
runStartedAt: null,
modelPreset: null,
recoveryState: {
status: "awaiting_user",
recovery_id: "recovery-two",
reason: "tool execution interrupted",
},
pinned: false,
archived: false,
},
@@ -43,17 +48,21 @@ describe("SessionMenu", () => {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
menu.open(sessions, "one", 6)
menu.open(sessions, "one", 6, "Codex")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain(" ● API migration")
expect(setup.captureCharFrame()).not.toContain("Move authentication")
expect(setup.captureCharFrame()).not.toContain("Codex")
expect(menu.choose()?.chatId).toBe("one")
menu.update("release stable", 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Release checklist")
expect(setup.captureCharFrame()).toContain("Release checklist")
expect(menu.choose()?.chatId).toBe("two")
})
@@ -63,12 +72,80 @@ describe("SessionMenu", () => {
)
})
test("animates running sessions and marks completed background sessions unread", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
const running = {
...sessions[0]!,
chatId: "running",
title: "Background task",
runStartedAt: Date.now(),
pinned: false,
}
menu.open([sessions[0]!, running], "one", 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).toMatch(/[] Background task/u)
menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "one")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("• Background task")
menu.markRead("running")
menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "running")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("● Background task")
menu.hide()
})
test("prioritizes actionable sessions without moving keyboard selection on refresh", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
const running = {
...sessions[0]!,
chatId: "running",
title: "Background task",
runStartedAt: Date.now(),
pinned: false,
}
const idle = { ...sessions[1]!, recoveryState: null }
menu.open([sessions[0]!, running, idle], "one", 6)
expect(menu.choose()?.chatId).toBe("one")
expect(menu.move(1)).toBe(true)
expect(menu.choose()?.chatId).toBe("running")
menu.replace([sessions[0]!, running, sessions[1]!], "one")
await setup.renderOnce()
expect(menu.choose()?.chatId).toBe("running")
const frame = setup.captureCharFrame()
expect(frame.indexOf("Release checklist")).toBeLessThan(frame.indexOf("Background task"))
menu.hide()
})
test("keeps keyboard selection when the pointer stays over the previous row", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
menu.open(sessions, "one", 6)
@@ -97,6 +174,8 @@ describe("SessionMenu", () => {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
const scoped = sessions.map((session) => ({
@@ -154,4 +233,31 @@ describe("SessionMenu", () => {
expect(duplicates).toContain("frontend/nanobot")
expect(duplicates).toContain("backend/nanobot")
})
test("shows only model overrides and keeps previews searchable", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
menu.open(sessions, "one", 6, "Codex")
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).not.toContain("Codex")
expect(frame).not.toContain("Prepare the stable release")
menu.update("prepare stable", 6)
await setup.renderOnce()
expect(menu.choose()?.chatId).toBe("two")
menu.replace([{ ...sessions[1]!, modelPreset: "Deep Research" }], "one", "Codex")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Deep Research")
menu.hide()
})
})
+165 -14
View File
@@ -1,9 +1,11 @@
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
import { RGBA, type BoxRenderable, type CliRenderer, type TextChunk } from "@opentui/core"
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
import type { SessionSummary } from "./protocol"
type SessionMenuRow = SessionSummary & { active: boolean }
type SessionMenuRow = SessionSummary & { active: boolean; unread: boolean }
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
export function sessionLabel(session: SessionSummary): string {
const label = session.title.trim() || session.preview.trim() || "Untitled chat"
@@ -27,14 +29,24 @@ export class SessionMenu {
private readonly picker: PickerMenu<SessionMenuRow>
private readonly workspaceLabels = new Map<string, string>()
private showWorkspaces = false
private spinnerFrame = 0
private spinnerTimer: ReturnType<typeof setInterval> | null = null
private rows: SessionMenuRow[] = []
private defaultModelPreset = ""
private readonly snapshots = new Map<string, {
preview: string
runStartedAt: number | null
}>()
private readonly unreadChatIds = new Set<string>()
constructor(
renderer: CliRenderer,
theme: PickerMenuTheme,
private theme: PickerMenuTheme,
onSelect?: (session: SessionSummary) => void,
) {
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
id: "nanobot-tui-session-menu",
key: (session) => session.chatId,
searchText: (session) => [
sessionLabel(session),
session.modelPreset || "",
@@ -42,20 +54,27 @@ export class SessionMenu {
session.chatId,
session.workspaceScope?.project_name || "",
session.workspaceScope?.project_path || "",
session.recoveryState?.status || "",
session.recoveryState?.reason || "",
].join(" "),
render: (session) => {
render: (session, selected) => {
const age = updatedLabel(session.updatedAt)
const preview = session.preview.trim()
const detail = [
this.showWorkspaces ? this.workspaceLabel(session) : "",
session.modelPreset,
age,
preview && preview !== sessionLabel(session) ? preview : "",
this.modelOverride(session),
]
.filter(Boolean)
.join(" · ")
const marker = session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""
return `${marker}${sessionLabel(session)}${detail ? ` ${detail}` : ""}`
const marker = this.marker(session)
const foreground = this.interrupted(session)
? this.theme.warning || this.theme.accent || this.theme.text
: selected ? this.theme.text : this.theme.muted
return [
...(marker ? [chunk(`${marker.text} `, marker.color)] : []),
chunk(sessionLabel(session), foreground),
...(detail ? [chunk(` ${detail}`, this.theme.muted)] : []),
...(age ? [chunk(` ${age}`, this.theme.muted)] : []),
]
},
emptyText: "No matching sessions",
onSelect,
@@ -67,16 +86,51 @@ export class SessionMenu {
return this.picker.visible
}
open(sessions: SessionSummary[], currentChatId: string, limit: number): void {
open(
sessions: SessionSummary[],
currentChatId: string,
limit: number,
defaultModelPreset = "",
): void {
this.defaultModelPreset = defaultModelPreset
this.observe(sessions, currentChatId)
this.rows = this.prepareRows(sessions, currentChatId)
this.picker.show(this.rows, "", limit)
this.syncSpinner()
}
replace(
sessions: SessionSummary[],
currentChatId: string,
defaultModelPreset = this.defaultModelPreset,
): void {
this.defaultModelPreset = defaultModelPreset
this.observe(sessions, currentChatId)
this.rows = this.prepareRows(sessions, currentChatId)
this.picker.replace(this.rows)
this.syncSpinner()
}
markRead(chatId: string): void {
this.unreadChatIds.delete(chatId)
}
private prepareRows(sessions: SessionSummary[], currentChatId: string): SessionMenuRow[] {
this.prepareWorkspaceLabels(sessions)
const rows = sessions
.map((session) => ({ ...session, active: session.chatId === currentChatId }))
return sessions
.map((session) => ({
...session,
active: session.chatId === currentChatId,
unread: this.unreadChatIds.has(session.chatId),
}))
.sort((left, right) => {
return Number(right.active) - Number(left.active)
|| sessionPriority(right) - sessionPriority(left)
|| Number(right.pinned) - Number(left.pinned)
|| Number(left.archived) - Number(right.archived)
|| timestamp(right.updatedAt) - timestamp(left.updatedAt)
|| sessionLabel(left).localeCompare(sessionLabel(right))
})
this.picker.show(rows, "", limit)
}
update(query: string, limit: number): void {
@@ -92,13 +146,84 @@ export class SessionMenu {
}
hide(): void {
this.stopSpinner()
this.rows = []
this.picker.hide()
}
setTheme(theme: PickerMenuTheme): void {
this.theme = theme
this.picker.setTheme(theme)
}
private marker(session: SessionMenuRow): { text: string; color: string } | null {
if (this.interrupted(session)) {
return {
text: "⚠",
color: this.theme.warning || this.theme.accent || this.theme.text,
}
}
if (session.runStartedAt !== null) {
return {
text: SPINNER_FRAMES[this.spinnerFrame % SPINNER_FRAMES.length] || SPINNER_FRAMES[0]!,
color: this.theme.accent || this.theme.text,
}
}
if (session.active) return { text: "●", color: this.theme.text }
if (session.unread) return { text: "•", color: this.theme.accent || this.theme.text }
if (session.pinned) return { text: "◆", color: this.theme.muted }
if (session.archived) return { text: "◇", color: this.theme.muted }
return null
}
private interrupted(session: SessionMenuRow): boolean {
return session.recoveryState?.status === "awaiting_user"
|| session.recoveryState?.status === "failed"
}
private observe(sessions: SessionSummary[], currentChatId: string): void {
const present = new Set<string>()
for (const session of sessions) {
present.add(session.chatId)
const previous = this.snapshots.get(session.chatId)
const active = session.chatId === currentChatId
const completed = previous !== undefined
&& previous.runStartedAt !== null
&& session.runStartedAt === null
const receivedContent = previous !== undefined && previous.preview !== session.preview
if (active) this.unreadChatIds.delete(session.chatId)
else if (completed || receivedContent) this.unreadChatIds.add(session.chatId)
this.snapshots.set(session.chatId, {
preview: session.preview,
runStartedAt: session.runStartedAt,
})
}
for (const chatId of this.snapshots.keys()) {
if (present.has(chatId)) continue
this.snapshots.delete(chatId)
this.unreadChatIds.delete(chatId)
}
}
private syncSpinner(): void {
if (!this.visible || !this.rows.some((session) => session.runStartedAt !== null)) {
this.stopSpinner()
return
}
if (this.spinnerTimer) return
this.spinnerTimer = setInterval(() => {
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length
this.picker.redraw()
}, 90)
;(this.spinnerTimer as unknown as { unref?: () => void }).unref?.()
}
private stopSpinner(): void {
if (this.spinnerTimer) clearInterval(this.spinnerTimer)
this.spinnerTimer = null
this.spinnerFrame = 0
}
private prepareWorkspaceLabels(sessions: SessionSummary[]): void {
this.workspaceLabels.clear()
const scopes = sessions.flatMap((session) => {
@@ -122,6 +247,19 @@ export class SessionMenu {
private workspaceLabel(session: SessionSummary): string {
return this.workspaceLabels.get(normalizeWorkspacePath(session.workspaceScope?.project_path)) || ""
}
private modelOverride(session: SessionSummary): string {
const preset = session.modelPreset?.trim() || ""
return preset && preset !== this.defaultModelPreset ? preset : ""
}
}
function chunk(text: string, color: string): TextChunk {
return {
__isChunk: true,
text,
fg: RGBA.fromHex(color),
}
}
function normalizeWorkspacePath(value: string | undefined): string {
@@ -136,3 +274,16 @@ function shortPath(path: string): string {
const parts = path.split("/").filter(Boolean)
return parts.slice(-2).join("/") || path
}
function sessionPriority(session: SessionMenuRow): number {
if (session.recoveryState?.status === "awaiting_user"
|| session.recoveryState?.status === "failed") return 3
if (session.runStartedAt !== null) return 2
return session.unread ? 1 : 0
}
function timestamp(value: string | null): number {
if (!value) return 0
const parsed = Date.parse(value)
return Number.isNaN(parsed) ? 0 : parsed
}
+10
View File
@@ -1274,6 +1274,15 @@ function Shell({
}, [activeKey, activeTabKey, activeTabState]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const recoveryChatIdList = useMemo(
() => sessions
.filter((session) => (
session.recoveryState?.status === "awaiting_user"
|| session.recoveryState?.status === "failed"
))
.map((session) => session.chatId),
[sessions],
);
const activeChatId = activePaneSession?.chatId ?? null;
useEffect(() => {
activeChatIdRef.current = activeChatId;
@@ -2552,6 +2561,7 @@ function Shell({
collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList,
updatedChatIds: updatedChatIdList,
recoveryChatIds: recoveryChatIdList,
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
showArchived: sidebarState.view.show_archived,
archivedCount: sidebarArchivedTabKeys.length,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next";
export type ChannelFieldMessages = {
type ChannelFieldMessages = {
label: string;
placeholder?: string;
help?: string;
+29 -1
View File
@@ -11,6 +11,7 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactElement } from
import {
Archive,
ArchiveRestore,
AlertTriangle,
ChevronDown,
Folder,
FolderTree,
@@ -260,6 +261,7 @@ interface ChatListProps {
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
updatedChatIds?: string[];
recoveryChatIds?: string[];
density?: SidebarDensity;
showPreviews?: boolean;
showTimestamps?: boolean;
@@ -302,6 +304,7 @@ export const ChatList = memo(function ChatList({
collapsedGroups = {},
runningChatIds = [],
updatedChatIds = [],
recoveryChatIds = [],
density = "comfortable",
showPreviews = false,
showTimestamps = false,
@@ -558,6 +561,7 @@ export const ChatList = memo(function ChatList({
const running = new Set(runningChatIds);
const updated = new Set(updatedChatIds);
const recovery = new Set(recoveryChatIds);
const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
@@ -881,6 +885,7 @@ export const ChatList = memo(function ChatList({
compact={compact}
running={running}
updated={updated}
recovery={recovery}
onSelectPane={onSelectPane}
onRequestDelete={onRequestDelete}
onRequestRename={onRequestRename}
@@ -915,6 +920,8 @@ export const ChatList = memo(function ChatList({
: "";
const activityState = running.has(s.chatId)
? "running"
: recovery.has(s.chatId)
? "recovery"
: updated.has(s.chatId) && !topicActive
? "updated"
: null;
@@ -1330,6 +1337,7 @@ function ActivePaneRows({
compact,
running,
updated,
recovery,
onSelectPane,
onRequestDelete,
onRequestRename,
@@ -1354,6 +1362,7 @@ function ActivePaneRows({
compact: boolean;
running: ReadonlySet<string>;
updated: ReadonlySet<string>;
recovery: ReadonlySet<string>;
onSelectPane?: (tabKey: string, paneKey: string) => void;
onRequestDelete: (key: string, label: string) => void;
onRequestRename: (key: string, label: string) => void;
@@ -1390,6 +1399,8 @@ function ActivePaneRows({
const active = tabActive && pane.key === group.activePaneKey;
const activityState = running.has(pane.chatId)
? "running"
: recovery.has(pane.chatId)
? "recovery"
: updated.has(pane.chatId) && !active
? "updated"
: null;
@@ -1852,10 +1863,27 @@ function ChatsFoldFooter({
function SessionActivityIndicator({
state,
}: {
state: "running" | "updated" | null;
state: "running" | "updated" | "recovery" | null;
}) {
const { t } = useTranslation();
if (state === "recovery") {
const label = t("chat.activity.recovery", {
defaultValue: "This conversation needs your attention",
});
return (
<SidebarItemTooltip label={label}>
<span
role="img"
aria-label={label}
className="grid h-4 w-4 shrink-0 place-items-center text-[#ff8a3d]"
>
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
</span>
</SidebarItemTooltip>
);
}
if (state === "running") {
const label = t("chat.activity.running");
return (
+3 -3
View File
@@ -137,7 +137,7 @@ export function CapabilityMentionToken({
return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />;
}
export function SessionMentionToken({
function SessionMentionToken({
mention,
label,
variant,
@@ -172,7 +172,7 @@ export function SessionMentionToken({
);
}
export function CliAppMentionToken({
function CliAppMentionToken({
app,
label,
variant,
@@ -229,7 +229,7 @@ export function CliAppMentionToken({
);
}
export function McpPresetMentionToken({
function McpPresetMentionToken({
preset,
label,
variant,
+2 -2
View File
@@ -151,7 +151,7 @@ export function splitFilePath(path: string): { directory: string; name: string }
};
}
export function fileKindForPath(path: string): FileReferenceKind {
function fileKindForPath(path: string): FileReferenceKind {
const normalized = path.toLowerCase();
const name = normalized.split(/[\\/]/).pop() ?? normalized;
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
@@ -193,7 +193,7 @@ export function fileKindForPath(path: string): FileReferenceKind {
}
}
export function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
if (kind === "python") {
return (
<svg
+2 -2
View File
@@ -967,7 +967,7 @@ interface ReasoningBubbleProps {
hasBodyBelow: boolean;
}
export function ReasoningBubble({
function ReasoningBubble({
text,
streaming,
hasBodyBelow,
@@ -993,7 +993,7 @@ interface TraceGroupProps {
* collapsed because tool traces are supporting evidence, not the answer.
* A single click expands the exact calls when the user wants details.
*/
export function TraceGroup({ message }: TraceGroupProps) {
function TraceGroup({ message }: TraceGroupProps) {
const { t } = useTranslation();
const lines = message.traces ?? [message.content];
const count = lines.length;
+2
View File
@@ -82,6 +82,7 @@ interface SidebarProps {
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
updatedChatIds?: string[];
recoveryChatIds?: string[];
viewState?: SidebarViewState;
showArchived?: boolean;
archivedCount?: number;
@@ -270,6 +271,7 @@ export function Sidebar(props: SidebarProps) {
collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds}
updatedChatIds={props.updatedChatIds}
recoveryChatIds={props.recoveryChatIds}
density={props.viewState?.density}
showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps}
@@ -174,7 +174,7 @@ export function ChannelLogo({
);
}
export function channelDisplayName(feature: NanobotFeatureInfo): string {
function channelDisplayName(feature: NanobotFeatureInfo): string {
return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name;
}
@@ -112,7 +112,7 @@ export function ChannelSetupLinks({
);
}
export function ChannelOfficialLink({
function ChannelOfficialLink({
feature,
setup,
}: {
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
import { cn } from "@/lib/utils";
export function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
}
@@ -27,7 +27,7 @@ export type ChannelSetupPresentation = {
presets?: ChannelProviderPreset[];
};
export type ChannelCatalogSetupPresentation = {
type ChannelCatalogSetupPresentation = {
mode?: "webui" | "credentials" | "connect";
command?: string;
docsUrl?: string;
@@ -38,15 +38,15 @@ export type ChannelCatalogSetupPresentation = {
presets?: ChannelProviderPresetDefinition[];
};
export type ChannelFieldPresentation = {
type ChannelFieldPresentation = {
key: string;
};
export type ChannelSetupActionDefinition = Omit<ChannelSetupAction, "label">;
type ChannelSetupActionDefinition = Omit<ChannelSetupAction, "label">;
export type ChannelProviderPresetDefinition = Omit<ChannelProviderPreset, "label">;
export type ChannelSetupAction = {
type ChannelSetupAction = {
id: string;
label: string;
url?: string;
@@ -72,7 +72,7 @@ export type ChannelConfigField = {
options?: ChannelConfigOption[];
};
export type ChannelConfigOption = {
type ChannelConfigOption = {
value: string;
label: string;
};
+1 -1
View File
@@ -14,7 +14,7 @@ export type SettingsSectionKey =
| "runtime"
| "advanced";
export type PendingRestartSection = "runtime" | "browser" | "image";
type PendingRestartSection = "runtime" | "browser" | "image";
export type PendingRestartSections = Record<PendingRestartSection, boolean>;
export type RestartAwarePayload = {
@@ -421,6 +421,37 @@ export function AppearanceSettings({
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.browserNotifications", "Task notifications")}
description={tx(
"settings.help.browserNotifications",
"Notify only when this page is in the background. Off by default.",
)}
>
<ToggleButton
checked={localPrefs.browserNotifications}
onChange={(enabled) => {
if (!enabled) {
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: false }));
return;
}
if (typeof Notification === "undefined") return;
if (Notification.permission === "granted") {
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
return;
}
void Notification.requestPermission().then((permission) => {
if (permission === "granted") {
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
}
});
}}
ariaLabel={tx("settings.rows.browserNotifications", "Task notifications")}
label={localPrefs.browserNotifications
? tx("settings.values.on", "On")
: tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
</section>
</div>
@@ -51,7 +51,7 @@ import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessag
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
export { isAgentActivityMember, isReasoningOnlyAssistant };
export { isAgentActivityMember };
interface ActivityCounts {
reasoningSteps: number;
@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { AlertTriangle, LoaderCircle, RotateCcw, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { RecoveryState } from "@/lib/types";
interface RecoveryNoticeProps {
state: RecoveryState;
onContinue: () => Promise<void>;
onDismiss: () => Promise<void>;
}
export function RecoveryNotice({ state, onContinue, onDismiss }: RecoveryNoticeProps) {
const { t } = useTranslation();
const [pending, setPending] = useState<"continue" | "dismiss" | null>(null);
const [error, setError] = useState<string | null>(null);
const [hiddenRecoveryId, setHiddenRecoveryId] = useState<string | null>(null);
useEffect(() => {
// A continuation can be interrupted again with the same recovery ID.
// Reveal the decision surface when the server returns to a waiting state.
if (state.status === "awaiting_user" || state.status === "failed") {
setHiddenRecoveryId(null);
}
}, [state.recovery_id, state.status]);
if (state.status === "recovered" || hiddenRecoveryId === state.recovery_id) return null;
const waiting = state.status === "awaiting_user" || state.status === "failed";
const contextUnavailable = state.can_continue === false;
const title = state.status === "failed"
? t("recovery.failed", { defaultValue: "Task recovery failed" })
: waiting
? t("recovery.interrupted", { defaultValue: "Task interrupted" })
: t("recovery.resuming", { defaultValue: "Restoring interrupted task…" });
const detail = state.status === "failed" || contextUnavailable
? t("recovery.failedHelp", {
defaultValue: "The saved task could not be restored safely. Review it before continuing.",
})
: waiting
? t("recovery.review", { defaultValue: "Review the task before continuing. Tools will not be replayed automatically." })
: t("recovery.safeResume", { defaultValue: "Continuing from saved conversation context." });
const run = (action: "continue" | "dismiss") => {
setPending(action);
setError(null);
// ``resuming`` is an internal transition, not another task for the user
// to monitor. Hide the notice optimistically and only bring it back if
// the explicit action is rejected.
if (action === "continue") setHiddenRecoveryId(state.recovery_id);
const operation = action === "continue" ? onContinue() : onDismiss();
void operation.catch(() => {
if (action === "continue") setHiddenRecoveryId(null);
setError(t("recovery.actionFailed", { defaultValue: "Recovery action failed. Try again." }));
}).finally(() => setPending(null));
};
return (
<div
role={waiting ? "alert" : "status"}
aria-live={waiting ? "assertive" : "polite"}
aria-busy={state.status === "resuming"}
data-recovery-status={state.status}
className="mx-auto mb-2 flex w-full max-w-[49.5rem] items-center gap-3 rounded-control border border-border/70 bg-muted/35 px-3 py-2 text-sm transition-[background-color,border-color,opacity,transform] duration-200 ease-out motion-reduce:transition-none animate-in fade-in-0 slide-in-from-bottom-1 duration-200 motion-reduce:animate-none"
>
{waiting ? (
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
) : (
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-primary motion-reduce:animate-none" aria-hidden />
)}
<div className="min-w-0 flex-1">
<p className="font-medium">
{title}
</p>
<p className={cn(
"mt-0.5 text-xs",
error ? "text-destructive" : "text-muted-foreground",
)}>
{error ?? detail}
</p>
</div>
{waiting ? (
<div className="flex shrink-0 items-center gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
disabled={pending !== null}
onClick={() => run("dismiss")}
>
<X className="mr-1 h-3.5 w-3.5" aria-hidden />
{t("recovery.dismiss", { defaultValue: "Dismiss" })}
</Button>
{!contextUnavailable ? (
<Button
type="button"
size="sm"
disabled={pending !== null}
onClick={() => run("continue")}
>
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden />
{t("recovery.continue", { defaultValue: "Continue" })}
</Button>
) : null}
</div>
) : null}
</div>
);
}
@@ -355,11 +355,15 @@ function activeTurnStartIndex(units: DisplayUnit[], activeTurnId: string | null)
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
if (previous.type !== next.type) return false;
if (previous.type === "message" && next.type === "message") {
return shallowMessageEqual(previous.message, next.message);
return (
previous.sourceMessageCount === next.sourceMessageCount
&& shallowMessageEqual(previous.message, next.message)
);
}
if (previous.type !== "activity" || next.type !== "activity") return false;
return (
previous.turnLatencyMs === next.turnLatencyMs
previous.sourceMessageCount === next.sourceMessageCount
&& previous.turnLatencyMs === next.turnLatencyMs
&& previous.startedAtMs === next.startedAtMs
&& previous.messages.length === next.messages.length
&& previous.messages.every((message, index) =>
@@ -383,7 +387,7 @@ function unitIndexAfterMessageCount(
let seen = 0;
for (let i = 0; i < units.length; i += 1) {
const unit = units[i];
seen += unit.type === "activity" ? unit.messages.length : 1;
seen += unit.sourceMessageCount;
if (seen >= messageCount) return i;
}
return null;
+20 -2
View File
@@ -7,6 +7,7 @@ import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailab
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { RecoveryNotice } from "@/components/thread/RecoveryNotice";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import {
ThreadComposer,
@@ -763,6 +764,9 @@ export function ThreadShell({
isStreaming,
runStartedAt,
goalState,
recoveryState,
continueRecovery,
dismissRecovery,
send,
transcribeAudio,
stop,
@@ -835,8 +839,15 @@ export function ThreadShell({
[displayMessages],
);
const currentGoalState = messagesReady ? goalState : undefined;
const currentRunStartedAt = messagesReady ? runStartedAt : null;
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
// Decision states freeze the interrupted turn and hand the next action to
// the recovery notice. ``resuming`` remains active; ``recovered`` is only
// historical metadata and must not suppress a later normal turn.
const recoveryNeedsDecision = recoveryState?.status === "awaiting_user"
|| recoveryState?.status === "failed";
const currentRunStartedAt = messagesReady && !recoveryNeedsDecision ? runStartedAt : null;
const turnActive = messagesReady
&& !recoveryNeedsDecision
&& (isStreaming || currentRunStartedAt !== null);
const restoredViewportTurnId = useMemo(
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
[currentRunStartedAt, displayMessages, turnActive],
@@ -1472,6 +1483,13 @@ export function ThreadShell({
const composer = (
<>
{recoveryState ? (
<RecoveryNotice
state={recoveryState}
onContinue={continueRecovery}
onDismiss={dismissRecovery}
/>
) : null}
{streamError && !hasInlineDeliveryError(messages, streamError) ? (
<StreamErrorNotice
error={streamError}
@@ -155,7 +155,7 @@ function FileEditRow({
);
}
export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
return edit.added > 0 || edit.deleted > 0;
}
@@ -3,7 +3,7 @@ import { compactActivityPath, redactActivityText } from "./activity-text";
export type GenericToolStatus = "running" | "done" | "error";
export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic";
export interface ToolField {
interface ToolField {
key:
| "query"
| "pattern"
@@ -7,7 +7,7 @@ import { displayWebHost, formatCompactWebUrl, parseSafeActivityHttpUrl } from ".
export type WebSearchStatus = "running" | "done" | "error";
export type WebSearchTarget = "web" | "x";
export interface WebSearchSource {
interface WebSearchSource {
title: string;
href: string;
host: string;
@@ -26,13 +26,13 @@ export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
});
}
export function promptLabel(content: string, index: number): string {
function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
return truncatePreview(text, 80);
}
export function promptPreview(content: string, index: number): string {
function promptPreview(content: string, index: number): string {
const text = compactPreview(content);
if (!text) return `Prompt ${index + 1}`;
return truncatePreview(text, 320);
+1 -1
View File
@@ -33,7 +33,7 @@ const buttonVariants = cva(
},
);
export interface ButtonProps
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from "react";
import { formControlFocusClassName } from "@/components/ui/form-control";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from "react";
import { formControlFocusClassName } from "@/components/ui/form-control";
import { cn } from "@/lib/utils";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
@@ -12,7 +12,7 @@ export type {
export const MAX_WORKBENCH_PANES = 4;
export const WORKBENCH_LAYOUTS = [
const WORKBENCH_LAYOUTS = [
"columns",
"rows",
"grid",
+5 -5
View File
@@ -9,10 +9,10 @@ import type { WebUIIngressLimits } from "@/lib/types";
* - ``ready`` ``dataUrl`` available; safe to submit
* - ``error`` validation / decode failure; chip shows inline error
*/
export type AttachmentStatus = "encoding" | "ready" | "error";
type AttachmentStatus = "encoding" | "ready" | "error";
export type AttachmentKind = "image" | "file";
export interface AttachedAttachment {
interface AttachedAttachment {
id: string;
kind: AttachmentKind;
file: File;
@@ -32,7 +32,7 @@ export interface AttachedAttachment {
export type AttachedImage = AttachedAttachment;
export interface RestoredReadyAttachment {
interface RestoredReadyAttachment {
dataUrl: string;
name?: string;
kind?: AttachmentKind;
@@ -55,8 +55,8 @@ export type AttachmentError =
| "io"; // file read failed at the browser layer
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
+2 -2
View File
@@ -11,7 +11,7 @@ import { acceptedAttachmentKind } from "@/hooks/useAttachedImages";
* - Plain text pasted alongside attachments is *not* consumed by this helper,
* so the caller can still let the textarea receive it naturally.
*/
export function extractImageFilesFromPaste(
function extractImageFilesFromPaste(
event: ClipboardEvent | React.ClipboardEvent,
): File[] {
const clipboard = (event as ClipboardEvent).clipboardData
@@ -27,7 +27,7 @@ export function extractImageFilesFromPaste(
}
/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
export function extractImageFilesFromDrop(
function extractImageFilesFromDrop(
event: DragEvent | React.DragEvent,
): File[] {
const dt = (event as DragEvent).dataTransfer
+102
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media";
@@ -28,6 +29,7 @@ import {
} from "@/lib/thread-event-projection";
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import { readLocalPreferences } from "@/lib/local-preferences";
import type {
InboundEvent,
OutboundCliAppMention,
@@ -36,6 +38,7 @@ import type {
SessionMention,
GoalStateWsPayload,
MessageDeliveryStatus,
RecoveryState,
UIMediaAttachment,
UIMessage,
WorkspaceScopePayload,
@@ -244,6 +247,9 @@ export function useNanobotStream(
runStartedAt: number | null;
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
goalState: GoalStateWsPayload | undefined;
recoveryState: RecoveryState | null;
continueRecovery: () => Promise<void>;
dismissRecovery: () => Promise<void>;
send: (
content: string,
images?: SendAttachment[],
@@ -262,6 +268,7 @@ export function useNanobotStream(
dismissStreamError: () => void;
} {
const { client } = useClient();
const { t } = useTranslation();
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
@@ -273,6 +280,7 @@ export function useNanobotStream(
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
const [recoveryState, setRecoveryState] = useState<RecoveryState | null>(null);
const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null);
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
@@ -288,6 +296,16 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []);
const notifyInBackground = useCallback((body: string) => {
if (
typeof Notification === "undefined"
|| Notification.permission !== "granted"
|| document.visibilityState === "visible"
|| !readLocalPreferences().browserNotifications
) return;
new Notification("nanobot", { body });
}, []);
const clearPendingStreamWork = useCallback(() => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
@@ -639,6 +657,7 @@ export function useNanobotStream(
setStreamError(null);
setRunStartedAt(restoredRunStartedAt);
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
setRecoveryState(null);
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
@@ -846,10 +865,71 @@ export function useNanobotStream(
return finalized;
});
suppressStreamUntilTurnEndRef.current = false;
notifyInBackground(t("recovery.completed", { defaultValue: "Task completed" }));
onTurnEnd?.();
return;
}
if (ev.event === "recovery_state") {
const next: RecoveryState = {
status: ev.status,
recovery_id: ev.recovery_id,
...(ev.reason ? { reason: ev.reason } : {}),
...(typeof ev.attempts === "number" ? { attempts: ev.attempts } : {}),
...(typeof ev.can_continue === "boolean"
? { can_continue: ev.can_continue }
: {}),
};
setRecoveryState(next);
if (ev.status === "resuming") {
setRunStartedAt((current) => current ?? Date.now() / 1000);
setIsStreaming(true);
}
if (
ev.status === "awaiting_user"
|| ev.status === "recovered"
|| ev.status === "failed"
) {
// Recovery is an explicit boundary. The interrupted turn is no
// longer running, so do not let the stale start time keep the
// activity clock (or composer stop state) alive underneath the
// recovery notice.
setRunStartedAt(null);
setIsStreaming(false);
client.finishRunLocally(chatId);
clearPendingStreamWork();
closeActiveAssistantStream();
clearActivitySegment();
if (ev.status !== "recovered") {
notifyInBackground(
ev.status === "failed"
? t("recovery.failed", { defaultValue: "Task recovery failed" })
: t("recovery.interrupted", { defaultValue: "Task interrupted" }),
);
}
}
return;
}
if (ev.event === "attached") {
setRecoveryState(ev.recovery_state ?? null);
if (ev.recovery_state?.status === "resuming") {
setRunStartedAt((current) => current ?? Date.now() / 1000);
setIsStreaming(true);
} else if (
ev.recovery_state?.status === "awaiting_user"
|| ev.recovery_state?.status === "failed"
) {
setRunStartedAt(null);
setIsStreaming(false);
client.finishRunLocally(chatId);
clearPendingStreamWork();
closeActiveAssistantStream();
clearActivitySegment();
}
return;
}
if (ev.event === "message") {
if (
suppressStreamUntilTurnEndRef.current &&
@@ -1062,8 +1142,10 @@ export function useNanobotStream(
ensureActivitySegmentId,
flushPendingStreamEvents,
isSideChannelEvent,
notifyInBackground,
onTurnEnd,
schedulePendingStreamFlush,
t,
]);
const send = useCallback(
@@ -1173,12 +1255,32 @@ export function useNanobotStream(
[client],
);
const recoveryAction = useCallback(async (action: "continue" | "dismiss") => {
if (!chatId || !recoveryState) return;
await client.requestMutation(`recovery.${action}`, {
chat_id: chatId,
recovery_id: recoveryState.recovery_id,
});
}, [chatId, client, recoveryState]);
const continueRecovery = useCallback(
() => recoveryAction("continue"),
[recoveryAction],
);
const dismissRecovery = useCallback(
() => recoveryAction("dismiss"),
[recoveryAction],
);
return {
messages,
messagesReady: messageOwnerChatId === chatId,
isStreaming,
runStartedAt,
goalState,
recoveryState,
continueRecovery,
dismissRecovery,
send,
transcribeAudio,
stop,
+2 -2
View File
@@ -5,7 +5,7 @@ import { normalizeWorkbenchState } from "@/components/workbench/workbench-model"
import { fetchSidebarState } from "@/lib/api";
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
schema_version: 1,
pinned_keys: [],
archived_keys: [],
@@ -74,7 +74,7 @@ function boolMap(value: unknown): Record<string, boolean> {
return out;
}
export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
function normalizeSidebarState(raw: unknown): SidebarStatePayload {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } };
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function normalizeLocale(
return baseMatch?.code ?? defaultLocale;
}
export function readStoredLocale(): SupportedLocale | null {
function readStoredLocale(): SupportedLocale | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
+1 -3
View File
@@ -10,7 +10,6 @@ import {
applyDocumentLocale,
defaultLocale,
fallbackLocale,
LOCALE_STORAGE_KEY,
normalizeLocale,
persistLocale,
resolveInitialLocale,
@@ -47,7 +46,7 @@ export function currentLocale(): SupportedLocale {
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
}
export async function loadLocaleResources(
async function loadLocaleResources(
locale: SupportedLocale,
): Promise<LocaleResource> {
const existing = resourcePromises.get(locale);
@@ -131,5 +130,4 @@ function syncLocaleSideEffects(language: string) {
persistLocale(locale);
}
export { LOCALE_STORAGE_KEY };
export default i18n;
+16 -1
View File
@@ -198,6 +198,7 @@
"fileEditDisplay": "File edit display",
"codeWrap": "Code wrapping",
"brandLogos": "Brand logos",
"browserNotifications": "Task notifications",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
@@ -243,6 +244,7 @@
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.",
"browserNotifications": "Notify only when this page is in the background. Off by default.",
"maxResults": "Results returned by each web_search call.",
"timeout": "Seconds before a search provider request times out.",
"jinaReader": "Use Jina Reader for web_fetch when available.",
@@ -1013,7 +1015,8 @@
"activity": {
"running": "Agent running",
"complete": "Agent finished",
"updated": "New activity"
"updated": "New activity",
"recovery": "This conversation needs your attention"
},
"pin": "Pin",
"unpin": "Unpin",
@@ -1436,6 +1439,18 @@
"estimated": "Includes estimated usage"
}
},
"recovery": {
"actionFailed": "Recovery action failed. Try again.",
"interrupted": "Task interrupted",
"completed": "Task completed",
"failed": "Task recovery failed",
"failedHelp": "The saved task could not be restored safely. Review it before continuing.",
"resuming": "Restoring interrupted task…",
"review": "Review the task before continuing. Tools will not be replayed automatically.",
"safeResume": "Continuing from saved conversation context.",
"dismiss": "Dismiss",
"continue": "Continue"
},
"lightbox": {
"title": "Image preview",
"open": "View image",
+16 -1
View File
@@ -161,6 +161,7 @@
"webuiDefaultAccess": "Acceso predeterminado",
"currentModel": "Configuración actual",
"brandLogos": "Logos de marca",
"browserNotifications": "Notificaciones de tareas",
"cliAppsCatalog": "Catálogo",
"cliAppsFilter": "Filtro",
"engine": "Motor",
@@ -204,6 +205,7 @@
"selectedModelProvider": "Definido por el modelo seleccionado.",
"selectedModelValue": "Definido por el modelo seleccionado.",
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
"browserNotifications": "Notifica solo cuando esta página está en segundo plano. Desactivado de forma predeterminada.",
"cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.",
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
"logs": "Abre la carpeta de registros del motor nativo.",
@@ -1000,7 +1002,8 @@
"activity": {
"running": "Agente en ejecución",
"complete": "Agente terminado",
"updated": "Nueva actividad"
"updated": "Nueva actividad",
"recovery": "Esta conversación requiere tu atención"
},
"pin": "Fijar",
"unpin": "Desfijar",
@@ -1423,6 +1426,18 @@
"automationSourceFallback": "Automatización",
"automationTriggered": "Activada automáticamente"
},
"recovery": {
"actionFailed": "La recuperación falló. Inténtalo de nuevo.",
"interrupted": "Tarea interrumpida",
"completed": "Tarea completada",
"failed": "La recuperación de la tarea falló",
"failedHelp": "La tarea guardada no se pudo restaurar de forma segura. Revísala antes de continuar.",
"resuming": "Restaurando la tarea interrumpida…",
"review": "Revisa la tarea antes de continuar. Las herramientas no se repetirán automáticamente.",
"safeResume": "Continuando desde el contexto guardado de la conversación.",
"dismiss": "Descartar",
"continue": "Continuar"
},
"lightbox": {
"title": "Vista previa de imagen",
"open": "Ver imagen",
+16 -1
View File
@@ -161,6 +161,7 @@
"webuiDefaultAccess": "Accès par défaut",
"currentModel": "Configuration actuelle",
"brandLogos": "Logos de marque",
"browserNotifications": "Notifications de tâches",
"cliAppsCatalog": "Catalogue",
"cliAppsFilter": "Filtre",
"engine": "Moteur",
@@ -204,6 +205,7 @@
"selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "Défini par le modèle sélectionné.",
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
"browserNotifications": "Notifier uniquement lorsque cette page est en arrière-plan. Désactivé par défaut.",
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI dapplications que nanobot peut exécuter localement ; les applications natives restent inchangées.",
"cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
"logs": "Ouvre le dossier des journaux du moteur natif.",
@@ -999,7 +1001,8 @@
"activity": {
"running": "Agent en cours",
"complete": "Agent terminé",
"updated": "Nouvelle activité"
"updated": "Nouvelle activité",
"recovery": "Cette conversation nécessite votre attention"
},
"pin": "Épingler",
"unpin": "Désépingler",
@@ -1422,6 +1425,18 @@
"automationSourceFallback": "Automatisation",
"automationTriggered": "Déclenché automatiquement"
},
"recovery": {
"actionFailed": "La récupération a échoué. Réessayez.",
"interrupted": "Tâche interrompue",
"completed": "Tâche terminée",
"failed": "Échec de la récupération de la tâche",
"failedHelp": "La tâche enregistrée na pas pu être restaurée en toute sécurité. Vérifiez-la avant de continuer.",
"resuming": "Restauration de la tâche interrompue…",
"review": "Vérifiez la tâche avant de continuer. Les outils ne seront pas relancés automatiquement.",
"safeResume": "Reprise depuis le contexte de conversation enregistré.",
"dismiss": "Ignorer",
"continue": "Continuer"
},
"lightbox": {
"title": "Aperçu de limage",
"open": "Voir limage",
+16 -1
View File
@@ -161,6 +161,7 @@
"webuiDefaultAccess": "Akses bawaan",
"currentModel": "Konfigurasi saat ini",
"brandLogos": "Logo merek",
"browserNotifications": "Notifikasi tugas",
"cliAppsCatalog": "Katalog",
"cliAppsFilter": "Saring",
"engine": "Mesin",
@@ -204,6 +205,7 @@
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
"browserNotifications": "Beri tahu hanya saat halaman ini di latar belakang. Nonaktif secara bawaan.",
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
"logs": "Buka folder log mesin asli.",
@@ -999,7 +1001,8 @@
"activity": {
"running": "Agen sedang berjalan",
"complete": "Agen selesai",
"updated": "Aktivitas baru"
"updated": "Aktivitas baru",
"recovery": "Percakapan ini memerlukan perhatian Anda"
},
"pin": "Sematkan",
"unpin": "Lepas sematan",
@@ -1422,6 +1425,18 @@
"automationSourceFallback": "Otomatisasi",
"automationTriggered": "Dipicu otomatis"
},
"recovery": {
"actionFailed": "Pemulihan gagal. Coba lagi.",
"interrupted": "Tugas terputus",
"completed": "Tugas selesai",
"failed": "Pemulihan tugas gagal",
"failedHelp": "Tugas tersimpan tidak dapat dipulihkan dengan aman. Tinjau sebelum melanjutkan.",
"resuming": "Memulihkan tugas yang terputus…",
"review": "Tinjau tugas sebelum melanjutkan. Alat tidak akan dijalankan ulang secara otomatis.",
"safeResume": "Melanjutkan dari konteks percakapan yang tersimpan.",
"dismiss": "Abaikan",
"continue": "Lanjutkan"
},
"lightbox": {
"title": "Pratinjau gambar",
"open": "Lihat gambar",
+16 -1
View File
@@ -161,6 +161,7 @@
"webuiDefaultAccess": "既定の権限",
"currentModel": "現在の設定",
"brandLogos": "ブランドロゴ",
"browserNotifications": "タスク通知",
"cliAppsCatalog": "カタログ",
"cliAppsFilter": "フィルター",
"engine": "エンジン",
@@ -204,6 +205,7 @@
"selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
"browserNotifications": "このページがバックグラウンドにある場合のみ通知します。既定ではオフです。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
"logs": "ネイティブエンジンのログフォルダーを開きます。",
@@ -999,7 +1001,8 @@
"activity": {
"running": "エージェント実行中",
"complete": "エージェント完了",
"updated": "新しいアクティビティ"
"updated": "新しいアクティビティ",
"recovery": "この会話には対応が必要です"
},
"pin": "ピン留め",
"unpin": "ピン留めを解除",
@@ -1422,6 +1425,18 @@
"automationSourceFallback": "自動化",
"automationTriggered": "自動実行"
},
"recovery": {
"actionFailed": "復元操作に失敗しました。もう一度お試しください。",
"interrupted": "タスクが中断されました",
"completed": "タスクが完了しました",
"failed": "タスクの復元に失敗しました",
"failedHelp": "保存されたタスクを安全に復元できませんでした。続行前に確認してください。",
"resuming": "中断されたタスクを復元しています…",
"review": "続行する前にタスクを確認してください。ツールは自動的に再実行されません。",
"safeResume": "保存された会話コンテキストから続行しています。",
"dismiss": "閉じる",
"continue": "続行"
},
"lightbox": {
"title": "画像プレビュー",
"open": "画像を表示",
+16 -1
View File
@@ -161,6 +161,7 @@
"webuiDefaultAccess": "기본 권한",
"currentModel": "현재 구성",
"brandLogos": "브랜드 로고",
"browserNotifications": "작업 알림",
"cliAppsCatalog": "카탈로그",
"cliAppsFilter": "필터",
"engine": "엔진",
@@ -204,6 +205,7 @@
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
"browserNotifications": "이 페이지가 백그라운드에 있을 때만 알립니다. 기본값은 꺼짐입니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
@@ -999,7 +1001,8 @@
"activity": {
"running": "에이전트 실행 중",
"complete": "에이전트 완료",
"updated": "새 활동"
"updated": "새 활동",
"recovery": "이 대화에는 확인이 필요합니다"
},
"pin": "고정",
"unpin": "고정 해제",
@@ -1422,6 +1425,18 @@
"automationSourceFallback": "자동화",
"automationTriggered": "자동 실행됨"
},
"recovery": {
"actionFailed": "복구 작업에 실패했습니다. 다시 시도하세요.",
"interrupted": "작업이 중단됨",
"completed": "작업 완료",
"failed": "작업 복구 실패",
"failedHelp": "저장된 작업을 안전하게 복구할 수 없습니다. 계속하기 전에 검토하세요.",
"resuming": "중단된 작업을 복구하는 중…",
"review": "계속하기 전에 작업을 검토하세요. 도구는 자동으로 다시 실행되지 않습니다.",
"safeResume": "저장된 대화 컨텍스트에서 계속합니다.",
"dismiss": "닫기",
"continue": "계속"
},
"lightbox": {
"title": "이미지 미리보기",
"open": "이미지 보기",
+16 -1
View File
@@ -198,6 +198,7 @@
"fileEditDisplay": "Exibição de edição de arquivo",
"codeWrap": "Quebra de linha no código",
"brandLogos": "Logos de marca",
"browserNotifications": "Notificações de tarefas",
"maxResults": "Máx. de resultados",
"timeout": "Tempo limite",
"jinaReader": "Leitor Jina",
@@ -243,6 +244,7 @@
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
"browserNotifications": "Notifica somente quando esta página está em segundo plano. Desativado por padrão.",
"maxResults": "Resultados retornados por cada chamada de web_search.",
"timeout": "Segundos antes de uma requisição de busca expirar.",
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
@@ -1013,7 +1015,8 @@
"activity": {
"running": "Agente em execução",
"complete": "Agente finalizado",
"updated": "Nova atividade"
"updated": "Nova atividade",
"recovery": "Esta conversa precisa da sua atenção"
},
"pin": "Fixar",
"unpin": "Desafixar",
@@ -1436,6 +1439,18 @@
"estimated": "Inclui uso estimado"
}
},
"recovery": {
"actionFailed": "A recuperação falhou. Tente novamente.",
"interrupted": "Tarefa interrompida",
"completed": "Tarefa concluída",
"failed": "Falha ao recuperar a tarefa",
"failedHelp": "Não foi possível restaurar a tarefa salva com segurança. Revise-a antes de continuar.",
"resuming": "Restaurando a tarefa interrompida…",
"review": "Revise a tarefa antes de continuar. As ferramentas não serão executadas novamente de forma automática.",
"safeResume": "Continuando a partir do contexto de conversa salvo.",
"dismiss": "Descartar",
"continue": "Continuar"
},
"lightbox": {
"title": "Pré-visualização de imagem",
"open": "Ver imagem",
+16 -1
View File
@@ -161,6 +161,7 @@
"webuiDefaultAccess": "Quyền mặc định",
"currentModel": "Cấu hình hiện tại",
"brandLogos": "Logo thương hiệu",
"browserNotifications": "Thông báo tác vụ",
"cliAppsCatalog": "Danh mục",
"cliAppsFilter": "Bộ lọc",
"engine": "Bộ máy",
@@ -204,6 +205,7 @@
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
"browserNotifications": "Chỉ thông báo khi trang này ở nền. Mặc định tắt.",
"cliAppsCatalog": "Chỉ cài đặt các bộ chuyển đổi CLI ứng dụng mà nanobot có thể chạy cục bộ; ứng dụng gốc không bị thay đổi.",
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
"logs": "Mở thư mục nhật ký của bộ máy gốc.",
@@ -999,7 +1001,8 @@
"activity": {
"running": "Tác nhân đang chạy",
"complete": "Tác nhân đã hoàn tất",
"updated": "Hoạt động mới"
"updated": "Hoạt động mới",
"recovery": "Cuộc trò chuyện này cần bạn xử lý"
},
"pin": "Ghim",
"unpin": "Bỏ ghim",
@@ -1422,6 +1425,18 @@
"automationSourceFallback": "Tự động hóa",
"automationTriggered": "Tự động kích hoạt"
},
"recovery": {
"actionFailed": "Khôi phục thất bại. Hãy thử lại.",
"interrupted": "Tác vụ bị gián đoạn",
"completed": "Tác vụ đã hoàn tất",
"failed": "Khôi phục tác vụ thất bại",
"failedHelp": "Không thể khôi phục an toàn tác vụ đã lưu. Hãy xem lại trước khi tiếp tục.",
"resuming": "Đang khôi phục tác vụ bị gián đoạn…",
"review": "Hãy xem lại tác vụ trước khi tiếp tục. Công cụ sẽ không tự động chạy lại.",
"safeResume": "Đang tiếp tục từ ngữ cảnh hội thoại đã lưu.",
"dismiss": "Bỏ qua",
"continue": "Tiếp tục"
},
"lightbox": {
"title": "Xem trước ảnh",
"open": "Xem ảnh",
+16 -1
View File
@@ -198,6 +198,7 @@
"fileEditDisplay": "文件编辑显示",
"codeWrap": "代码换行",
"brandLogos": "品牌 Logo",
"browserNotifications": "任务通知",
"maxResults": "最大结果数",
"timeout": "超时",
"jinaReader": "Jina 阅读器",
@@ -243,6 +244,7 @@
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
"codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
"browserNotifications": "仅在页面位于后台时通知,默认关闭。",
"maxResults": "每次 web_search 调用返回的结果数。",
"timeout": "搜索提供商请求超时前等待的秒数。",
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
@@ -1013,7 +1015,8 @@
"activity": {
"running": "智能体正在运行",
"complete": "智能体已完成",
"updated": "有新内容"
"updated": "有新内容",
"recovery": "此对话需要你的处理"
},
"pin": "置顶",
"unpin": "取消置顶",
@@ -1436,6 +1439,18 @@
"estimated": "包含估算用量"
}
},
"recovery": {
"actionFailed": "恢复操作失败,请重试。",
"interrupted": "任务已中断",
"completed": "任务已完成",
"failed": "任务恢复失败",
"failedHelp": "无法安全恢复已保存的任务,继续前请先检查。",
"resuming": "正在恢复中断的任务…",
"review": "继续前请检查任务。工具不会被自动重放。",
"safeResume": "正在从已保存的对话上下文继续。",
"dismiss": "忽略",
"continue": "继续"
},
"lightbox": {
"title": "图片预览",
"open": "查看图片",
+16 -1
View File
@@ -161,6 +161,7 @@
"webuiDefaultAccess": "預設存取權",
"currentModel": "目前設定",
"brandLogos": "品牌 Logo",
"browserNotifications": "任務通知",
"cliAppsCatalog": "目錄",
"cliAppsFilter": "篩選",
"engine": "引擎",
@@ -204,6 +205,7 @@
"selectedModelProvider": "由選取的模型決定。",
"selectedModelValue": "由選取的模型決定。",
"brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。",
"browserNotifications": "僅在頁面位於背景時通知,預設關閉。",
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。",
"cliAppsFilter": "依應用程式、類別或功能搜尋。",
"logs": "開啟原生引擎日誌資料夾。",
@@ -999,7 +1001,8 @@
"activity": {
"running": "智能體正在執行",
"complete": "智能體已完成",
"updated": "有新內容"
"updated": "有新內容",
"recovery": "此對話需要你的處理"
},
"pin": "置頂",
"unpin": "取消置頂",
@@ -1422,6 +1425,18 @@
"automationTriggered": "已自動觸發",
"askAboutSelection": "繼續提問"
},
"recovery": {
"actionFailed": "復原操作失敗,請再試一次。",
"interrupted": "任務已中斷",
"completed": "任務已完成",
"failed": "任務復原失敗",
"failedHelp": "無法安全復原已儲存的任務,繼續前請先檢查。",
"resuming": "正在復原中斷的任務…",
"review": "繼續前請檢查任務。工具不會自動重播。",
"safeResume": "正在從已儲存的對話上下文繼續。",
"dismiss": "略過",
"continue": "繼續"
},
"lightbox": {
"title": "圖片預覽",
"open": "檢視圖片",
+64 -53
View File
@@ -1,20 +1,32 @@
import type { UIMessage } from "@/lib/types";
/** A completed turn has two surfaces: one activity container and one final
* answer. An active turn temporarily preserves arrival order so visible
* Markdown never moves when a later tool starts. */
* answer. Answer text is never inferred to be activity merely because a later
* tool event arrived; explicit reasoning/activity fields own that distinction. */
export type TurnUnit =
| {
type: "activity";
messages: UIMessage[];
/** Number of raw UI messages represented by this display unit. */
sourceMessageCount: number;
turnLatencyMs?: number;
startedAtMs?: number;
}
| { type: "message"; message: UIMessage };
| {
type: "message";
message: UIMessage;
/** Number of raw UI messages represented by this display unit. */
sourceMessageCount: number;
};
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false;
if (message.activityKind === "model" || message.content.trim().length > 0) return false;
if (
message.activityKind === "model"
|| message.content.trim().length > 0
|| !!message.media?.length
|| !!message.images?.length
) return false;
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
}
@@ -42,10 +54,9 @@ export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
*
* user [one live/completed activity surface] [one final answer]
*
* Assistant text that is followed by another activity is an intermediate model
* segment. It remains in the activity timeline, where the renderer keeps its
* normal Markdown surface, but it never creates a second answer bubble. This
* is the same causal model used by Codex-style transcripts.
* A provider may emit multiple answer segments around tool activity. They are
* merged into the one final answer instead of being reclassified as reasoning;
* only explicit reasoning, trace, and model-activity rows enter the fold.
*/
export function normalizeActivityTimeline(
messages: UIMessage[],
@@ -63,50 +74,41 @@ export function normalizeActivityTimeline(
}
const ordered = orderMessagesByTurnSeq(turnMessages);
const lastActivityIndex = ordered.reduce(
(index, message, current) => isRawActivity(message) ? current : index,
-1,
);
const answerIndices = ordered
.map((message, index) => ({ message, index }))
.filter(({ message }) => isAssistantAnswer(message));
const finalAnswerIndex = answerIndices.at(-1)?.index;
// A replay can deliver a completed answer before a late trace row. Keep
// that answer visible, but place the late activity in the single activity
// surface before it. An answer followed by more activity is an
// intermediate model segment and stays in that surface in turn order.
const hasFinalAnswer = finalAnswerIndex !== undefined
&& (finalAnswerIndex > lastActivityIndex || ordered[finalAnswerIndex].isStreaming !== true);
const activity: UIMessage[] = [];
const answers: UIMessage[] = [];
ordered.forEach((message, index) => {
let activitySourceMessageCount = 0;
for (const message of ordered) {
if (isRawActivity(message)) {
activity.push(message);
activitySourceMessageCount += 1;
} else if (isAssistantAnswer(message)) {
if (message.reasoning?.trim() || message.reasoningStreaming) {
// The synthetic reasoning row and answer both come from one raw
// message, so account for that source on the answer unit only.
activity.push(reasoningOnlyMessageFromAnswer(message));
}
if (hasFinalAnswer && index === finalAnswerIndex) {
answers.push(stripInlineReasoning(message));
} else {
activity.push(modelActivitySnippet(message));
}
} else {
activity.push(message);
activitySourceMessageCount += 1;
}
}
});
if (activity.length) {
units.push({
type: "activity",
messages: activity,
sourceMessageCount: activitySourceMessageCount,
turnLatencyMs: activityTurnLatencyMs(activity, ordered),
startedAtMs: activeTurnStartedAtMs,
});
}
if (answers.length) {
units.push({ type: "message", message: mergeAssistantAnswers(answers) });
units.push({
type: "message",
message: mergeAssistantAnswers(answers),
sourceMessageCount: answers.length,
});
}
turnMessages = [];
@@ -117,7 +119,7 @@ export function normalizeActivityTimeline(
for (const message of messages) {
if (message.role === "user") {
flushTurn();
units.push({ type: "message", message });
units.push({ type: "message", message, sourceMessageCount: 1 });
activeTurnId = message.turnId;
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
continue;
@@ -132,15 +134,16 @@ export function normalizeActivityTimeline(
}
/**
* Keep an in-flight turn in arrival order. Until ``turn_end`` there is no
* reliable way to know whether an assistant text segment is the final answer
* or commentary before another tool call. Reclassifying it when that tool
* arrives makes an already-visible Markdown tree jump between containers.
* Keep an in-flight turn in arrival order. Answer, reasoning, and activity
* semantics are explicit, but a later tool event can still arrive after
* already-visible answer Markdown. Reparenting on each event would make that
* Markdown tree jump between containers.
*
* Completed turns still use ``normalizeActivityTimeline`` and collapse into
* one audit surface plus the final answer. While the turn is active, text and
* contiguous activity phases stay in arrival order. A later tool therefore
* appends a Working surface after existing Markdown instead of reparenting it.
* one audit surface plus the merged answer. While the turn is active, answer
* text and contiguous activity phases stay in arrival order. A later tool
* therefore appends a Working surface after existing Markdown instead of
* reparenting it.
*/
export function projectActivityTimeline(
messages: UIMessage[],
@@ -176,23 +179,29 @@ function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
const prompt = messages[0];
const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined;
let activity: UIMessage[] = [];
let activitySourceMessageCount = 0;
if (prompt?.role === "user") units.push({ type: "message", message: prompt });
if (prompt?.role === "user") {
units.push({ type: "message", message: prompt, sourceMessageCount: 1 });
}
const flushActivity = () => {
if (!activity.length) return;
units.push({
type: "activity",
messages: activity,
sourceMessageCount: activitySourceMessageCount,
turnLatencyMs: activityTurnLatencyMs(activity, activity),
startedAtMs,
});
activity = [];
activitySourceMessageCount = 0;
};
for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) {
if (isRawActivity(message)) {
activity.push(message);
activitySourceMessageCount += 1;
continue;
}
if (isAssistantAnswer(message)) {
@@ -200,10 +209,15 @@ function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
activity.push(reasoningOnlyMessageFromAnswer(message));
}
flushActivity();
units.push({ type: "message", message: stripInlineReasoning(message) });
units.push({
type: "message",
message: stripInlineReasoning(message),
sourceMessageCount: 1,
});
continue;
}
activity.push(message);
activitySourceMessageCount += 1;
}
flushActivity();
@@ -215,7 +229,16 @@ function isRawActivity(message: UIMessage): boolean {
}
function isAssistantAnswer(message: UIMessage): boolean {
return message.role === "assistant" && message.kind !== "trace" && message.content.trim().length > 0;
if (message.role !== "assistant" || message.kind === "trace" || message.activityKind === "model") {
return false;
}
if (message.turnPhase === "reasoning" || message.turnPhase === "activity") return false;
return (
message.turnPhase === "answer"
|| message.content.trim().length > 0
|| !!message.media?.length
|| !!message.images?.length
);
}
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
@@ -248,18 +271,6 @@ function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
return merged;
}
function modelActivitySnippet(message: UIMessage): UIMessage {
return {
...stripInlineReasoning(message),
id: `${message.id}-activity`,
activityKind: "model",
turnPhase: "activity",
// Keep the source stream state so the activity surface can render this
// segment with the same Markdown streaming semantics as a normal answer.
isStreaming: message.isStreaming,
};
}
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
return {
id: `${message.id}-reasoning`,
+1 -1
View File
@@ -3,7 +3,7 @@ export type AnsiSegment = {
style?: AnsiStyle;
};
export type AnsiStyle = {
type AnsiStyle = {
backgroundColor?: string;
color?: string;
fontStyle?: "italic";
+3
View File
@@ -22,6 +22,7 @@ import type {
ProviderOAuthCompletionResult,
ProviderOAuthLoginResult,
ProviderSettingsUpdate,
RecoveryState,
SessionDeleteResult,
SessionHandle,
SessionAutomationsPayload,
@@ -192,6 +193,7 @@ export async function listSessions(
preview?: string;
model_preset?: string | null;
run_started_at?: number | null;
recovery_state?: RecoveryState | null;
workspace_scope?: WorkspaceScopePayload | null;
handle?: SessionHandle | null;
};
@@ -212,6 +214,7 @@ export async function listSessions(
preview: s.preview ?? "",
modelPreset: s.model_preset ?? null,
runStartedAt: s.run_started_at ?? null,
recoveryState: s.recovery_state ?? null,
workspaceScope: s.workspace_scope ?? null,
handle,
};
+1 -1
View File
@@ -1,4 +1,4 @@
export const DEFAULT_HTTP_TIMEOUT_MS = 20_000;
const DEFAULT_HTTP_TIMEOUT_MS = 20_000;
export async function fetchWithTimeout(
input: RequestInfo | URL,
+1 -1
View File
@@ -11,7 +11,7 @@ import {
type EncodeResponse,
} from "@/workers/imageEncode.worker";
export type { EncodeResponse, EncodeSuccess, EncodeFailure } from "@/workers/imageEncode.worker";
export type { EncodeResponse, EncodeFailure } from "@/workers/imageEncode.worker";
type Pending = {
resolve: (r: EncodeResponse) => void;
+3
View File
@@ -7,6 +7,7 @@ export interface LocalPreferences {
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
browserNotifications: boolean;
fileEditDisplayMode: FileEditDisplayMode;
}
@@ -18,6 +19,7 @@ export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
activityMode: "auto",
codeWrap: true,
brandLogos: false,
browserNotifications: false,
fileEditDisplayMode: "summary",
};
@@ -35,6 +37,7 @@ export function readLocalPreferences(): LocalPreferences {
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos === true,
browserNotifications: parsed.browserNotifications === true,
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
};
} catch {

Some files were not shown because too many files have changed in this diff Show More