Compare commits

..
116 changed files with 921 additions and 5263 deletions
+1 -7
View File
@@ -209,13 +209,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
nanobot agent nanobot agent
``` ```
This opens the native terminal client with the launch directory as its workspace. It shares saved conversations and the local gateway with the WebUI. 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.
- 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: For one request and an immediate exit, use:
+143 -112
View File
@@ -79,15 +79,6 @@ from nanobot.session.model_selection import (
SESSION_MODEL_PRESET_METADATA_KEY, SESSION_MODEL_PRESET_METADATA_KEY,
model_preset_from_metadata, 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.session.summary import SessionSummary
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
from nanobot.utils.cancellation import task_is_cancelling from nanobot.utils.cancellation import task_is_cancelling
@@ -300,14 +291,12 @@ class AgentLoop:
restart_mode: str = "auto", restart_mode: str = "auto",
local_trigger_store: LocalTriggerStore | None = None, local_trigger_store: LocalTriggerStore | None = None,
idle_compact_check_interval_seconds: int = 0, idle_compact_check_interval_seconds: int = 0,
recovery_admission: RecoveryAdmission | None = None,
): ):
from nanobot.config.schema import ToolsConfig from nanobot.config.schema import ToolsConfig
_tc = tools_config or ToolsConfig() _tc = tools_config or ToolsConfig()
defaults = AgentDefaults() defaults = AgentDefaults()
self.bus = bus self.bus = bus
self._recovery_admission = recovery_admission
if turn_delivery_factory is not None: if turn_delivery_factory is not None:
if turn_delivery_factory.bus is not bus: if turn_delivery_factory.bus is not bus:
raise ValueError("turn delivery factory must use the agent message bus") raise ValueError("turn delivery factory must use the agent message bus")
@@ -420,7 +409,6 @@ class AgentLoop:
# When a session has an active task, new messages for that session # When a session has an active task, new messages for that session
# are routed here instead of creating a new task. # are routed here instead of creating a new task.
self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {} self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
self._preserve_inflight_turns_on_shutdown = False
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {} self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
self._cron_turns = CronTurnCoordinator( self._cron_turns = CronTurnCoordinator(
publish_inbound=self.bus.publish_inbound, publish_inbound=self.bus.publish_inbound,
@@ -738,9 +726,6 @@ class AgentLoop:
extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
session.add_message("user", text, **extra) session.add_message("user", text, **extra)
self._mark_pending_user_turn(session) 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) self.sessions.save(session)
return True return True
return False return False
@@ -1076,9 +1061,6 @@ class AgentLoop:
row["subagent_task_id"] = task_id row["subagent_task_id"] = task_id
row[HIDDEN_HISTORY_META] = subagent_marker row[HIDDEN_HISTORY_META] = subagent_marker
row["injected_event"] = "subagent_result" 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 return row
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
@@ -1303,23 +1285,6 @@ class AgentLoop:
break break
if deferred: if deferred:
continue 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 # If this session already has an active pending queue (i.e. a task
# is processing this session), route the message there for mid-turn # is processing this session), route the message there for mid-turn
# injection instead of creating a competing task. # injection instead of creating a competing task.
@@ -1332,18 +1297,12 @@ class AgentLoop:
self.commands.dispatch, self.commands.dispatch,
) )
continue continue
pending_msg = routed_msg pending_msg = msg
session = self.sessions.get_or_create(effective_key) if effective_key != msg.session_key:
followup_id = record_pending_followup(session, pending_msg)
if followup_id is not None:
pending_msg = dataclasses.replace( pending_msg = dataclasses.replace(
pending_msg, msg,
metadata={ session_key_override=effective_key,
**pending_msg.metadata,
PENDING_FOLLOWUP_ID_KEY: followup_id,
},
) )
self.sessions.save(session)
try: try:
self._pending_queues[effective_key].put_nowait(pending_msg) self._pending_queues[effective_key].put_nowait(pending_msg)
except asyncio.QueueFull: except asyncio.QueueFull:
@@ -1351,7 +1310,6 @@ class AgentLoop:
"Pending queue full for session {}, falling back to queued task", "Pending queue full for session {}, falling back to queued task",
effective_key, effective_key,
) )
msg = pending_msg
else: else:
logger.info( logger.info(
"Routed follow-up message to pending queue for session {}", "Routed follow-up message to pending queue for session {}",
@@ -1361,45 +1319,17 @@ class AgentLoop:
# Compute the effective session key before dispatching # Compute the effective session key before dispatching
# This ensures /stop command can find tasks correctly when unified session is enabled # This ensures /stop command can find tasks correctly when unified session is enabled
task = asyncio.create_task(self._dispatch(msg)) task = asyncio.create_task(self._dispatch(msg))
active_tasks: set[asyncio.Task[Any]] = self._active_tasks.setdefault( active_tasks = self._active_tasks.setdefault(effective_key, set())
effective_key,
set(),
)
active_tasks.add(task) active_tasks.add(task)
task.add_done_callback(active_tasks.discard) task.add_done_callback(active_tasks.discard)
finally: finally:
await self.aclose() 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: async def _dispatch(self, msg: InboundMessage) -> None:
"""Process a message: per-session serial, cross-session concurrent.""" """Process a message: per-session serial, cross-session concurrent."""
session_key = self._effective_session_key(msg) session_key = self._effective_session_key(msg)
if session_key != msg.session_key: if session_key != msg.session_key:
msg = dataclasses.replace(msg, session_key_override=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) lock = self._get_session_lock(session_key)
gate = self._concurrency_gate or nullcontext() gate = self._concurrency_gate or nullcontext()
@@ -1443,14 +1373,14 @@ class AgentLoop:
session_key, session_key,
exc_info=True, exc_info=True,
) )
# An explicit turn stop materializes partial context so # Preserve partial context from the interrupted turn so
# the next prompt can see completed tool results. Gateway # the user does not lose tool results and assistant
# shutdown keeps the durable checkpoint untouched instead, # messages accumulated before /stop. The checkpoint was
# allowing RecoveryCoordinator to offer Continue safely. # already persisted to session metadata by
if ( # _emit_checkpoint during tool execution; materializing
session_key in self._discarding_sessions # it into session history now makes it visible in the
or self._preserve_inflight_turns_on_shutdown # next conversation turn.
): if session_key in self._discarding_sessions:
raise raise
try: try:
key = self._effective_session_key(msg) key = self._effective_session_key(msg)
@@ -1507,12 +1437,6 @@ class AgentLoop:
await delivery.idle() await delivery.idle()
await self._publish_next_deferred_automation_turn(session_key) await self._publish_next_deferred_automation_turn(session_key)
finally: 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: if pending is None:
await delivery.idle() await delivery.idle()
await self._publish_next_deferred_automation_turn(session_key) await self._publish_next_deferred_automation_turn(session_key)
@@ -1814,10 +1738,7 @@ class AgentLoop:
if self._restore_runtime_checkpoint(session): if self._restore_runtime_checkpoint(session):
self.sessions.save(session) self.sessions.save(session)
if ( if self._restore_pending_user_turn(session):
RECOVERY_INBOUND_METADATA_KEY not in msg.metadata
and restore_pending_interruption(session)
):
self.sessions.save(session) self.sessions.save(session)
async def _compact_session(self, ctx: TurnContext) -> None: async def _compact_session(self, ctx: TurnContext) -> None:
@@ -2172,21 +2093,8 @@ class AgentLoop:
if m.get("role") == "tool" and m.get("tool_call_id") if m.get("role") == "tool" and m.get("tool_call_id")
} }
last_assistant_idx: int | None = None last_assistant_idx: int | None = None
saved_followup_ids: set[str] = set()
for m in messages[skip:]: for m in messages[skip:]:
entry = dict(m) 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)) internal_meta = cast(object, entry.pop("_meta", None))
runtime_context_meta = ( runtime_context_meta = (
cast(dict[str, Any], internal_meta).get( cast(dict[str, Any], internal_meta).get(
@@ -2239,8 +2147,6 @@ class AgentLoop:
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
entry.setdefault("timestamp", datetime.now().isoformat()) entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry) 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": if role == "assistant":
last_assistant_idx = len(session.messages) - 1 last_assistant_idx = len(session.messages) - 1
declared_tool_call_ids.update( declared_tool_call_ids.update(
@@ -2255,8 +2161,6 @@ class AgentLoop:
) )
if turn_latency_ms is not None and last_assistant_idx is not None: 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) 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() session.updated_at = datetime.now()
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool: def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
@@ -2291,7 +2195,7 @@ class AgentLoop:
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None: def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
"""Persist the latest in-flight turn state into session metadata.""" """Persist the latest in-flight turn state into session metadata."""
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
self.sessions.save_runtime_checkpoint(session) self.sessions.save(session)
def _mark_pending_user_turn(self, session: Session) -> None: def _mark_pending_user_turn(self, session: Session) -> None:
session.metadata[self._PENDING_USER_TURN_KEY] = True session.metadata[self._PENDING_USER_TURN_KEY] = True
@@ -2303,9 +2207,136 @@ class AgentLoop:
if self._RUNTIME_CHECKPOINT_KEY in session.metadata: if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None) 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: def _restore_runtime_checkpoint(self, session: Session) -> bool:
"""Materialize an unfinished turn into session history before a new request.""" """Materialize an unfinished turn into session history before a new request."""
return restore_runtime_checkpoint(session) 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
async def process_direct( async def process_direct(
self, self,
-18
View File
@@ -37,7 +37,6 @@ from nanobot.runtime_context import (
reattach_runtime_context, reattach_runtime_context,
) )
from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
IncrementalThinkExtractor, IncrementalThinkExtractor,
build_assistant_message, build_assistant_message,
@@ -235,23 +234,6 @@ class AgentRunner:
merged.get("content"), merged.get("content"),
injection.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 messages[-1] = merged
continue continue
messages.append(injection) messages.append(injection)
-9
View File
@@ -62,15 +62,6 @@ class TurnEndEvent(OutboundEvent):
context_window_tokens: int | None = None 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) @dataclass(frozen=True)
class GoalStatusEvent(OutboundEvent): class GoalStatusEvent(OutboundEvent):
status: str status: str
@@ -145,9 +145,14 @@ class _FakeChannel:
class _FakeInteractionResponse: class _FakeInteractionResponse:
def __init__(self) -> None: def __init__(self) -> None:
self.messages: list[dict] = [] self.messages: list[dict] = []
self._done = False
async def send_message(self, content: str, *, ephemeral: bool = False) -> None: async def send_message(self, content: str, *, ephemeral: bool = False) -> None:
self.messages.append({"content": content, "ephemeral": ephemeral}) self.messages.append({"content": content, "ephemeral": ephemeral})
self._done = True
def is_done(self) -> bool:
return self._done
def _make_interaction( def _make_interaction(
-11
View File
@@ -104,9 +104,6 @@ class ChannelManager:
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None, webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
webui_mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None, webui_mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
webui_skill_state_action: Callable[[set[str]], None] | 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, config_path: Path | None = None,
): ):
if config_path is None: if config_path is None:
@@ -129,7 +126,6 @@ class ChannelManager:
self._webui_mcp_runtime_status = webui_mcp_runtime_status self._webui_mcp_runtime_status = webui_mcp_runtime_status
self._webui_mcp_reload = webui_mcp_reload self._webui_mcp_reload = webui_mcp_reload
self._webui_skill_state_action = webui_skill_state_action self._webui_skill_state_action = webui_skill_state_action
self._webui_recovery_action = webui_recovery_action
self.channels: dict[str, BaseChannel] = {} self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {} self._channel_owners: dict[str, str] = {}
self._channel_runtime_specs: dict[str, tuple[str, str]] = {} self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
@@ -201,7 +197,6 @@ class ChannelManager:
mcp_runtime_status=self._webui_mcp_runtime_status, mcp_runtime_status=self._webui_mcp_runtime_status,
mcp_reload=self._webui_mcp_reload, mcp_reload=self._webui_mcp_reload,
skill_state_action=self._webui_skill_state_action, skill_state_action=self._webui_skill_state_action,
recovery_action=self._webui_recovery_action,
logger=logger, logger=logger,
) )
kwargs["gateway"] = gateway kwargs["gateway"] = gateway
@@ -620,12 +615,6 @@ class ChannelManager:
if target is None: if target is None:
logger.warning("Restart notice target channel is not enabled: {}", notice.channel) logger.warning("Restart notice target channel is not enabled: {}", notice.channel)
return 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: while not target.is_running:
remaining = deadline - loop.time() remaining = deadline - loop.time()
+3
View File
@@ -53,6 +53,7 @@ class MattermostConfig(Base):
include_thread_context: bool = True include_thread_context: bool = True
thread_context_limit: int = 20 thread_context_limit: int = 20
streaming: bool = True streaming: bool = True
streaming_max_chars: int = 16000
react_emoji: str = "eyes" react_emoji: str = "eyes"
done_emoji: str = "white_check_mark" done_emoji: str = "white_check_mark"
send_progress: bool = True send_progress: bool = True
@@ -105,6 +106,7 @@ class MattermostChannel(BaseChannel):
self._ws_task: asyncio.Task[None] | None = None self._ws_task: asyncio.Task[None] | None = None
self._self_id: str | None = None self._self_id: str | None = None
self._self_username: str | None = None self._self_username: str | None = None
self._self_email: str | None = None
self._usernames: dict[str, str] = {} self._usernames: dict[str, str] = {}
self._user_emails: dict[str, str] = {} self._user_emails: dict[str, str] = {}
self._channel_types: dict[str, str] = {} self._channel_types: dict[str, str] = {}
@@ -136,6 +138,7 @@ class MattermostChannel(BaseChannel):
me = cast(dict[str, Any], resp.json()) me = cast(dict[str, Any], resp.json())
self._self_id = me.get("id") self._self_id = me.get("id")
self._self_username = me.get("username") self._self_username = me.get("username")
self._self_email = me.get("email", "")
self.logger.info("bot @{} connected", self._self_username) self.logger.info("bot @{} connected", self._self_username)
except Exception as e: except Exception as e:
self.logger.error("Failed to identify bot user: {}", e) self.logger.error("Failed to identify bot user: {}", e)
@@ -31,6 +31,8 @@ class _FakeHTTPClient:
self.delete_calls: list[dict[str, Any]] = [] self.delete_calls: list[dict[str, Any]] = []
self._get_responses: dict[str, Any] = {} self._get_responses: dict[str, Any] = {}
self._post_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: def _req(self, method: str, path: str) -> httpx.Request:
return httpx.Request(method, f"https://chat.example.com{path}") return httpx.Request(method, f"https://chat.example.com{path}")
@@ -44,6 +46,12 @@ class _FakeHTTPClient:
def set_post_response(self, path: str, data: Any) -> None: def set_post_response(self, path: str, data: Any) -> None:
self._post_responses[path] = data 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: async def get(self, path: str, **kwargs) -> httpx.Response:
self.get_calls.append({"path": path, **kwargs}) self.get_calls.append({"path": path, **kwargs})
data = self._get_responses.get(path, {"id": "resp_" + path.split("/")[-1]}) data = self._get_responses.get(path, {"id": "resp_" + path.split("/")[-1]})
@@ -63,11 +71,13 @@ class _FakeHTTPClient:
async def put(self, path: str, *, json: dict[str, Any] | None = None, **kwargs) -> httpx.Response: async def put(self, path: str, *, json: dict[str, Any] | None = None, **kwargs) -> httpx.Response:
self.put_calls.append({"path": path, "json": json}) self.put_calls.append({"path": path, "json": json})
return self._resp(200, {"id": path.split("/")[-1]}, "PUT", path) data = self._put_responses.get(path, {"id": path.split("/")[-1]})
return self._resp(200, data, "PUT", path)
async def delete(self, path: str, **kwargs) -> httpx.Response: async def delete(self, path: str, **kwargs) -> httpx.Response:
self.delete_calls.append({"path": path}) self.delete_calls.append({"path": path})
return self._resp(200, {}, "DELETE", path) status = self._delete_status if self._delete_status is not None else 200
return self._resp(status, {}, "DELETE", path)
async def aclose(self) -> None: async def aclose(self) -> None:
pass pass
@@ -109,6 +119,7 @@ def test_config_defaults():
assert config.server_url == "" assert config.server_url == ""
assert config.token == "" assert config.token == ""
assert config.streaming is True assert config.streaming is True
assert config.streaming_max_chars == 16000
assert config.send_tool_hints is True assert config.send_tool_hints is True
assert config.dm.enabled is True assert config.dm.enabled is True
assert config.dm.policy == "open" assert config.dm.policy == "open"
@@ -139,6 +150,7 @@ def test_config_camelcase_aliases():
"serverUrl": "https://mm.example.com", "serverUrl": "https://mm.example.com",
"token": "abc123", "token": "abc123",
"allowFromMatchMode": "username", "allowFromMatchMode": "username",
"streamingMaxChars": 8000,
"replyInThread": False, "replyInThread": False,
"sendToolHints": False, "sendToolHints": False,
} }
@@ -146,6 +158,7 @@ def test_config_camelcase_aliases():
assert config.server_url == "https://mm.example.com" assert config.server_url == "https://mm.example.com"
assert config.token == "abc123" assert config.token == "abc123"
assert config.allow_from_match_mode == "username" assert config.allow_from_match_mode == "username"
assert config.streaming_max_chars == 8000
assert config.reply_in_thread is False assert config.reply_in_thread is False
assert config.send_tool_hints is False assert config.send_tool_hints is False
@@ -181,6 +194,7 @@ async def test_start_identifies_bot():
assert channel._self_id == "botuserid123" assert channel._self_id == "botuserid123"
assert channel._self_username == "nanobot" assert channel._self_username == "nanobot"
assert channel._self_email == "bot@example.com"
assert not start_task.done() 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"]] 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 assert len(user_me_calls) == 1
@@ -660,7 +674,7 @@ async def test_stream_end_adds_done_emoji():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stream_chunk_boundary_finalizes_and_creates_new(): async def test_stream_chunk_boundary_finalizes_and_creates_new():
channel, fake = _make_channel() channel, fake = _make_channel({"streamingMaxChars": 10})
channel._self_id = "bot_id" channel._self_id = "bot_id"
fake.set_post_response("/api/v4/posts", {"id": "post_1"}) 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.config: MochatConfig = config
self._http: httpx.AsyncClient | None = None self._http: httpx.AsyncClient | None = None
self._socket: Any = None self._socket: Any = None
self._ws_ready = False self._ws_connected = self._ws_ready = False
self._state_dir = get_runtime_subdir("mochat") self._state_dir = get_runtime_subdir("mochat")
self._cursor_path = self._state_dir / "session_cursors.json" self._cursor_path = self._state_dir / "session_cursors.json"
@@ -346,7 +346,7 @@ class MochatChannel(BaseChannel):
if self._http: if self._http:
await self._http.aclose() await self._http.aclose()
self._http = None self._http = None
self._ws_ready = False self._ws_connected = self._ws_ready = False
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send outbound message to session or panel.""" """Send outbound message to session or panel."""
@@ -422,7 +422,7 @@ class MochatChannel(BaseChannel):
) )
async def connect() -> None: async def connect() -> None:
self._ws_ready = False self._ws_connected, self._ws_ready = True, False
self.logger.info("websocket connected") self.logger.info("websocket connected")
subscribed = await self._subscribe_all() subscribed = await self._subscribe_all()
self._ws_ready = subscribed self._ws_ready = subscribed
@@ -431,7 +431,7 @@ class MochatChannel(BaseChannel):
async def disconnect() -> None: async def disconnect() -> None:
if not self._running: if not self._running:
return return
self._ws_ready = False self._ws_connected = self._ws_ready = False
self.logger.warning("websocket disconnected") self.logger.warning("websocket disconnected")
await self._ensure_fallback_workers() await self._ensure_fallback_workers()
@@ -363,6 +363,13 @@ 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(): def test_partition_styles_single_chunk_passthrough():
plain, styles = _markdown_to_signal("**bold** plain *it*") plain, styles = _markdown_to_signal("**bold** plain *it*")
parts = _partition_styles(plain, [plain], styles) parts = _partition_styles(plain, [plain], styles)
+1
View File
@@ -69,6 +69,7 @@ class SlackConfig(Base):
webhook_path: str = "/slack/events" webhook_path: str = "/slack/events"
bot_token: str = "" bot_token: str = ""
app_token: str = "" app_token: str = ""
user_token_read_only: bool = True
reply_in_thread: bool = True reply_in_thread: bool = True
react_emoji: str = "eyes" react_emoji: str = "eyes"
done_emoji: str = "white_check_mark" done_emoji: str = "white_check_mark"
-30
View File
@@ -32,7 +32,6 @@ from nanobot.bus.outbound_events import (
GoalStateSyncEvent, GoalStateSyncEvent,
GoalStatusEvent, GoalStatusEvent,
ProgressEvent, ProgressEvent,
RecoveryStateEvent,
RuntimeModelUpdatedEvent, RuntimeModelUpdatedEvent,
SessionUpdatedEvent, SessionUpdatedEvent,
TurnEndEvent, TurnEndEvent,
@@ -56,7 +55,6 @@ from nanobot.security.workspace_access import (
) )
from nanobot.session.goal_state import goal_state_ws_blob from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.model_selection import model_preset_from_metadata 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 ( from nanobot.session.webui_turns import (
clear_websocket_turn_if_current, clear_websocket_turn_if_current,
clear_websocket_turns, clear_websocket_turns,
@@ -455,9 +453,6 @@ class WebSocketChannel(BaseChannel):
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id) self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
fields["model_preset"] = None fields["model_preset"] = None
if isinstance(metadata, dict): 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") usage = metadata.get("_last_usage")
if isinstance(usage, dict): if isinstance(usage, dict):
sanitized_usage: dict[str, int | float] = {} sanitized_usage: dict[str, int | float] = {}
@@ -1745,10 +1740,6 @@ class WebSocketChannel(BaseChannel):
provenance=event.provenance, provenance=event.provenance,
) )
return return
if isinstance(event, RecoveryStateEvent):
if conns:
await self.send_recovery_state(msg.chat_id, event)
return
if isinstance(event, GoalStateSyncEvent): if isinstance(event, GoalStateSyncEvent):
if conns: if conns:
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False}) await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
@@ -2066,27 +2057,6 @@ class WebSocketChannel(BaseChannel):
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_end ") 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: 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).""" """Push persisted goal-state snapshot for *chat_id* (multi-chat isolation)."""
conns = list(self._subs.get(chat_id, ())) conns = list(self._subs.get(chat_id, ()))
@@ -27,7 +27,6 @@ from nanobot.bus.outbound_events import (
GoalStateSyncEvent, GoalStateSyncEvent,
GoalStatusEvent, GoalStatusEvent,
ProgressEvent, ProgressEvent,
RecoveryStateEvent,
RuntimeModelUpdatedEvent, RuntimeModelUpdatedEvent,
SessionUpdatedEvent, SessionUpdatedEvent,
TurnEndEvent, TurnEndEvent,
@@ -2721,39 +2720,6 @@ 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 @pytest.mark.asyncio
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None: async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
bus = MagicMock() bus = MagicMock()
@@ -83,7 +83,6 @@ def _make_handler(
channel_feature_action: Any | None = None, channel_feature_action: Any | None = None,
channel_runtime_status: Any | None = None, channel_runtime_status: Any | None = None,
mcp_reload: Any | None = None, mcp_reload: Any | None = None,
recovery_action: Any | None = None,
) -> GatewayServices: ) -> GatewayServices:
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
workspace = workspace_path or Path.cwd() workspace = workspace_path or Path.cwd()
@@ -104,7 +103,6 @@ def _make_handler(
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
mcp_reload=mcp_reload, mcp_reload=mcp_reload,
recovery_action=recovery_action,
) )
@@ -123,7 +121,6 @@ def _ch(
channel_feature_action: Any | None = None, channel_feature_action: Any | None = None,
channel_runtime_status: Any | None = None, channel_runtime_status: Any | None = None,
mcp_reload: Any | None = None, mcp_reload: Any | None = None,
recovery_action: Any | None = None,
**extra: Any, **extra: Any,
) -> WebSocketChannel: ) -> WebSocketChannel:
cfg: dict[str, Any] = { cfg: dict[str, Any] = {
@@ -148,7 +145,6 @@ def _ch(
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
channel_runtime_status=channel_runtime_status, channel_runtime_status=channel_runtime_status,
mcp_reload=mcp_reload, mcp_reload=mcp_reload,
recovery_action=recovery_action,
) )
return InProcessHttpChannel(cfg, bus, gateway=gateway) return InProcessHttpChannel(cfg, bus, gateway=gateway)
@@ -1248,6 +1244,39 @@ async def test_pairing_routes_require_token_and_approve_or_deny(
assert "Missing pairing code" in missing_code.text 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 @pytest.mark.asyncio
async def test_nanobot_feature_remote_install_requires_opt_in( async def test_nanobot_feature_remote_install_requires_opt_in(
bus: MagicMock, bus: MagicMock,
@@ -3246,28 +3275,6 @@ 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 @pytest.mark.asyncio
async def test_workspace_folder_picker_is_local_authenticated_mutation( async def test_workspace_folder_picker_is_local_authenticated_mutation(
bus: MagicMock, bus: MagicMock,
@@ -202,6 +202,12 @@ class WsTestClient:
assert msg.event == "delta", f"Expected 'delta' event, got '{msg.event}'" assert msg.event == "delta", f"Expected 'delta' event, got '{msg.event}'"
return msg 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]: async def collect_stream(self, timeout: float = 10.0) -> list[WsMessage]:
"""Collect all deltas and the final stream_end into a list.""" """Collect all deltas and the final stream_end into a list."""
messages: list[WsMessage] = [] messages: list[WsMessage] = []
@@ -226,6 +232,10 @@ class WsTestClient:
"""Send a JSON frame.""" """Send a JSON frame."""
await self.ws.send(json.dumps(data, ensure_ascii=False)) 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 ----------------------------------------- # -- Connection introspection -----------------------------------------
@property @property
+1 -7
View File
@@ -56,7 +56,6 @@ from nanobot.cli.agent import agent # noqa: E402
from nanobot.cli.gateway import create_gateway_app # 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.gateway_runtime import _run_gateway # noqa: E402
from nanobot.cli.log_control import _set_nanobot_logs # 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.provider import provider_app # noqa: E402
from nanobot.cli.runtime_config import ( # noqa: E402 from nanobot.cli.runtime_config import ( # noqa: E402
_load_inspection_config, _load_inspection_config,
@@ -100,17 +99,12 @@ def version_callback(value: bool):
@app.callback() @app.callback()
def main( def main(
ctx: typer.Context,
version: bool = typer.Option( version: bool = typer.Option(
None, "--version", "-v", callback=version_callback, is_eager=True None, "--version", "-v", callback=version_callback, is_eager=True
), ),
): ):
"""nanobot - Personal AI Assistant.""" """nanobot - Personal AI Assistant."""
# Editable/source installs can retain an older generated console script that pass
# 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,8 +6,6 @@ import os
import sys import sys
from contextlib import suppress from contextlib import suppress
from nanobot.cli.process_identity import set_cli_process_identity
def _native_tui_candidate(args: list[str]) -> bool: def _native_tui_candidate(args: list[str]) -> bool:
"""Return whether ``agent`` can start without the classic agent stack.""" """Return whether ``agent`` can start without the classic agent stack."""
@@ -36,7 +34,6 @@ def _configure_windows_console() -> None:
def main() -> None: def main() -> None:
"""Dispatch native TUI startup without importing the complete CLI graph.""" """Dispatch native TUI startup without importing the complete CLI graph."""
set_cli_process_identity(sys.argv[1:])
_configure_windows_console() _configure_windows_console()
if _native_tui_candidate(sys.argv[1:]): if _native_tui_candidate(sys.argv[1:]):
import typer import typer
-24
View File
@@ -322,7 +322,6 @@ def _run_gateway(
from nanobot.providers.fallback_provider import FallbackProvider from nanobot.providers.fallback_provider import FallbackProvider
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.session.recovery import RecoveryCoordinator
from nanobot.session.webui_turns import ( from nanobot.session.webui_turns import (
WebuiTurnCoordinator, WebuiTurnCoordinator,
WebuiTurnRoutePolicy, WebuiTurnRoutePolicy,
@@ -423,12 +422,6 @@ def _run_gateway(
tools = ToolRegistry() tools = ToolRegistry()
mcp_provider = MCPProvider.from_config(config, tools) 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 # Create agent with cron service
agent = AgentLoop.from_config( agent = AgentLoop.from_config(
config, bus, config, bus,
@@ -447,7 +440,6 @@ def _run_gateway(
local_trigger_store=trigger_store, local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook], hook_factories=[create_file_edit_activity_hook],
tool_registry=tools, tool_registry=tools,
recovery_admission=recovery,
) )
def _schedule_webui_background(awaitable: Awaitable[None]) -> None: def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable)) agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
@@ -456,7 +448,6 @@ def _run_gateway(
bus=bus, bus=bus,
sessions=session_manager, sessions=session_manager,
schedule_background=_schedule_webui_background, schedule_background=_schedule_webui_background,
recovery=recovery,
) )
webui_turn_coordinator.subscribe(runtime_events) webui_turn_coordinator.subscribe(runtime_events)
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@@ -692,7 +683,6 @@ def _run_gateway(
webui_mcp_runtime_status=mcp_provider.runtime_status, webui_mcp_runtime_status=mcp_provider.runtime_status,
webui_mcp_reload=mcp_provider.reload, webui_mcp_reload=mcp_provider.reload,
webui_skill_state_action=_webui_skill_state_action, webui_skill_state_action=_webui_skill_state_action,
webui_recovery_action=recovery.handle_action,
config_path=Path(config_path), config_path=Path(config_path),
) )
@@ -859,7 +849,6 @@ def _run_gateway(
tasks: list[asyncio.Task[Any]] = [] tasks: list[asyncio.Task[Any]] = []
shutdown_task: asyncio.Task[Any] | None = None shutdown_task: asyncio.Task[Any] | None = None
runtime_tasks: asyncio.Future[list[Any]] | None = None runtime_tasks: asyncio.Future[list[Any]] | None = None
startup_complete = False
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
cli_terminal._ensure_interactive_tty_mode() cli_terminal._ensure_interactive_tty_mode()
restore_shutdown_handlers = _install_gateway_shutdown_handlers( restore_shutdown_handlers = _install_gateway_shutdown_handlers(
@@ -872,10 +861,6 @@ def _run_gateway(
await cron.start() await cron.start()
# Re-read once on first admission to close the watcher subscription window. # Re-read once on first admission to close the watcher subscription window.
agent.runtime_resolver.invalidate() 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: async def _run_agent() -> None:
try: try:
await mcp_provider.connect() await mcp_provider.connect()
@@ -930,7 +915,6 @@ def _run_gateway(
name="nanobot-webui-dev-server", name="nanobot-webui-dev-server",
)) ))
runtime_tasks = asyncio.gather(*tasks) runtime_tasks = asyncio.gather(*tasks)
startup_complete = True
shutdown_task = asyncio.create_task( shutdown_task = asyncio.create_task(
shutdown_event.wait(), shutdown_event.wait(),
name="nanobot-gateway-shutdown", name="nanobot-gateway-shutdown",
@@ -952,10 +936,6 @@ def _run_gateway(
console.print("\n[red]Error: Gateway crashed unexpectedly[/red]") console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
console.print(traceback.format_exc()) 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: finally:
try: try:
if shutdown_task and not shutdown_task.done(): if shutdown_task and not shutdown_task.done():
@@ -963,10 +943,6 @@ def _run_gateway(
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await shutdown_task await shutdown_task
cron.stop() 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() agent.stop()
# Cancel runtime tasks first, then deterministically close # Cancel runtime tasks first, then deterministically close
# exec/MCP resources while the event loop is still alive. # exec/MCP resources while the event loop is still alive.
-52
View File
@@ -1,52 +0,0 @@
"""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)
+1 -7
View File
@@ -17,7 +17,6 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from nanobot import __version__ from nanobot import __version__
from nanobot.cli.process_identity import named_executable
from nanobot.cli.runtime_config import _model_display from nanobot.cli.runtime_config import _model_display
from nanobot.cli.webui_support import ( from nanobot.cli.webui_support import (
_gateway_health_ready, _gateway_health_ready,
@@ -230,12 +229,7 @@ def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
detail = (install.stderr or install.stdout).strip().splitlines() detail = (install.stderr or install.stdout).strip().splitlines()
suffix = f": {detail[-1]}" if detail else "" suffix = f": {detail[-1]}" if detail else ""
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}") raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
executable = named_executable( return [bun, str(source_dir / "src" / "index.ts")]
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: def _download_release_tui(asset: str) -> Path | None:
+16
View File
@@ -96,6 +96,22 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
# it; poll() both reaps it and reports the real lifecycle state. # it; poll() both reaps it and reports the real lifecycle state.
self._owned_process: Any | None = None 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: def start_background(self, options: _StartOptionsT) -> ProcessResult:
"""Start the configured command as a detached process.""" """Start the configured command as a detached process."""
with self._lifecycle_lock(): with self._lifecycle_lock():
+2 -135
View File
@@ -48,21 +48,15 @@ _SESSION_PREVIEW_MAX_CHARS = 120
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200 _SESSION_LIST_PREVIEW_MAX_RECORDS = 200
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000 _SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError) _SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
_RUNTIME_CHECKPOINT_DATA_ERRORS = (OSError, *_SESSION_DATA_ERRORS)
_PROVIDER_STATE_RECORD_TYPE = "provider_state" _PROVIDER_STATE_RECORD_TYPE = "provider_state"
_PROVIDER_STATE_RECORD_PREFIX_RE = re.compile( _PROVIDER_STATE_RECORD_PREFIX_RE = re.compile(
r'^\s*\{\s*"_type"\s*:\s*"provider_state"\s*(?:,|\})' 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 = { _FORK_VOLATILE_METADATA_KEYS = {
"goal_state", "goal_state",
"pending_user_turn", "pending_user_turn",
"pending_user_followups",
"runtime_checkpoint", "runtime_checkpoint",
"session_handle", "session_handle",
"webui_recovery",
"thread_goal", "thread_goal",
"title", "title",
"title_user_edited", "title_user_edited",
@@ -1007,9 +1001,6 @@ class JsonlSessionStore:
def get_session_path(self, key: str) -> Path: def get_session_path(self, key: str) -> Path:
return self.sessions_dir / f"{self.storage_key(key)}.jsonl" 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: def get_legacy_lossy_path(self, key: str) -> Path:
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl" return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
@@ -1075,7 +1066,7 @@ class JsonlSessionStore:
else: else:
messages.append(data) messages.append(data)
session = Session( return Session(
key=key, key=key,
messages=messages, messages=messages,
created_at=created_at or datetime.now(), created_at=created_at or datetime.now(),
@@ -1084,8 +1075,6 @@ class JsonlSessionStore:
last_consolidated=last_consolidated, last_consolidated=last_consolidated,
provider_state=provider_state, provider_state=provider_state,
) )
self._overlay_runtime_checkpoint_unlocked(session, path)
return session
except _SESSION_DATA_ERRORS as e: except _SESSION_DATA_ERRORS as e:
logger.warning("Failed to load session {}: {}", key, e) logger.warning("Failed to load session {}: {}", key, e)
repaired = self._repair_unlocked(key) repaired = self._repair_unlocked(key)
@@ -1170,7 +1159,7 @@ class JsonlSessionStore:
if not messages and not metadata and provider_state is None: if not messages and not metadata and provider_state is None:
return None return None
session = Session( return Session(
key=key, key=key,
messages=messages, messages=messages,
created_at=created_at or datetime.now(), created_at=created_at or datetime.now(),
@@ -1179,8 +1168,6 @@ class JsonlSessionStore:
last_consolidated=last_consolidated, last_consolidated=last_consolidated,
provider_state=provider_state, provider_state=provider_state,
) )
self._overlay_runtime_checkpoint_unlocked(session, path)
return session
except _SESSION_DATA_ERRORS as e: except _SESSION_DATA_ERRORS as e:
logger.warning("Repair failed for session {}: {}", key, e) logger.warning("Repair failed for session {}: {}", key, e)
return None return None
@@ -1199,105 +1186,6 @@ class JsonlSessionStore:
with self._session_files_lock: with self._session_files_lock:
self._save_unlocked(session, fsync=fsync) 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: def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
path = self.get_session_path(session.key) path = self.get_session_path(session.key)
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
@@ -1327,10 +1215,6 @@ class JsonlSessionStore:
os.replace(tmp_path, path) 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: if fsync:
with suppress(PermissionError): with suppress(PermissionError):
fd = os.open(str(path.parent), os.O_RDONLY) fd = os.open(str(path.parent), os.O_RDONLY)
@@ -1394,7 +1278,6 @@ class JsonlSessionStore:
def _delete_unlocked(self, key: str) -> bool: def _delete_unlocked(self, key: str) -> bool:
paths = [ paths = [
self.get_session_path(key), self.get_session_path(key),
self.get_runtime_checkpoint_path(key),
self.get_legacy_lossy_path(key), self.get_legacy_lossy_path(key),
self.get_legacy_session_path(key), self.get_legacy_session_path(key),
] ]
@@ -1702,10 +1585,6 @@ class SessionManager:
"""Get the collision-resistant workspace path for a session.""" """Get the collision-resistant workspace path for a session."""
return self._jsonl_store.get_session_path(key) 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: def _get_legacy_lossy_path(self, key: str) -> Path:
"""Previous workspace session path using lossy ':' to '_' replacement.""" """Previous workspace session path using lossy ':' to '_' replacement."""
return self._jsonl_store.get_legacy_lossy_path(key) return self._jsonl_store.get_legacy_lossy_path(key)
@@ -1774,18 +1653,6 @@ class SessionManager:
self._store.save(session, fsync=fsync) self._store.save(session, fsync=fsync)
self._remember(session) 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: def rename_model_preset(self, old_name: str, new_name: str) -> int:
"""Rename a session-scoped model preset across durable and live sessions.""" """Rename a session-scoped model preset across durable and live sessions."""
if old_name == new_name: if old_name == new_name:
-939
View File
@@ -1,939 +0,0 @@
"""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
+9 -4
View File
@@ -43,7 +43,6 @@ from nanobot.runtime_context import public_history_message
from nanobot.session.goal_state import goal_state_ws_blob from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.session.recovery import RecoveryCoordinator
from nanobot.session.session_handles import session_handle_for_name from nanobot.session.session_handles import session_handle_for_name
from nanobot.session.session_messages import ( from nanobot.session.session_messages import (
SessionMessageEnvelope, SessionMessageEnvelope,
@@ -512,7 +511,6 @@ class WebuiTurnCoordinator:
bus: MessageBus bus: MessageBus
sessions: SessionManager sessions: SessionManager
schedule_background: Callable[[Awaitable[None]], None] schedule_background: Callable[[Awaitable[None]], None]
recovery: RecoveryCoordinator | None = None
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]: def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
"""Subscribe this coordinator to runtime events.""" """Subscribe this coordinator to runtime events."""
@@ -656,8 +654,6 @@ class WebuiTurnCoordinator:
event.runtime.context_window_tokens if event.runtime is not None else None 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) self._schedule_title_update_from_event(event)
async def _handle_goal_state_changed(self, event: GoalStateChanged) -> None: async def _handle_goal_state_changed(self, event: GoalStateChanged) -> None:
@@ -689,6 +685,15 @@ 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( async def handle_turn_end(
self, self,
msg: InboundMessage, msg: InboundMessage,
-2
View File
@@ -69,7 +69,6 @@ def build_gateway_services(
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None, mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None, mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
skill_state_action: Callable[[set[str]], None] | 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, logger: Any = default_logger,
) -> GatewayServices: ) -> GatewayServices:
settings = WebUISettingsServices.create( settings = WebUISettingsServices.create(
@@ -132,7 +131,6 @@ def build_gateway_services(
mcp_runtime_status=mcp_runtime_status, mcp_runtime_status=mcp_runtime_status,
mcp_reload=mcp_reload, mcp_reload=mcp_reload,
skill_state_action=skill_state_action, skill_state_action=skill_state_action,
recovery_action=recovery_action,
log=logger, log=logger,
) )
return GatewayServices( return GatewayServices(
+3
View File
@@ -63,6 +63,9 @@ class GatewayTokenStore:
self.api_tokens[token_value] = expiry self.api_tokens[token_value] = expiry
return token_value 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( def take_issued_token_audience(
self, self,
token_value: str | None, token_value: str | None,
+1 -6
View File
@@ -31,9 +31,8 @@ from nanobot.session.manager import (
_metadata_title, # pyright: ignore[reportPrivateUsage] _metadata_title, # pyright: ignore[reportPrivateUsage]
) )
from nanobot.session.model_selection import model_preset_from_metadata from nanobot.session.model_selection import model_preset_from_metadata
from nanobot.session.recovery import recovery_state_from_metadata
_INDEX_VERSION = 8 _INDEX_VERSION = 7
_INDEX_FILENAME = ".webui_session_index.json" _INDEX_FILENAME = ".webui_session_index.json"
_MODEL_PRESET_FIELD = "model_preset" _MODEL_PRESET_FIELD = "model_preset"
_ROW_SOURCE_FIELD = "_source" _ROW_SOURCE_FIELD = "_source"
@@ -246,7 +245,6 @@ def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dic
"title": row.get("title", ""), "title": row.get("title", ""),
"preview": row.get("preview", ""), "preview": row.get("preview", ""),
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD), _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_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD), _WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
"path": str(path), "path": str(path),
@@ -487,7 +485,6 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
"title": _metadata_title(session.metadata), "title": _metadata_title(session.metadata),
"preview": _preview_from_messages(session.messages), "preview": _preview_from_messages(session.messages),
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata), _MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
"recovery_state": recovery_state_from_metadata(session.metadata),
**_indexed_workspace_scope_fields(session.metadata), **_indexed_workspace_scope_fields(session.metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE, _ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name, "file": path.name,
@@ -604,7 +601,6 @@ def _scan_transcript_row(
"title": "", "title": "",
"preview": preview or fallback_preview, "preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: None, _MODEL_PRESET_FIELD: None,
"recovery_state": None,
**_indexed_workspace_scope_fields({}), **_indexed_workspace_scope_fields({}),
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE, _ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
"file": stem, "file": stem,
@@ -691,7 +687,6 @@ def _scan_session_row(
"title": _metadata_title(metadata), "title": _metadata_title(metadata),
"preview": preview or fallback_preview, "preview": preview or fallback_preview,
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata), _MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
"recovery_state": recovery_state_from_metadata(metadata),
**_indexed_workspace_scope_fields(metadata), **_indexed_workspace_scope_fields(metadata),
_ROW_SOURCE_FIELD: _SESSION_SOURCE, _ROW_SOURCE_FIELD: _SESSION_SOURCE,
"file": path.name, "file": path.name,
+61
View File
@@ -36,6 +36,7 @@ from nanobot.webui.nanobot_features_api import (
nanobot_features_payload, nanobot_features_payload,
) )
from nanobot.webui.settings_api import ( from nanobot.webui.settings_api import (
WebUISettingsError,
complete_oauth_provider, complete_oauth_provider,
create_model_configuration, create_model_configuration,
create_provider_settings, create_provider_settings,
@@ -489,6 +490,17 @@ class WebUISettingsRouter:
lambda: request_image_generation_reload(self.bus), 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]: async def _reload_mcp_runtime(self) -> dict[str, Any]:
if self._mcp_reload is None: if self._mcp_reload is None:
return { return {
@@ -519,9 +531,47 @@ class WebUISettingsRouter:
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams: def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
return self._query(request) 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: def _api_runtime(self) -> ApiRuntime:
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path)) 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( def _save_channel_config_values(
self, self,
name: str, name: str,
@@ -560,6 +610,17 @@ class WebUISettingsRouter:
allow_install=allow_install, 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( def _allow_feature_package_install(
self, self,
connection: Any, connection: Any,
+16 -24
View File
@@ -1870,14 +1870,7 @@ def replay_transcript_to_ui_messages(
return None return None
return str(last.get("id")) return str(last.get("id"))
def close_interrupted_assistant() -> None: def demote_interrupted_assistant(segment: str) -> 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 nonlocal buffer_message_id, buffer_parts
for i in range(len(messages) - 1, -1, -1): for i in range(len(messages) - 1, -1, -1):
candidate = messages[i] candidate = messages[i]
@@ -1893,7 +1886,19 @@ def replay_transcript_to_ui_messages(
or candidate.get("media") or candidate.get("media")
): ):
continue continue
messages[i] = {**candidate, "isStreaming": False} 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,
}
if buffer_message_id == candidate.get("id"): if buffer_message_id == candidate.get("id"):
buffer_message_id = None buffer_message_id = None
buffer_parts = [] buffer_parts = []
@@ -2064,7 +2069,7 @@ def replay_transcript_to_ui_messages(
if not segment: if not segment:
segment = _new_activity_segment(activate=False) segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment active_file_edit_segment_id = segment
close_interrupted_assistant() demote_interrupted_assistant(segment)
strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields) strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields)
target_index = find_file_edit_trace_index(segment, edits) target_index = find_file_edit_trace_index(segment, edits)
if target_index is not None: if target_index is not None:
@@ -2358,7 +2363,7 @@ def replay_transcript_to_ui_messages(
if not trace_lines: if not trace_lines:
continue continue
segment = _ensure_activity_segment() segment = _ensure_activity_segment()
close_interrupted_assistant() demote_interrupted_assistant(segment)
last = messages[-1] if messages else None last = messages[-1] if messages else None
if ( if (
last last
@@ -2541,19 +2546,6 @@ def has_pending_tool_calls(
return False 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]: def completed_turn_ids(lines: list[dict[str, Any]]) -> list[str]:
"""Return stable identities for turns with an explicitly persisted completion.""" """Return stable identities for turns with an explicitly persisted completion."""
completed: list[str] = [] completed: list[str] = []
-39
View File
@@ -29,7 +29,6 @@ from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob, CronSchedule from nanobot.cron.types import CronJob, CronSchedule
from nanobot.security.workspace_access import WorkspaceScope from nanobot.security.workspace_access import WorkspaceScope
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.session.recovery import RecoveryActionError
from nanobot.session.session_handles import ( from nanobot.session.session_handles import (
SessionHandleResolver, SessionHandleResolver,
) )
@@ -146,8 +145,6 @@ _WEBUI_MUTATION_PATHS = {
"skill.delete": "/api/webui/skills/delete", "skill.delete": "/api/webui/skills/delete",
"sidebar.update": "/api/webui/sidebar-state/update", "sidebar.update": "/api/webui/sidebar-state/update",
"workspace.pick_folder": "/api/workspaces/pick-folder", "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.agent.update": "/api/settings/update",
"settings.model_configuration.create": "/api/settings/model-configurations/create", "settings.model_configuration.create": "/api/settings/model-configurations/create",
"settings.model_configuration.update": "/api/settings/model-configurations/update", "settings.model_configuration.update": "/api/settings/model-configurations/update",
@@ -326,9 +323,6 @@ class GatewayHTTPHandler:
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None, mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None, mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
skill_state_action: Callable[[set[str]], None] | 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, log: Any = logger,
) -> None: ) -> None:
self.config = config self.config = config
@@ -346,7 +340,6 @@ class GatewayHTTPHandler:
disabled_skills if disabled_skills is not None else set() disabled_skills if disabled_skills is not None else set()
) )
self.skill_state_action = skill_state_action self.skill_state_action = skill_state_action
self.recovery_action = recovery_action
self._skill_install_lock = asyncio.Lock() self._skill_install_lock = asyncio.Lock()
self._folder_picker_lock = asyncio.Lock() self._folder_picker_lock = asyncio.Lock()
self.cron_service = cron_service self.cron_service = cron_service
@@ -461,8 +454,6 @@ class GatewayHTTPHandler:
return True return True
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path): if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
return True return True
if path in {"/api/webui/recovery/continue", "/api/webui/recovery/dismiss"}:
return True
return path in { return path in {
"/api/webui/skills/install", "/api/webui/skills/install",
"/api/webui/skills/update", "/api/webui/skills/update",
@@ -516,11 +507,6 @@ class GatewayHTTPHandler:
if response is not None: if response is not None:
return response return response
# Recovery routes
response = await self._dispatch_recovery_route(request, got)
if response is not None:
return response
# Session routes # Session routes
response = await self._dispatch_session_routes(request, got) response = await self._dispatch_session_routes(request, got)
if response is not None: if response is not None:
@@ -714,27 +700,6 @@ class GatewayHTTPHandler:
return None 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: async def _handle_session_context_get(self, request: WsRequest, key: str) -> Response:
if not self.check_api_token(request): if not self.check_api_token(request):
return _http_error(401, "Unauthorized") return _http_error(401, "Unauthorized")
@@ -781,10 +746,6 @@ class GatewayHTTPHandler:
for k, v in s.items() for k, v in s.items()
if k != "path" and k not in WEBUI_SESSION_INDEX_INTERNAL_FIELDS 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] chat_id = key.split(":", 1)[1]
started_at = websocket_turn_wall_started_at(chat_id) started_at = websocket_turn_wall_started_at(chat_id)
if started_at is not None: if started_at is not None:
+1 -1
View File
@@ -29,6 +29,7 @@ dependencies = [
"pydantic-settings>=2.12.0,<3.0.0", "pydantic-settings>=2.12.0,<3.0.0",
# Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16. # Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16.
"websockets>=15.0,<17.0", "websockets>=15.0,<17.0",
"websocket-client>=1.9.0,<2.0.0",
"httpx[socks]>=0.28.0,<1.0.0", "httpx[socks]>=0.28.0,<1.0.0",
"ddgs>=9.5.5,<10.0.0", "ddgs>=9.5.5,<10.0.0",
"oauth-cli-kit>=0.1.6,<1.0.0", "oauth-cli-kit>=0.1.6,<1.0.0",
@@ -39,7 +40,6 @@ dependencies = [
"qrcode[pil]>=8.0", "qrcode[pil]>=8.0",
"croniter>=6.0.0,<7.0.0", "croniter>=6.0.0,<7.0.0",
"prompt-toolkit>=3.0.50,<4.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", "questionary>=2.0.0,<3.0.0",
"mcp>=1.26.0,<2.0.0", "mcp>=1.26.0,<2.0.0",
"json-repair>=0.57.0,<1.0.0", "json-repair>=0.57.0,<1.0.0",
-58
View File
@@ -36,7 +36,6 @@ from nanobot.session.keys import (
UNIFIED_SESSION_KEY, UNIFIED_SESSION_KEY,
) )
from nanobot.session.manager import Session from nanobot.session.manager import Session
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY, PENDING_FOLLOWUPS_KEY
from nanobot.session.turn_continuation import ( from nanobot.session.turn_continuation import (
INTERNAL_CONTINUATION_META, INTERNAL_CONTINUATION_META,
INTERNAL_CONTINUATION_RUN_STARTED_AT_META, INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
@@ -162,35 +161,6 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
assert message["cron_prompt_ref"] == prompt_ref 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: def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path) loop = _make_full_loop(tmp_path)
session = loop.sessions.get_or_create("websocket:auto") session = loop.sessions.get_or_create("websocket:auto")
@@ -411,34 +381,6 @@ def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
assert public_history_message(session.messages[0])["content"] == [] 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: def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None:
loop = _mk_loop() loop = _mk_loop()
session = Session(key="test:image") session = Session(key="test:image")
+2 -131
View File
@@ -26,7 +26,7 @@ def _make_injection_callback(queue: asyncio.Queue):
return inject_cb return inject_cb
def _make_loop(tmp_path, *, recovery_admission=None): def _make_loop(tmp_path):
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
@@ -39,12 +39,7 @@ def _make_loop(tmp_path, *, recovery_admission=None):
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr: 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.cancel_by_session = AsyncMock(return_value=0)
mock_sub_mgr.return_value.close = AsyncMock() mock_sub_mgr.return_value.close = AsyncMock()
loop = AgentLoop( loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
bus=bus,
provider=provider,
workspace=tmp_path,
recovery_admission=recovery_admission,
)
return loop return loop
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -764,20 +759,6 @@ 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: def test_runner_merge_preserves_runtime_markers_with_media() -> None:
from nanobot.agent.runner import AgentRunner from nanobot.agent.runner import AgentRunner
from nanobot.runtime_context import ( from nanobot.runtime_context import (
@@ -986,71 +967,6 @@ async def test_followup_routed_to_pending_queue(tmp_path):
assert queued_msg.session_key == UNIFIED_SESSION_KEY 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 @pytest.mark.asyncio
async def test_mid_turn_subagent_result_does_not_resolve_a_new_turn_route(tmp_path): 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.""" """Injected results stay inside the active turn instead of opening a side turn."""
@@ -1398,51 +1314,6 @@ async def test_pending_queue_full_falls_back_to_queued_task(tmp_path):
assert pending.qsize() == 1 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 @pytest.mark.asyncio
async def test_dispatch_republishes_leftover_queue_messages(tmp_path): async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
"""Messages left in the pending queue after _dispatch are re-published to the bus. """Messages left in the pending queue after _dispatch are re-published to the bus.
@@ -161,25 +161,3 @@ async def test_dispatch_cancellation_restores_checkpoint():
"Checkpoint metadata should be cleared after restore" "Checkpoint metadata should be cleared after restore"
assert loop.sessions.save.called, \ assert loop.sessions.save.called, \
"Session should be persisted so the restored state survives process restart" "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,27 +3721,6 @@ async def test_notify_restart_done_waits_until_channel_starts():
assert sent_msg.content.startswith("Restart completed") 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 @pytest.mark.asyncio
async def test_restart_notice_retries_until_running_channel_accepts_delivery(): async def test_restart_notice_retries_until_running_channel_accepts_delivery():
"""A running flag must not make an early transport failure final.""" """A running flag must not make an early transport failure final."""
+7 -24
View File
@@ -102,19 +102,6 @@ class _GatewayAgentContractStub:
) -> OutboundMessage | None: ) -> OutboundMessage | None:
return 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: def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
class _FakeLoop: class _FakeLoop:
@@ -2769,7 +2756,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
monkeypatch, monkeypatch,
config, config,
message_bus=lambda: object(), message_bus=lambda: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(), session_manager=lambda _workspace: object(),
) )
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app) monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
@@ -2836,7 +2823,7 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path:
monkeypatch, monkeypatch,
config, config,
message_bus=lambda: object(), message_bus=lambda: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(), session_manager=lambda _workspace: object(),
cron_service=_StopCron, cron_service=_StopCron,
) )
@@ -3342,7 +3329,7 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron(
monkeypatch, monkeypatch,
config, config,
message_bus=lambda: object(), message_bus=lambda: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(), session_manager=lambda _workspace: object(),
cron_service=_StopCron, cron_service=_StopCron,
get_cron_dir=lambda: legacy_dir, get_cron_dir=lambda: legacy_dir,
) )
@@ -3381,7 +3368,7 @@ def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
monkeypatch, monkeypatch,
config, config,
message_bus=lambda: object(), message_bus=lambda: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(), session_manager=lambda _workspace: object(),
cron_service=_StopCron, cron_service=_StopCron,
get_cron_dir=lambda: legacy_dir, get_cron_dir=lambda: legacy_dir,
) )
@@ -3582,7 +3569,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
monkeypatch, monkeypatch,
config, config,
message_bus=lambda: object(), message_bus=lambda: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(), session_manager=lambda _workspace: object(),
) )
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager) monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
@@ -3715,9 +3702,6 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
async def aclose(self) -> None: async def aclose(self) -> None:
seen["agent_closed"] = True seen["agent_closed"] = True
def preserve_inflight_turns_on_shutdown(self) -> None:
seen["inflight_turns_preserved"] = True
def stop(self) -> None: def stop(self) -> None:
seen["agent_stopped"] = True seen["agent_stopped"] = True
@@ -3787,7 +3771,7 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
monkeypatch, monkeypatch,
config, config,
message_bus=lambda: object(), message_bus=lambda: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(), session_manager=lambda _workspace: object(),
) )
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider) monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider)
@@ -3799,7 +3783,6 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
assert result.exit_code == 0 assert result.exit_code == 0
assert seen["agent_stopped"] is True assert seen["agent_stopped"] is True
assert seen["inflight_turns_preserved"] is True
assert seen["agent_closed"] is True assert seen["agent_closed"] is True
assert seen["agent_task_cleaned_up"] is True assert seen["agent_task_cleaned_up"] is True
assert seen["channels_stopped"] is True assert seen["channels_stopped"] is True
@@ -3911,7 +3894,7 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
monkeypatch, monkeypatch,
config, config,
message_bus=lambda: object(), message_bus=lambda: object(),
session_manager=lambda _workspace: _EmptyGatewaySessionManager(), session_manager=lambda _workspace: object(),
) )
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop) monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager) monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
-91
View File
@@ -1,91 +0,0 @@
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"
)
+1 -5
View File
@@ -467,13 +467,9 @@ def test_source_checkout_refreshes_locked_tui_dependencies(
return subprocess.CompletedProcess(command, 0, "", "") return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install) 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) == [ assert _resolve_source_tui_command(source_dir, bun) == [
f"{bun}-named", bun,
str(source_dir / "src" / "index.ts"), str(source_dir / "src" / "index.ts"),
] ]
-28
View File
@@ -18,7 +18,6 @@ from nanobot.gateway import (
GatewayRuntimePaths, GatewayRuntimePaths,
GatewayStartOptions, GatewayStartOptions,
GatewayStatus, GatewayStatus,
RuntimeResult,
) )
from nanobot.gateway.runtime import monitor_gateway_clients from nanobot.gateway.runtime import monitor_gateway_clients
from nanobot.process_runtime import process_is_running from nanobot.process_runtime import process_is_running
@@ -455,33 +454,6 @@ def test_restart_does_not_detach_a_foreground_gateway(tmp_path, monkeypatch):
assert result.message == "gateway_foreground_restart_required" 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): def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux") runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid) monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
-773
View File
@@ -1,773 +0,0 @@
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,7 +1,6 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
import nanobot.session as session_api import nanobot.session as session_api
from nanobot.providers.base import ProviderConversationState
from nanobot.session import Session, SessionManager from nanobot.session import Session, SessionManager
from nanobot.session.manager import SessionStore from nanobot.session.manager import SessionStore
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
@@ -124,93 +123,3 @@ def test_manager_preserves_full_session_before_store_save(tmp_path) -> None:
assert session.messages[0]["content"] == "0" assert session.messages[0]["content"] == "0"
assert session.messages[-1]["content"] == "2000" assert session.messages[-1]["content"] == "2000"
store.save.assert_called_once_with(session, fsync=False) 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()
+3 -23
View File
@@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import nanobot.webui.transcript as transcript_module import nanobot.webui.transcript as transcript_module
from nanobot.session.history_visibility import HIDDEN_HISTORY_META from nanobot.session.history_visibility import HIDDEN_HISTORY_META
from nanobot.webui.transcript import ( from nanobot.webui.transcript import (
@@ -556,22 +554,6 @@ def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatc
assert out["completed_turn_ids"] == [] 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( def test_thread_response_reports_active_registry_without_transcript(
tmp_path, tmp_path,
monkeypatch, monkeypatch,
@@ -1503,7 +1485,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"] assert [edit["path"] for edit in msgs[0]["fileEdits"]] == ["USER.md", "MEMORY.md"]
def test_replay_keeps_interrupted_pre_tool_text_as_answer() -> None: def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
msgs = replay_transcript_to_ui_messages([ msgs = replay_transcript_to_ui_messages([
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."}, {"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
{"event": "stream_end", "chat_id": "t-stream"}, {"event": "stream_end", "chat_id": "t-stream"},
@@ -1522,10 +1504,8 @@ def test_replay_keeps_interrupted_pre_tool_text_as_answer() -> None:
assert len(msgs) == 3 assert len(msgs) == 3
assert msgs[0]["role"] == "assistant" assert msgs[0]["role"] == "assistant"
assert msgs[0]["content"] == "I will inspect first." assert msgs[0]["content"] == ""
assert msgs[0]["turnPhase"] == "answer" assert msgs[0]["reasoning"] == "I will inspect first."
assert "reasoning" not in msgs[0]
assert "activitySegmentId" not in msgs[0]
assert "isStreaming" not in msgs[0] assert "isStreaming" not in msgs[0]
assert msgs[1]["kind"] == "trace" assert msgs[1]["kind"] == "trace"
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})'] assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
-84
View File
@@ -15,9 +15,6 @@ import httpx
import pytest import pytest
import websockets import websockets
from nanobot.session.manager import SessionManager
from nanobot.session.recovery import PENDING_USER_TURN_KEY, RUNTIME_CHECKPOINT_KEY
_BOOTSTRAP_SECRET = "smoke-secret" _BOOTSTRAP_SECRET = "smoke-secret"
@@ -209,84 +206,3 @@ async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Pa
assert any("shell-ok" in text for text in contents) assert any("shell-ok" in text for text in contents)
finally: finally:
_stop_gateway(process) _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,7 +18,6 @@ from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.history_visibility import HIDDEN_HISTORY_META from nanobot.session.history_visibility import HIDDEN_HISTORY_META
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
from nanobot.session.recovery import RECOVERY_METADATA_KEY
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -67,30 +66,6 @@ def test_webui_session_list_refreshes_after_model_preset_rename(tmp_path: Path)
assert list_webui_sessions(manager)[0]["model_preset"] == "Codex" 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: def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
manager = SessionManager(tmp_path) manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:unique-index-temp") session = manager.get_or_create("websocket:unique-index-temp")
+1 -225
View File
@@ -7,12 +7,7 @@ import {
} from "@opentui/core/testing" } from "@opentui/core/testing"
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app" import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
import type { import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
MessageOptions,
RecoveryState,
SlashCommand,
WorkspaceScopePayload,
} from "./protocol"
import type { HostAgentState, HostMetadata, TuiHost } from "./host" import type { HostAgentState, HostMetadata, TuiHost } from "./host"
const options: AppOptions = { const options: AppOptions = {
@@ -96,13 +91,6 @@ function client(
setWorkspaceScope(scope: WorkspaceScopePayload) { setWorkspaceScope(scope: WorkspaceScopePayload) {
scopes.push(scope) scopes.push(scope)
}, },
updateRecovery(
_action: "continue" | "dismiss",
_chatId: string,
recoveryId: string,
): Promise<RecoveryState> {
return Promise.resolve({ status: "recovered" as const, recovery_id: recoveryId })
},
} }
} }
@@ -567,97 +555,6 @@ 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 () => { test("refreshes expired API credentials before opening sessions", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const original = globalThis.fetch const original = globalThis.fetch
@@ -1006,127 +903,6 @@ 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 () => { test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => {
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" }) setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
const sent: string[] = [] const sent: string[] = []
+20 -220
View File
@@ -34,7 +34,6 @@ import {
type InboundEvent, type InboundEvent,
type MentionCandidate, type MentionCandidate,
type MessageOptions, type MessageOptions,
type RecoveryState,
type SlashCommand, type SlashCommand,
type SessionSummary, type SessionSummary,
type TokenUsage, type TokenUsage,
@@ -71,7 +70,6 @@ import {
} from "./mention-menu" } from "./mention-menu"
import { PromptQueue, type QueuedPrompt } from "./prompt-queue" import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview" import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
import { RecoveryNotice, type RecoveryNoticeTheme } from "./recovery-notice"
import { RuntimeControls } from "./runtime-controls" import { RuntimeControls } from "./runtime-controls"
import { import {
contextualFooterHints, contextualFooterHints,
@@ -109,11 +107,6 @@ interface ChatClient {
newChat(scope?: WorkspaceScopePayload): void newChat(scope?: WorkspaceScopePayload): void
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
setWorkspaceScope(scope: WorkspaceScopePayload): void setWorkspaceScope(scope: WorkspaceScopePayload): void
updateRecovery(
action: "continue" | "dismiss",
chatId: string,
recoveryId: string,
): Promise<RecoveryState>
} }
interface Palette { interface Palette {
@@ -125,7 +118,6 @@ interface Palette {
accent: string accent: string
link: string link: string
success: string success: string
warning: string
error: string error: string
user: string user: string
userBackground: string userBackground: string
@@ -142,7 +134,6 @@ const DARK: Palette = {
accent: "#EF8E30", accent: "#EF8E30",
link: "#60A5FA", link: "#60A5FA",
success: "#5CC489", success: "#5CC489",
warning: "#F5C451",
error: "#F87171", error: "#F87171",
user: "#EF8E30", user: "#EF8E30",
// Codex-style turn anchor: 12% white over the reference dark background. // Codex-style turn anchor: 12% white over the reference dark background.
@@ -160,7 +151,6 @@ const LIGHT: Palette = {
accent: "#B94D0B", accent: "#B94D0B",
link: "#1D4ED8", link: "#1D4ED8",
success: "#166534", success: "#166534",
warning: "#A16207",
error: "#B91C1C", error: "#B91C1C",
user: "#B94D0B", user: "#B94D0B",
// Codex-style turn anchor: 4% black over the reference light background. // Codex-style turn anchor: 4% black over the reference light background.
@@ -174,7 +164,6 @@ const ACTIVE_COMPOSER_PLACEHOLDER = "Steer this turn…"
const SHIMMER_PAUSE = 16 const SHIMMER_PAUSE = 16
const SHIMMER_BAND = 4 const SHIMMER_BAND = 4
const SHIMMER_INTERVAL_MS = 80 const SHIMMER_INTERVAL_MS = 80
const SESSION_REFRESH_INTERVAL_MS = 1_000
const LOCAL_COMMANDS: TuiCommand[] = [ const LOCAL_COMMANDS: TuiCommand[] = [
{ {
command: "/sessions", command: "/sessions",
@@ -263,8 +252,6 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme {
text: palette.text, text: palette.text,
muted: palette.muted, muted: palette.muted,
border: palette.border, border: palette.border,
accent: palette.accent,
warning: palette.warning,
selectedBackground: palette.userBackground, selectedBackground: palette.userBackground,
} }
} }
@@ -308,17 +295,6 @@ 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 { function footerHintTheme(palette: Palette): FooterHintTheme {
return { return {
accent: palette.accent, accent: palette.accent,
@@ -404,7 +380,6 @@ export class NanobotTui {
private readonly contextPanel: ContextPanel private readonly contextPanel: ContextPanel
private readonly diffViewer: DiffViewer private readonly diffViewer: DiffViewer
private readonly queuePreview: QueuePreview private readonly queuePreview: QueuePreview
private readonly recoveryNotice: RecoveryNotice
private readonly client: ChatClient private readonly client: ChatClient
private readonly shell: BoxRenderable private readonly shell: BoxRenderable
private readonly title: BoxRenderable private readonly title: BoxRenderable
@@ -415,8 +390,7 @@ export class NanobotTui {
private readonly meta: TextRenderable private readonly meta: TextRenderable
private readonly host: TuiHost private readonly host: TuiHost
private readonly draft = new ComposerDraft() private readonly draft = new ComposerDraft()
private readonly promptQueues = new Map<string, PromptQueue>() private readonly promptQueue = new PromptQueue()
private currentChatId = ""
private palette: Palette private palette: Palette
private activeThemeMode: ThemeMode private activeThemeMode: ThemeMode
private backgroundKnown: boolean private backgroundKnown: boolean
@@ -462,8 +436,6 @@ export class NanobotTui {
private quitting = false private quitting = false
private sessionLoadId = 0 private sessionLoadId = 0
private sessionLoading = false private sessionLoading = false
private sessionRefreshPending = false
private sessionRefreshTimer: ReturnType<typeof setInterval> | null = null
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>() private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
private readonly modelCommandTurns = new Set<string>() private readonly modelCommandTurns = new Set<string>()
private readonly silentCommandTurns = new Set<string>() private readonly silentCommandTurns = new Set<string>()
@@ -472,8 +444,6 @@ export class NanobotTui {
private currentTask = "" private currentTask = ""
private currentAction = "" private currentAction = ""
private hostBlocked = false private hostBlocked = false
private recoveryState: RecoveryState | null = null
private recoveryPending = false
private hostWorkspace: string private hostWorkspace: string
private hostBranch: string private hostBranch: string
private readonly apiReauthenticator: ApiReauthenticator | undefined private readonly apiReauthenticator: ApiReauthenticator | undefined
@@ -525,14 +495,6 @@ export class NanobotTui {
treeSitterClient, treeSitterClient,
) )
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette)) 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({ this.client = client || new NanobotClient({
...(options.bootstrapUrl ...(options.bootstrapUrl
? { ? {
@@ -761,7 +723,6 @@ export class NanobotTui {
this.shell.add(this.runtimeControls.menuRoot) this.shell.add(this.runtimeControls.menuRoot)
if (!host.hosted) this.shell.add(this.title) if (!host.hosted) this.shell.add(this.title)
this.shell.add(this.queuePreview.root) this.shell.add(this.queuePreview.root)
this.shell.add(this.recoveryNotice.root)
this.shell.add(this.composerFrame) this.shell.add(this.composerFrame)
this.shell.add(statusRow) this.shell.add(statusRow)
this.shell.add(this.diffViewer.root) this.shell.add(this.diffViewer.root)
@@ -870,12 +831,6 @@ export class NanobotTui {
this.quit() this.quit()
return 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) const completion = this.commandMenu.completion(visibleContent)
if (completion) { if (completion) {
this.setComposer(completion) this.setComposer(completion)
@@ -970,8 +925,6 @@ export class NanobotTui {
accept(event: InboundEvent): void { accept(event: InboundEvent): void {
if (event.event === "attached") { 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) this.host.reportSession(event.chat_id)
if (event.usage) this.lastUsage = event.usage if (event.usage) this.lastUsage = event.usage
if (event.model_preset !== undefined) { if (event.model_preset !== undefined) {
@@ -993,11 +946,7 @@ export class NanobotTui {
} }
const hydrationId = ++this.hydrationId const hydrationId = ++this.hydrationId
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => { void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
if (hydrationId !== this.hydrationId) return if (hydrationId === this.hydrationId) this.flushPendingEvents()
this.applyRecoveryState(event.recovery_state ?? null)
this.flushPendingEvents()
this.syncQueuePreview()
if (switchedSession) this.sendNextFollowUp()
}) })
return return
} }
@@ -1134,9 +1083,6 @@ export class NanobotTui {
this.applyHostGoalState(event.goal_state) this.applyHostGoalState(event.goal_state)
if (!this.activeTurn) this.reportHostResting() if (!this.activeTurn) this.reportHostResting()
return return
case "recovery_state":
this.applyRecoveryState(event)
return
case "turn_model_updated": case "turn_model_updated":
if (typeof event.context_window_tokens === "number") { if (typeof event.context_window_tokens === "number") {
this.contextWindowTokens = event.context_window_tokens this.contextWindowTokens = event.context_window_tokens
@@ -1245,87 +1191,6 @@ export class NanobotTui {
for (const event of events || []) this.accept(event) 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 { private updateGatewayApiConnection(apiUrl: string, apiToken: string): void {
this.options.apiUrl = apiUrl this.options.apiUrl = apiUrl
this.options.apiToken = apiToken this.options.apiToken = apiToken
@@ -1417,7 +1282,6 @@ export class NanobotTui {
} }
private renderActiveStatus(): void { private renderActiveStatus(): void {
if (this.sessionLoading || this.sessionMenu.visible) return
const elapsed = formatElapsed(Date.now() - this.activeStartedAt) const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : "" const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : "" const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
@@ -1447,16 +1311,6 @@ export class NanobotTui {
this.sendPrompt(prompt) 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 { private restoreQueuedPrompts(): void {
const queued = this.promptQueue.restore() const queued = this.promptQueue.restore()
if (!queued.length) return if (!queued.length) return
@@ -1722,7 +1576,6 @@ export class NanobotTui {
this.contextPanel.setTheme(contextPanelTheme(this.palette)) this.contextPanel.setTheme(contextPanelTheme(this.palette))
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown)) this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
this.queuePreview.setTheme(queuePreviewTheme(this.palette)) this.queuePreview.setTheme(queuePreviewTheme(this.palette))
this.recoveryNotice.setTheme(recoveryNoticeTheme(this.palette))
this.updateComposerAppearance() this.updateComposerAppearance()
this.composer.textColor = this.palette.text this.composer.textColor = this.palette.text
this.composer.focusedTextColor = this.palette.text this.composer.focusedTextColor = this.palette.text
@@ -2005,7 +1858,7 @@ export class NanobotTui {
private closeTransientMenus(): void { private closeTransientMenus(): void {
this.commandMenu.hide() this.commandMenu.hide()
this.hideSessionMenu() this.sessionMenu.hide()
this.mentionMenu.hide() this.mentionMenu.hide()
this.branchMenu.hide() this.branchMenu.hide()
this.contextPanel.hide() this.contextPanel.hide()
@@ -2059,7 +1912,7 @@ export class NanobotTui {
return return
} }
this.commandMenu.hide() this.commandMenu.hide()
this.hideSessionMenu() this.sessionMenu.hide()
this.contextPanel.hide() this.contextPanel.hide()
this.clearComposer() this.clearComposer()
this.status.content = "Loading branch points…" this.status.content = "Loading branch points…"
@@ -2121,6 +1974,10 @@ export class NanobotTui {
} }
private async openSessions(): Promise<void> { 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.commandMenu.hide()
this.dismissRuntimeControls() this.dismissRuntimeControls()
this.mentionMenu.hide() this.mentionMenu.hide()
@@ -2144,17 +2001,10 @@ export class NanobotTui {
this.sessionTitle = sessionLabel(current) this.sessionTitle = sessionLabel(current)
this.applySessionModel(current) this.applySessionModel(current)
this.applySessionScope(current) this.applySessionScope(current)
this.applyRecoveryState(current.recoveryState ?? null)
this.updateTitle() this.updateTitle()
} }
const limit = this.renderer.height >= 20 ? 8 : 4 const limit = this.renderer.height >= 20 ? 8 : 4
this.sessionMenu.open( this.sessionMenu.open(sessions, this.client.activeChatId, limit)
sessions,
this.client.activeChatId,
limit,
this.defaultModelPreset,
)
this.startSessionRefresh()
this.renderTitleColor() this.renderTitleColor()
this.sessionMenu.update(this.composer.plainText, limit) this.sessionMenu.update(this.composer.plainText, limit)
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
@@ -2169,14 +2019,17 @@ export class NanobotTui {
} }
private switchSession(session: SessionSummary): void { private switchSession(session: SessionSummary): void {
this.sessionMenu.markRead(session.chatId) if (this.activeTurn) {
this.status.content = "Wait for the current turn or press Ctrl+C"
return
}
if (session.chatId === this.client.activeChatId) { if (session.chatId === this.client.activeChatId) {
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
this.applySessionModel(session) this.applySessionModel(session)
this.applySessionScope(session) this.applySessionScope(session)
this.applyRecoveryState(session.recoveryState ?? null)
this.updateTitle() this.updateTitle()
this.closeSessions() this.closeSessions()
this.status.content = this.readyStatus()
return return
} }
if (!this.ready) { if (!this.ready) {
@@ -2186,10 +2039,7 @@ export class NanobotTui {
this.closeSessions() this.closeSessions()
try { try {
this.ready = false this.ready = false
this.activeTurnId = null this.clearPromptQueue()
this.setActive(false)
this.clearRecoveryState()
this.queuePreview.update([])
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.clearHostContext() this.clearHostContext()
this.sessionTitle = sessionLabel(session) this.sessionTitle = sessionLabel(session)
@@ -2216,14 +2066,13 @@ export class NanobotTui {
return return
} }
this.commandMenu.hide() this.commandMenu.hide()
this.hideSessionMenu() this.sessionMenu.hide()
this.mentionMenu.hide() this.mentionMenu.hide()
this.branchMenu.hide() this.branchMenu.hide()
this.contextPanel.hide() this.contextPanel.hide()
this.clearComposer() this.clearComposer()
try { try {
this.ready = false this.ready = false
this.clearRecoveryState()
this.clearPromptQueue() this.clearPromptQueue()
this.sessionMetadataId += 1 this.sessionMetadataId += 1
this.clearHostContext() this.clearHostContext()
@@ -2324,65 +2173,18 @@ export class NanobotTui {
private closeSessions(): void { private closeSessions(): void {
this.sessionLoadId += 1 this.sessionLoadId += 1
this.sessionLoading = false this.sessionLoading = false
this.hideSessionMenu() this.sessionMenu.hide()
this.renderTitleColor() this.renderTitleColor()
this.clearComposer() this.clearComposer()
this.syncComposerPlaceholder() this.syncComposerPlaceholder()
this.composer.focus() this.composer.focus()
if (this.activeTurn) this.renderActiveStatus() if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
else if (this.ready) this.status.content = this.readyStatus()
this.updateMeta() 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> { private async openContext(): Promise<void> {
this.commandMenu.hide() this.commandMenu.hide()
this.hideSessionMenu() this.sessionMenu.hide()
this.mentionMenu.hide() this.mentionMenu.hide()
this.branchMenu.hide() this.branchMenu.hide()
this.clearComposer() this.clearComposer()
@@ -2454,7 +2256,7 @@ export class NanobotTui {
private openDiff(): void { private openDiff(): void {
this.commandMenu.hide() this.commandMenu.hide()
this.hideSessionMenu() this.sessionMenu.hide()
this.mentionMenu.hide() this.mentionMenu.hide()
this.branchMenu.hide() this.branchMenu.hide()
this.contextPanel.hide() this.contextPanel.hide()
@@ -2518,7 +2320,6 @@ export class NanobotTui {
this.quitting = true this.quitting = true
this.submitGeneration += 1 this.submitGeneration += 1
this.submitPending = false this.submitPending = false
this.stopSessionRefresh()
this.host.release() this.host.release()
this.client.close() this.client.close()
this.renderer.destroy() this.renderer.destroy()
@@ -2529,7 +2330,6 @@ export class NanobotTui {
private handleDestroy = (): void => { private handleDestroy = (): void => {
if (this.shimmerTimer) clearInterval(this.shimmerTimer) if (this.shimmerTimer) clearInterval(this.shimmerTimer)
this.stopSessionRefresh()
this.transcript.destroy() this.transcript.destroy()
this.diffViewer.destroy() this.diffViewer.destroy()
this.host.release() this.host.release()
+1 -1
View File
@@ -5,7 +5,7 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
export type CommandMenuTheme = PickerMenuTheme export type CommandMenuTheme = PickerMenuTheme
type TuiCommandAction = export type TuiCommandAction =
| "sessions" | "sessions"
| "new-chat" | "new-chat"
| "context" | "context"
+3 -46
View File
@@ -1,27 +1,22 @@
import { import {
BoxRenderable, BoxRenderable,
RGBA, RGBA,
StyledText,
TextAttributes, TextAttributes,
TextRenderable, TextRenderable,
type CliRenderer, type CliRenderer,
type TextChunk,
} from "@opentui/core" } from "@opentui/core"
export interface PickerMenuTheme { export interface PickerMenuTheme {
text: string text: string
muted: string muted: string
border: string border: string
accent?: string
warning?: string
selectedBackground?: string selectedBackground?: string
} }
interface PickerMenuOptions<T> { interface PickerMenuOptions<T> {
id: string id: string
key?: (item: T) => string
searchText: (item: T) => string searchText: (item: T) => string
render: (item: T, selected: boolean) => string | TextChunk[] render: (item: T) => string
emptyText?: string emptyText?: string
maxWidth?: number maxWidth?: number
onSelect?: (item: T) => void onSelect?: (item: T) => void
@@ -73,21 +68,9 @@ export class PickerMenu<T> {
this.update(query, limit) 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 { update(query: string, limit = this.limit): void {
if (!this.visible) return if (!this.visible) return
const changed = query !== this.query const changed = query !== this.query
const previous = this.matches[this.selected]
const previousKey = previous === undefined ? null : this.options.key?.(previous)
this.query = query this.query = query
this.limit = Math.max(1, limit) this.limit = Math.max(1, limit)
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean) const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
@@ -97,18 +80,7 @@ export class PickerMenu<T> {
return words.every((word) => haystack.includes(word)) return words.every((word) => haystack.includes(word))
}) })
.slice(0, this.limit) .slice(0, this.limit)
if (changed) { this.selected = changed ? 0 : Math.min(this.selected, Math.max(0, this.matches.length - 1))
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() this.render()
} }
@@ -153,16 +125,9 @@ export class PickerMenu<T> {
} }
for (const [index, item] of this.matches.entries()) { for (const [index, item] of this.matches.entries()) {
const selected = index === this.selected 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, { this.root.add(new TextRenderable(this.renderer, {
id: `${this.options.id}-${index}`, id: `${this.options.id}-${index}`,
content, content: `${selected ? "" : " "} ${this.options.render(item)}`,
width: "100%", width: "100%",
height: 1, height: 1,
wrapMode: "none", wrapMode: "none",
@@ -196,11 +161,3 @@ export class PickerMenu<T> {
} }
} }
} }
function chunk(text: string, color: string): TextChunk {
return {
__isChunk: true,
text,
fg: RGBA.fromHex(color),
}
}
-56
View File
@@ -595,62 +595,6 @@ 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 () => { test("reports when the bounded history snapshot omits earlier turns", async () => {
const original = globalThis.fetch const original = globalThis.fetch
let requested = "" let requested = ""
+4 -135
View File
@@ -29,14 +29,14 @@ export interface FileEditEvent {
diff?: FileDiff diff?: FileDiff
} }
interface FileDiff { export interface FileDiff {
format: "unified" | string format: "unified" | string
context?: number context?: number
truncated?: boolean truncated?: boolean
text?: string text?: string
} }
interface MediaAttachment { export interface MediaAttachment {
kind: "image" | "video" | "file" kind: "image" | "video" | "file"
url: string url: string
name?: string name?: string
@@ -54,16 +54,6 @@ export interface RuntimeControls {
canUseFullAccess: boolean 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 = export type InboundEvent =
| { event: "ready"; chat_id: string; client_id: string } | { event: "ready"; chat_id: string; client_id: string }
| { | {
@@ -71,7 +61,6 @@ export type InboundEvent =
chat_id: string chat_id: string
model_preset?: string | null model_preset?: string | null
usage?: TokenUsage usage?: TokenUsage
recovery_state?: RecoveryState
} }
| { | {
event: "message_accepted" event: "message_accepted"
@@ -129,7 +118,6 @@ export type InboundEvent =
turn_id?: string turn_id?: string
} }
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> } | { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
| ({ event: "recovery_state"; chat_id: string } & RecoveryState)
| { | {
event: "session_updated" event: "session_updated"
chat_id: string chat_id: string
@@ -151,12 +139,6 @@ type OutboundEvent =
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string } | { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string } | { type: "attach"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload } | { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| {
type: "webui_request"
request_id: string
action: string
payload: Record<string, unknown>
}
| { | {
type: "message" type: "message"
chat_id: string chat_id: string
@@ -243,7 +225,7 @@ export interface SessionContextSnapshot {
lastUsage: TokenUsage | null lastUsage: TokenUsage | null
} }
interface SessionMention { export interface SessionMention {
name: string name: string
session_key: string session_key: string
title?: string title?: string
@@ -289,7 +271,6 @@ export interface SessionSummary {
updatedAt: string | null updatedAt: string | null
runStartedAt: number | null runStartedAt: number | null
modelPreset: string | null modelPreset: string | null
recoveryState?: RecoveryState | null
workspaceScope?: WorkspaceScopePayload | null workspaceScope?: WorkspaceScopePayload | null
pinned: boolean pinned: boolean
archived: boolean archived: boolean
@@ -316,7 +297,6 @@ const CHAT_EVENTS = new Set([
"turn_end", "turn_end",
"goal_status", "goal_status",
"goal_state", "goal_state",
"recovery_state",
"session_updated", "session_updated",
"turn_model_updated", "turn_model_updated",
"error", "error",
@@ -397,34 +377,6 @@ function isWorkspaceScope(value: unknown): value is WorkspaceScopePayload {
&& optional(value.restrict_to_workspace, "boolean") && 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 { function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
if (!isRecord(value)) return null if (!isRecord(value)) return null
const record = value const record = value
@@ -455,8 +407,7 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
&& ((record.model_preset !== undefined && ((record.model_preset !== undefined
&& record.model_preset !== null && record.model_preset !== null
&& typeof record.model_preset !== "string") && 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 ) return null
if ( if (
["user_message", "message", "delta", "reasoning_delta"].includes(name) ["user_message", "message", "delta", "reasoning_delta"].includes(name)
@@ -501,7 +452,6 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
) return null ) return null
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") 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 === "goal_state" && !isRecord(record.goal_state)) return null
if (name === "recovery_state" && !isRecoveryState(record)) return null
if ( if (
name === "session_updated" name === "session_updated"
&& (!optional(record.scope, "string") && (!optional(record.scope, "string")
@@ -750,9 +700,6 @@ export async function fetchSessions(
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim() modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
? value.model_preset.trim() ? value.model_preset.trim()
: null, : null,
...(isRecoveryState(value.recovery_state)
? { recoveryState: value.recovery_state }
: {}),
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}), ...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
pinned: pinned.has(value.key), pinned: pinned.has(value.key),
archived: archived.has(value.key), archived: archived.has(value.key),
@@ -908,11 +855,6 @@ export class NanobotClient {
private closedByClient = false private closedByClient = false
private opening = false private opening = false
private connectedOnce = 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) {} constructor(private readonly options: ClientOptions) {}
@@ -974,7 +916,6 @@ export class NanobotClient {
socket.addEventListener("close", () => { socket.addEventListener("close", () => {
if (this.socket !== socket) return if (this.socket !== socket) return
this.socket = null this.socket = null
this.rejectPendingMutations("gateway connection closed")
if (this.closedByClient) { if (this.closedByClient) {
this.options.onStatus("closed") this.options.onStatus("closed")
return return
@@ -990,7 +931,6 @@ export class NanobotClient {
const socket = this.socket const socket = this.socket
this.socket = null this.socket = null
socket?.close() socket?.close()
this.rejectPendingMutations("gateway connection closed")
} }
send(content: string, options: MessageOptions = {}): string { send(content: string, options: MessageOptions = {}): string {
@@ -1035,63 +975,6 @@ export class NanobotClient {
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope }) 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 { private handleMessage(raw: string): void {
let value: unknown let value: unknown
try { try {
@@ -1100,20 +983,6 @@ export class NanobotClient {
this.options.onStatus("error", "gateway sent invalid JSON") this.options.onStatus("error", "gateway sent invalid JSON")
return 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) const event = decodeInboundEvent(value)
if (event === undefined) return if (event === undefined) return
if (event === null) { if (event === null) {
-177
View File
@@ -1,177 +0,0 @@
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,
}
}
+2 -108
View File
@@ -24,11 +24,6 @@ const sessions: SessionSummary[] = [
updatedAt: "2026-08-12T10:00:00Z", updatedAt: "2026-08-12T10:00:00Z",
runStartedAt: null, runStartedAt: null,
modelPreset: null, modelPreset: null,
recoveryState: {
status: "awaiting_user",
recovery_id: "recovery-two",
reason: "tool execution interrupted",
},
pinned: false, pinned: false,
archived: false, archived: false,
}, },
@@ -48,21 +43,17 @@ describe("SessionMenu", () => {
text: "#FFFFFF", text: "#FFFFFF",
muted: "#999999", muted: "#999999",
border: "#555555", border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
}) })
setup.renderer.root.add(menu.root) setup.renderer.root.add(menu.root)
menu.open(sessions, "one", 6, "Codex") menu.open(sessions, "one", 6)
await setup.renderOnce() await setup.renderOnce()
expect(setup.captureCharFrame()).toContain(" ● API migration") 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") expect(menu.choose()?.chatId).toBe("one")
menu.update("release stable", 6) menu.update("release stable", 6)
await setup.renderOnce() await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Release checklist") expect(setup.captureCharFrame()).toContain("Release checklist")
expect(menu.choose()?.chatId).toBe("two") expect(menu.choose()?.chatId).toBe("two")
}) })
@@ -72,80 +63,12 @@ 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 () => { test("keeps keyboard selection when the pointer stays over the previous row", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" }) setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, { const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF", text: "#FFFFFF",
muted: "#999999", muted: "#999999",
border: "#555555", border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
}) })
setup.renderer.root.add(menu.root) setup.renderer.root.add(menu.root)
menu.open(sessions, "one", 6) menu.open(sessions, "one", 6)
@@ -174,8 +97,6 @@ describe("SessionMenu", () => {
text: "#FFFFFF", text: "#FFFFFF",
muted: "#999999", muted: "#999999",
border: "#555555", border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
}) })
setup.renderer.root.add(menu.root) setup.renderer.root.add(menu.root)
const scoped = sessions.map((session) => ({ const scoped = sessions.map((session) => ({
@@ -233,31 +154,4 @@ describe("SessionMenu", () => {
expect(duplicates).toContain("frontend/nanobot") expect(duplicates).toContain("frontend/nanobot")
expect(duplicates).toContain("backend/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()
})
}) })
+14 -165
View File
@@ -1,11 +1,9 @@
import { RGBA, type BoxRenderable, type CliRenderer, type TextChunk } from "@opentui/core" import { type BoxRenderable, type CliRenderer } from "@opentui/core"
import { PickerMenu, type PickerMenuTheme } from "./picker-menu" import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
import type { SessionSummary } from "./protocol" import type { SessionSummary } from "./protocol"
type SessionMenuRow = SessionSummary & { active: boolean; unread: boolean } type SessionMenuRow = SessionSummary & { active: boolean }
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
export function sessionLabel(session: SessionSummary): string { export function sessionLabel(session: SessionSummary): string {
const label = session.title.trim() || session.preview.trim() || "Untitled chat" const label = session.title.trim() || session.preview.trim() || "Untitled chat"
@@ -29,24 +27,14 @@ export class SessionMenu {
private readonly picker: PickerMenu<SessionMenuRow> private readonly picker: PickerMenu<SessionMenuRow>
private readonly workspaceLabels = new Map<string, string>() private readonly workspaceLabels = new Map<string, string>()
private showWorkspaces = false 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( constructor(
renderer: CliRenderer, renderer: CliRenderer,
private theme: PickerMenuTheme, theme: PickerMenuTheme,
onSelect?: (session: SessionSummary) => void, onSelect?: (session: SessionSummary) => void,
) { ) {
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, { this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
id: "nanobot-tui-session-menu", id: "nanobot-tui-session-menu",
key: (session) => session.chatId,
searchText: (session) => [ searchText: (session) => [
sessionLabel(session), sessionLabel(session),
session.modelPreset || "", session.modelPreset || "",
@@ -54,27 +42,20 @@ export class SessionMenu {
session.chatId, session.chatId,
session.workspaceScope?.project_name || "", session.workspaceScope?.project_name || "",
session.workspaceScope?.project_path || "", session.workspaceScope?.project_path || "",
session.recoveryState?.status || "",
session.recoveryState?.reason || "",
].join(" "), ].join(" "),
render: (session, selected) => { render: (session) => {
const age = updatedLabel(session.updatedAt) const age = updatedLabel(session.updatedAt)
const preview = session.preview.trim()
const detail = [ const detail = [
this.showWorkspaces ? this.workspaceLabel(session) : "", this.showWorkspaces ? this.workspaceLabel(session) : "",
this.modelOverride(session), session.modelPreset,
age,
preview && preview !== sessionLabel(session) ? preview : "",
] ]
.filter(Boolean) .filter(Boolean)
.join(" · ") .join(" · ")
const marker = this.marker(session) const marker = session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""
const foreground = this.interrupted(session) return `${marker}${sessionLabel(session)}${detail ? ` ${detail}` : ""}`
? 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", emptyText: "No matching sessions",
onSelect, onSelect,
@@ -86,51 +67,16 @@ export class SessionMenu {
return this.picker.visible return this.picker.visible
} }
open( open(sessions: SessionSummary[], currentChatId: string, limit: number): void {
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) this.prepareWorkspaceLabels(sessions)
return sessions const rows = sessions
.map((session) => ({ .map((session) => ({ ...session, active: session.chatId === currentChatId }))
...session,
active: session.chatId === currentChatId,
unread: this.unreadChatIds.has(session.chatId),
}))
.sort((left, right) => { .sort((left, right) => {
return Number(right.active) - Number(left.active) return Number(right.active) - Number(left.active)
|| sessionPriority(right) - sessionPriority(left)
|| Number(right.pinned) - Number(left.pinned) || Number(right.pinned) - Number(left.pinned)
|| Number(left.archived) - Number(right.archived) || 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 { update(query: string, limit: number): void {
@@ -146,84 +92,13 @@ export class SessionMenu {
} }
hide(): void { hide(): void {
this.stopSpinner()
this.rows = []
this.picker.hide() this.picker.hide()
} }
setTheme(theme: PickerMenuTheme): void { setTheme(theme: PickerMenuTheme): void {
this.theme = theme
this.picker.setTheme(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 { private prepareWorkspaceLabels(sessions: SessionSummary[]): void {
this.workspaceLabels.clear() this.workspaceLabels.clear()
const scopes = sessions.flatMap((session) => { const scopes = sessions.flatMap((session) => {
@@ -247,19 +122,6 @@ export class SessionMenu {
private workspaceLabel(session: SessionSummary): string { private workspaceLabel(session: SessionSummary): string {
return this.workspaceLabels.get(normalizeWorkspacePath(session.workspaceScope?.project_path)) || "" 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 { function normalizeWorkspacePath(value: string | undefined): string {
@@ -274,16 +136,3 @@ function shortPath(path: string): string {
const parts = path.split("/").filter(Boolean) const parts = path.split("/").filter(Boolean)
return parts.slice(-2).join("/") || path 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,15 +1274,6 @@ function Shell({
}, [activeKey, activeTabKey, activeTabState]); }, [activeKey, activeTabKey, activeTabState]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]); const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); 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; const activeChatId = activePaneSession?.chatId ?? null;
useEffect(() => { useEffect(() => {
activeChatIdRef.current = activeChatId; activeChatIdRef.current = activeChatId;
@@ -2561,7 +2552,6 @@ function Shell({
collapsedGroups: sidebarState.collapsed_groups, collapsedGroups: sidebarState.collapsed_groups,
runningChatIds: runningChatIdList, runningChatIds: runningChatIdList,
updatedChatIds: updatedChatIdList, updatedChatIds: updatedChatIdList,
recoveryChatIds: recoveryChatIdList,
viewState: { ...sidebarState.view, sort: automaticSidebarSort }, viewState: { ...sidebarState.view, sort: automaticSidebarSort },
showArchived: sidebarState.view.show_archived, showArchived: sidebarState.view.show_archived,
archivedCount: sidebarArchivedTabKeys.length, archivedCount: sidebarArchivedTabKeys.length,
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
type ChannelFieldMessages = { export type ChannelFieldMessages = {
label: string; label: string;
placeholder?: string; placeholder?: string;
help?: string; help?: string;
+1 -29
View File
@@ -11,7 +11,6 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactElement } from
import { import {
Archive, Archive,
ArchiveRestore, ArchiveRestore,
AlertTriangle,
ChevronDown, ChevronDown,
Folder, Folder,
FolderTree, FolderTree,
@@ -261,7 +260,6 @@ interface ChatListProps {
collapsedGroups?: Record<string, boolean>; collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[]; runningChatIds?: string[];
updatedChatIds?: string[]; updatedChatIds?: string[];
recoveryChatIds?: string[];
density?: SidebarDensity; density?: SidebarDensity;
showPreviews?: boolean; showPreviews?: boolean;
showTimestamps?: boolean; showTimestamps?: boolean;
@@ -304,7 +302,6 @@ export const ChatList = memo(function ChatList({
collapsedGroups = {}, collapsedGroups = {},
runningChatIds = [], runningChatIds = [],
updatedChatIds = [], updatedChatIds = [],
recoveryChatIds = [],
density = "comfortable", density = "comfortable",
showPreviews = false, showPreviews = false,
showTimestamps = false, showTimestamps = false,
@@ -561,7 +558,6 @@ export const ChatList = memo(function ChatList({
const running = new Set(runningChatIds); const running = new Set(runningChatIds);
const updated = new Set(updatedChatIds); const updated = new Set(updatedChatIds);
const recovery = new Set(recoveryChatIds);
const compact = density === "compact"; const compact = density === "compact";
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project"); const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => ( const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
@@ -885,7 +881,6 @@ export const ChatList = memo(function ChatList({
compact={compact} compact={compact}
running={running} running={running}
updated={updated} updated={updated}
recovery={recovery}
onSelectPane={onSelectPane} onSelectPane={onSelectPane}
onRequestDelete={onRequestDelete} onRequestDelete={onRequestDelete}
onRequestRename={onRequestRename} onRequestRename={onRequestRename}
@@ -920,8 +915,6 @@ export const ChatList = memo(function ChatList({
: ""; : "";
const activityState = running.has(s.chatId) const activityState = running.has(s.chatId)
? "running" ? "running"
: recovery.has(s.chatId)
? "recovery"
: updated.has(s.chatId) && !topicActive : updated.has(s.chatId) && !topicActive
? "updated" ? "updated"
: null; : null;
@@ -1337,7 +1330,6 @@ function ActivePaneRows({
compact, compact,
running, running,
updated, updated,
recovery,
onSelectPane, onSelectPane,
onRequestDelete, onRequestDelete,
onRequestRename, onRequestRename,
@@ -1362,7 +1354,6 @@ function ActivePaneRows({
compact: boolean; compact: boolean;
running: ReadonlySet<string>; running: ReadonlySet<string>;
updated: ReadonlySet<string>; updated: ReadonlySet<string>;
recovery: ReadonlySet<string>;
onSelectPane?: (tabKey: string, paneKey: string) => void; onSelectPane?: (tabKey: string, paneKey: string) => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
onRequestRename: (key: string, label: string) => void; onRequestRename: (key: string, label: string) => void;
@@ -1399,8 +1390,6 @@ function ActivePaneRows({
const active = tabActive && pane.key === group.activePaneKey; const active = tabActive && pane.key === group.activePaneKey;
const activityState = running.has(pane.chatId) const activityState = running.has(pane.chatId)
? "running" ? "running"
: recovery.has(pane.chatId)
? "recovery"
: updated.has(pane.chatId) && !active : updated.has(pane.chatId) && !active
? "updated" ? "updated"
: null; : null;
@@ -1863,27 +1852,10 @@ function ChatsFoldFooter({
function SessionActivityIndicator({ function SessionActivityIndicator({
state, state,
}: { }: {
state: "running" | "updated" | "recovery" | null; state: "running" | "updated" | null;
}) { }) {
const { t } = useTranslation(); 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") { if (state === "running") {
const label = t("chat.activity.running"); const label = t("chat.activity.running");
return ( return (
+3 -3
View File
@@ -137,7 +137,7 @@ export function CapabilityMentionToken({
return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />; return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />;
} }
function SessionMentionToken({ export function SessionMentionToken({
mention, mention,
label, label,
variant, variant,
@@ -172,7 +172,7 @@ function SessionMentionToken({
); );
} }
function CliAppMentionToken({ export function CliAppMentionToken({
app, app,
label, label,
variant, variant,
@@ -229,7 +229,7 @@ function CliAppMentionToken({
); );
} }
function McpPresetMentionToken({ export function McpPresetMentionToken({
preset, preset,
label, label,
variant, variant,
+2 -2
View File
@@ -151,7 +151,7 @@ export function splitFilePath(path: string): { directory: string; name: string }
}; };
} }
function fileKindForPath(path: string): FileReferenceKind { export function fileKindForPath(path: string): FileReferenceKind {
const normalized = path.toLowerCase(); const normalized = path.toLowerCase();
const name = normalized.split(/[\\/]/).pop() ?? normalized; const name = normalized.split(/[\\/]/).pop() ?? normalized;
const ext = name.includes(".") ? name.split(".").pop() ?? "" : ""; const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
@@ -193,7 +193,7 @@ function fileKindForPath(path: string): FileReferenceKind {
} }
} }
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) { export function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
if (kind === "python") { if (kind === "python") {
return ( return (
<svg <svg
+79 -23
View File
@@ -65,6 +65,8 @@ interface MessageBubbleProps {
temporary?: boolean; temporary?: boolean;
/** When false, hide this message's copy button. Default true. */ /** When false, hide this message's copy button. Default true. */
showCopyAction?: boolean; showCopyAction?: boolean;
/** Show opt-in per-turn token usage for completed assistant messages. */
showTurnUsage?: boolean;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
@@ -189,31 +191,65 @@ function compactDuration(milliseconds: number): string {
return `${minutes}m ${Math.round(seconds % 60)}s`; return `${minutes}m ${Math.round(seconds % 60)}s`;
} }
function TurnUsageMeta({ function TurnUsageMeta({ usage }: { usage: TurnUsage }) {
usage,
latencyMs,
}: {
usage: TurnUsage;
latencyMs?: number;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const context = usage.context_tokens;
const prompt = usage.prompt_tokens; const prompt = usage.prompt_tokens;
const completion = usage.completion_tokens; const completion = usage.completion_tokens;
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : ""; const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
const formatTokens = (tokens: number) => `${approximate}${formatCompactTokenCount(tokens)}`;
const parts: string[] = []; const parts: string[] = [];
if (typeof prompt === "number") parts.push(`${approximate}${formatCompactTokenCount(prompt)} in`); const details: string[] = [];
if (typeof completion === "number") parts.push(`${approximate}${formatCompactTokenCount(completion)} out`);
if (typeof context === "number" && context >= 0) {
parts.push(t("message.usage.context", {
tokens: formatTokens(context),
defaultValue: "{{tokens}} context",
}));
if (typeof prompt === "number") {
details.push(t("message.usage.turnInput", {
tokens: formatTokens(prompt),
defaultValue: "{{tokens}} turn input",
}));
}
} else if (typeof prompt === "number") {
parts.push(t("message.usage.turnInput", {
tokens: formatTokens(prompt),
defaultValue: "{{tokens}} turn input",
}));
}
if (typeof usage.request_count === "number" && usage.request_count > 0) {
const requestKey = usage.request_count === 1
? "message.usage.request"
: "message.usage.requests";
details.push(t(requestKey, {
count: usage.request_count,
defaultValue: usage.request_count === 1 ? "{{count}} request" : "{{count}} requests",
}));
}
if (typeof completion === "number") {
const output = t("message.usage.output", {
tokens: formatTokens(completion),
defaultValue: "{{tokens}} out",
});
if (parts.length > 0) details.push(output);
else parts.push(output);
}
if ( if (
typeof usage.cached_tokens === "number" typeof usage.cached_tokens === "number"
&& typeof prompt === "number" && typeof prompt === "number"
&& prompt > 0 && prompt > 0
) { ) {
parts.push(`${Math.round(Math.min(1, usage.cached_tokens / prompt) * 100)}% cached`); details.push(t("message.usage.cached", {
percent: Math.round(Math.min(1, usage.cached_tokens / prompt) * 100),
defaultValue: "{{percent}}% cached",
}));
} }
if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs));
if (parts.length === 0) return null;
const details: string[] = []; if (parts.length === 0) return null;
if (approximate) { if (approximate) {
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" })); details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
} }
@@ -246,6 +282,17 @@ function TurnUsageMeta({
); );
} }
function TurnLatencyMeta({ latencyMs }: { latencyMs: number }) {
return (
<span
data-turn-latency
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
>
{compactDuration(latencyMs)}
</span>
);
}
function deliveryErrorCopy( function deliveryErrorCopy(
kind: MessageDeliveryErrorKind | undefined, kind: MessageDeliveryErrorKind | undefined,
t: (key: string) => string, t: (key: string) => string,
@@ -394,6 +441,7 @@ export function MessageBubble({
isTurnStreaming = false, isTurnStreaming = false,
temporary = false, temporary = false,
showCopyAction = true, showCopyAction = true,
showTurnUsage = false,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
slashCommands = [], slashCommands = [],
@@ -549,9 +597,21 @@ export function MessageBubble({
&& (!empty || hasReasoning || media.length > 0); && (!empty || hasReasoning || media.length > 0);
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : ""; const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0; const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
const showUsage = message.role === "assistant" && !!message.usage && !message.isStreaming; const showUsage = (
showTurnUsage
&& message.role === "assistant"
&& !!message.usage
&& !message.isStreaming
);
const showLatency = (
message.role === "assistant"
&& !!message.usage
&& !message.isStreaming
&& typeof message.latencyMs === "number"
&& message.latencyMs >= 0
);
const showAssistantFooterRow = const showAssistantFooterRow =
showCopyButton || showForkButton || showAssistantTimestamp || showUsage; showCopyButton || showForkButton || showAssistantTimestamp || showUsage || showLatency;
const showAssistantFooterSlot = const showAssistantFooterSlot =
message.role === "assistant" message.role === "assistant"
&& (!empty || hasReasoning || media.length > 0); && (!empty || hasReasoning || media.length > 0);
@@ -617,12 +677,8 @@ export function MessageBubble({
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent> <TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
{showUsage ? ( {showUsage ? <TurnUsageMeta usage={message.usage!} /> : null}
<TurnUsageMeta {showLatency ? <TurnLatencyMeta latencyMs={message.latencyMs!} /> : null}
usage={message.usage!}
latencyMs={message.latencyMs}
/>
) : null}
{showAssistantTimestamp ? ( {showAssistantTimestamp ? (
<MessageTimestamp <MessageTimestamp
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})} {...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
@@ -967,7 +1023,7 @@ interface ReasoningBubbleProps {
hasBodyBelow: boolean; hasBodyBelow: boolean;
} }
function ReasoningBubble({ export function ReasoningBubble({
text, text,
streaming, streaming,
hasBodyBelow, hasBodyBelow,
@@ -993,7 +1049,7 @@ interface TraceGroupProps {
* collapsed because tool traces are supporting evidence, not the answer. * collapsed because tool traces are supporting evidence, not the answer.
* A single click expands the exact calls when the user wants details. * A single click expands the exact calls when the user wants details.
*/ */
function TraceGroup({ message }: TraceGroupProps) { export function TraceGroup({ message }: TraceGroupProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const lines = message.traces ?? [message.content]; const lines = message.traces ?? [message.content];
const count = lines.length; const count = lines.length;
-2
View File
@@ -82,7 +82,6 @@ interface SidebarProps {
collapsedGroups?: Record<string, boolean>; collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[]; runningChatIds?: string[];
updatedChatIds?: string[]; updatedChatIds?: string[];
recoveryChatIds?: string[];
viewState?: SidebarViewState; viewState?: SidebarViewState;
showArchived?: boolean; showArchived?: boolean;
archivedCount?: number; archivedCount?: number;
@@ -271,7 +270,6 @@ export function Sidebar(props: SidebarProps) {
collapsedGroups={props.collapsedGroups} collapsedGroups={props.collapsedGroups}
runningChatIds={props.runningChatIds} runningChatIds={props.runningChatIds}
updatedChatIds={props.updatedChatIds} updatedChatIds={props.updatedChatIds}
recoveryChatIds={props.recoveryChatIds}
density={props.viewState?.density} density={props.viewState?.density}
showPreviews={props.viewState?.show_previews} showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps} showTimestamps={props.viewState?.show_timestamps}
@@ -174,7 +174,7 @@ export function ChannelLogo({
); );
} }
function channelDisplayName(feature: NanobotFeatureInfo): string { export function channelDisplayName(feature: NanobotFeatureInfo): string {
return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name; return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name;
} }
@@ -112,7 +112,7 @@ export function ChannelSetupLinks({
); );
} }
function ChannelOfficialLink({ export function ChannelOfficialLink({
feature, feature,
setup, setup,
}: { }: {
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
import type { ChannelConfigField } from "@/components/settings/channels/catalog"; import type { ChannelConfigField } from "@/components/settings/channels/catalog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string { export function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? ""; return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
} }
@@ -27,7 +27,7 @@ export type ChannelSetupPresentation = {
presets?: ChannelProviderPreset[]; presets?: ChannelProviderPreset[];
}; };
type ChannelCatalogSetupPresentation = { export type ChannelCatalogSetupPresentation = {
mode?: "webui" | "credentials" | "connect"; mode?: "webui" | "credentials" | "connect";
command?: string; command?: string;
docsUrl?: string; docsUrl?: string;
@@ -38,15 +38,15 @@ type ChannelCatalogSetupPresentation = {
presets?: ChannelProviderPresetDefinition[]; presets?: ChannelProviderPresetDefinition[];
}; };
type ChannelFieldPresentation = { export type ChannelFieldPresentation = {
key: string; key: string;
}; };
type ChannelSetupActionDefinition = Omit<ChannelSetupAction, "label">; export type ChannelSetupActionDefinition = Omit<ChannelSetupAction, "label">;
export type ChannelProviderPresetDefinition = Omit<ChannelProviderPreset, "label">; export type ChannelProviderPresetDefinition = Omit<ChannelProviderPreset, "label">;
type ChannelSetupAction = { export type ChannelSetupAction = {
id: string; id: string;
label: string; label: string;
url?: string; url?: string;
@@ -72,7 +72,7 @@ export type ChannelConfigField = {
options?: ChannelConfigOption[]; options?: ChannelConfigOption[];
}; };
type ChannelConfigOption = { export type ChannelConfigOption = {
value: string; value: string;
label: string; label: string;
}; };
+1 -1
View File
@@ -14,7 +14,7 @@ export type SettingsSectionKey =
| "runtime" | "runtime"
| "advanced"; | "advanced";
type PendingRestartSection = "runtime" | "browser" | "image"; export type PendingRestartSection = "runtime" | "browser" | "image";
export type PendingRestartSections = Record<PendingRestartSection, boolean>; export type PendingRestartSections = Record<PendingRestartSection, boolean>;
export type RestartAwarePayload = { export type RestartAwarePayload = {
@@ -407,6 +407,22 @@ export function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow
title={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
description={tx(
"settings.help.showTurnUsage",
"Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
)}
>
<ToggleButton
checked={localPrefs.showTurnUsage}
onChange={(showTurnUsage) => (
onChangeLocalPrefs((prev) => ({ ...prev, showTurnUsage }))
)}
ariaLabel={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
label={localPrefs.showTurnUsage ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow <SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")} title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx( description={tx(
@@ -421,37 +437,6 @@ export function AppearanceSettings({
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/> />
</SettingsRow> </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> </SettingsGroup>
</section> </section>
</div> </div>
@@ -51,7 +51,7 @@ import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessag
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24; const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
export { isAgentActivityMember }; export { isAgentActivityMember, isReasoningOnlyAssistant };
interface ActivityCounts { interface ActivityCounts {
reasoningSteps: number; reasoningSteps: number;
@@ -1,108 +0,0 @@
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>
);
}
+10 -7
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction"; import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline"; import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
@@ -67,6 +68,7 @@ export function ThreadMessages({
onQuoteSelection, onQuoteSelection,
}: ThreadMessagesProps) { }: ThreadMessagesProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const showTurnUsage = useShowTurnUsage();
const messageListRef = useRef<HTMLDivElement>(null); const messageListRef = useRef<HTMLDivElement>(null);
const units = useMemo( const units = useMemo(
() => buildDisplayUnits(messages, isStreaming, activeTurnId), () => buildDisplayUnits(messages, isStreaming, activeTurnId),
@@ -158,6 +160,7 @@ export function ThreadMessages({
showForkBoundary={index === forkBoundaryAfterUnitIndex} showForkBoundary={index === forkBoundaryAfterUnitIndex}
forkBoundaryLabel={t("thread.forkedFromHistory")} forkBoundaryLabel={t("thread.forkedFromHistory")}
temporary={temporary} temporary={temporary}
showTurnUsage={showTurnUsage}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
@@ -239,6 +242,7 @@ interface ThreadDisplayUnitProps {
showForkBoundary: boolean; showForkBoundary: boolean;
forkBoundaryLabel: string; forkBoundaryLabel: string;
temporary: boolean; temporary: boolean;
showTurnUsage: boolean;
cliApps: CliAppInfo[]; cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[]; mcpPresets: McpPresetInfo[];
slashCommands: SlashCommand[]; slashCommands: SlashCommand[];
@@ -257,6 +261,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
showForkBoundary, showForkBoundary,
forkBoundaryLabel, forkBoundaryLabel,
temporary, temporary,
showTurnUsage,
cliApps, cliApps,
mcpPresets, mcpPresets,
slashCommands, slashCommands,
@@ -295,6 +300,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
message={unit.message} message={unit.message}
isTurnStreaming={isTurnStreaming} isTurnStreaming={isTurnStreaming}
temporary={temporary} temporary={temporary}
showTurnUsage={showTurnUsage}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
@@ -323,6 +329,7 @@ function threadDisplayUnitPropsEqual(
&& previous.showForkBoundary === next.showForkBoundary && previous.showForkBoundary === next.showForkBoundary
&& previous.forkBoundaryLabel === next.forkBoundaryLabel && previous.forkBoundaryLabel === next.forkBoundaryLabel
&& previous.temporary === next.temporary && previous.temporary === next.temporary
&& previous.showTurnUsage === next.showTurnUsage
&& previous.cliApps === next.cliApps && previous.cliApps === next.cliApps
&& previous.mcpPresets === next.mcpPresets && previous.mcpPresets === next.mcpPresets
&& previous.slashCommands === next.slashCommands && previous.slashCommands === next.slashCommands
@@ -355,15 +362,11 @@ function activeTurnStartIndex(units: DisplayUnit[], activeTurnId: string | null)
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean { function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
if (previous.type !== next.type) return false; if (previous.type !== next.type) return false;
if (previous.type === "message" && next.type === "message") { if (previous.type === "message" && next.type === "message") {
return ( return shallowMessageEqual(previous.message, next.message);
previous.sourceMessageCount === next.sourceMessageCount
&& shallowMessageEqual(previous.message, next.message)
);
} }
if (previous.type !== "activity" || next.type !== "activity") return false; if (previous.type !== "activity" || next.type !== "activity") return false;
return ( return (
previous.sourceMessageCount === next.sourceMessageCount previous.turnLatencyMs === next.turnLatencyMs
&& previous.turnLatencyMs === next.turnLatencyMs
&& previous.startedAtMs === next.startedAtMs && previous.startedAtMs === next.startedAtMs
&& previous.messages.length === next.messages.length && previous.messages.length === next.messages.length
&& previous.messages.every((message, index) => && previous.messages.every((message, index) =>
@@ -387,7 +390,7 @@ function unitIndexAfterMessageCount(
let seen = 0; let seen = 0;
for (let i = 0; i < units.length; i += 1) { for (let i = 0; i < units.length; i += 1) {
const unit = units[i]; const unit = units[i];
seen += unit.sourceMessageCount; seen += unit.type === "activity" ? unit.messages.length : 1;
if (seen >= messageCount) return i; if (seen >= messageCount) return i;
} }
return null; return null;
+2 -20
View File
@@ -7,7 +7,6 @@ import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailab
import { FilePreviewPanel } from "@/components/FilePreviewPanel"; import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { SessionHandleLabel } from "@/components/SessionHandleLabel"; import { SessionHandleLabel } from "@/components/SessionHandleLabel";
import { PromptNavigator } from "@/components/thread/PromptNavigator"; import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { RecoveryNotice } from "@/components/thread/RecoveryNotice";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { import {
ThreadComposer, ThreadComposer,
@@ -764,9 +763,6 @@ export function ThreadShell({
isStreaming, isStreaming,
runStartedAt, runStartedAt,
goalState, goalState,
recoveryState,
continueRecovery,
dismissRecovery,
send, send,
transcribeAudio, transcribeAudio,
stop, stop,
@@ -839,15 +835,8 @@ export function ThreadShell({
[displayMessages], [displayMessages],
); );
const currentGoalState = messagesReady ? goalState : undefined; const currentGoalState = messagesReady ? goalState : undefined;
// Decision states freeze the interrupted turn and hand the next action to const currentRunStartedAt = messagesReady ? runStartedAt : null;
// the recovery notice. ``resuming`` remains active; ``recovered`` is only const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
// 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( const restoredViewportTurnId = useMemo(
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null, () => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
[currentRunStartedAt, displayMessages, turnActive], [currentRunStartedAt, displayMessages, turnActive],
@@ -1483,13 +1472,6 @@ export function ThreadShell({
const composer = ( const composer = (
<> <>
{recoveryState ? (
<RecoveryNotice
state={recoveryState}
onContinue={continueRecovery}
onDismiss={dismissRecovery}
/>
) : null}
{streamError && !hasInlineDeliveryError(messages, streamError) ? ( {streamError && !hasInlineDeliveryError(messages, streamError) ? (
<StreamErrorNotice <StreamErrorNotice
error={streamError} error={streamError}
@@ -155,7 +155,7 @@ function FileEditRow({
); );
} }
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean { export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
return edit.added > 0 || edit.deleted > 0; 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 GenericToolStatus = "running" | "done" | "error";
export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic"; export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic";
interface ToolField { export interface ToolField {
key: key:
| "query" | "query"
| "pattern" | "pattern"
@@ -7,7 +7,7 @@ import { displayWebHost, formatCompactWebUrl, parseSafeActivityHttpUrl } from ".
export type WebSearchStatus = "running" | "done" | "error"; export type WebSearchStatus = "running" | "done" | "error";
export type WebSearchTarget = "web" | "x"; export type WebSearchTarget = "web" | "x";
interface WebSearchSource { export interface WebSearchSource {
title: string; title: string;
href: string; href: string;
host: string; host: string;
@@ -26,13 +26,13 @@ export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
}); });
} }
function promptLabel(content: string, index: number): string { export function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim(); const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`; if (!text) return `Prompt ${index + 1}`;
return truncatePreview(text, 80); return truncatePreview(text, 80);
} }
function promptPreview(content: string, index: number): string { export function promptPreview(content: string, index: number): string {
const text = compactPreview(content); const text = compactPreview(content);
if (!text) return `Prompt ${index + 1}`; if (!text) return `Prompt ${index + 1}`;
return truncatePreview(text, 320); return truncatePreview(text, 320);
+1 -1
View File
@@ -33,7 +33,7 @@ const buttonVariants = cva(
}, },
); );
interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from "react";
import { formControlFocusClassName } from "@/components/ui/form-control"; import { formControlFocusClassName } from "@/components/ui/form-control";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type InputProps = React.InputHTMLAttributes<HTMLInputElement>; export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ 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 { formControlFocusClassName } from "@/components/ui/form-control";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>; export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
@@ -12,7 +12,7 @@ export type {
export const MAX_WORKBENCH_PANES = 4; export const MAX_WORKBENCH_PANES = 4;
const WORKBENCH_LAYOUTS = [ export const WORKBENCH_LAYOUTS = [
"columns", "columns",
"rows", "rows",
"grid", "grid",
+5 -5
View File
@@ -9,10 +9,10 @@ import type { WebUIIngressLimits } from "@/lib/types";
* - ``ready`` ``dataUrl`` available; safe to submit * - ``ready`` ``dataUrl`` available; safe to submit
* - ``error`` validation / decode failure; chip shows inline error * - ``error`` validation / decode failure; chip shows inline error
*/ */
type AttachmentStatus = "encoding" | "ready" | "error"; export type AttachmentStatus = "encoding" | "ready" | "error";
export type AttachmentKind = "image" | "file"; export type AttachmentKind = "image" | "file";
interface AttachedAttachment { export interface AttachedAttachment {
id: string; id: string;
kind: AttachmentKind; kind: AttachmentKind;
file: File; file: File;
@@ -32,7 +32,7 @@ interface AttachedAttachment {
export type AttachedImage = AttachedAttachment; export type AttachedImage = AttachedAttachment;
interface RestoredReadyAttachment { export interface RestoredReadyAttachment {
dataUrl: string; dataUrl: string;
name?: string; name?: string;
kind?: AttachmentKind; kind?: AttachmentKind;
@@ -55,8 +55,8 @@ export type AttachmentError =
| "io"; // file read failed at the browser layer | "io"; // file read failed at the browser layer
export const MAX_ATTACHMENTS_PER_MESSAGE = 4; export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024; export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024; export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */ /** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([ 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, * - Plain text pasted alongside attachments is *not* consumed by this helper,
* so the caller can still let the textarea receive it naturally. * so the caller can still let the textarea receive it naturally.
*/ */
function extractImageFilesFromPaste( export function extractImageFilesFromPaste(
event: ClipboardEvent | React.ClipboardEvent, event: ClipboardEvent | React.ClipboardEvent,
): File[] { ): File[] {
const clipboard = (event as ClipboardEvent).clipboardData const clipboard = (event as ClipboardEvent).clipboardData
@@ -27,7 +27,7 @@ function extractImageFilesFromPaste(
} }
/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */ /** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
function extractImageFilesFromDrop( export function extractImageFilesFromDrop(
event: DragEvent | React.DragEvent, event: DragEvent | React.DragEvent,
): File[] { ): File[] {
const dt = (event as DragEvent).dataTransfer const dt = (event as DragEvent).dataTransfer
-102
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media"; import { toMediaAttachment } from "@/lib/media";
@@ -29,7 +28,6 @@ import {
} from "@/lib/thread-event-projection"; } from "@/lib/thread-event-projection";
import type { UIMessageTurnFields } from "@/lib/thread-event-projection"; import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
import { formatQuotedUserMessage } from "@/lib/user-message-quote"; import { formatQuotedUserMessage } from "@/lib/user-message-quote";
import { readLocalPreferences } from "@/lib/local-preferences";
import type { import type {
InboundEvent, InboundEvent,
OutboundCliAppMention, OutboundCliAppMention,
@@ -38,7 +36,6 @@ import type {
SessionMention, SessionMention,
GoalStateWsPayload, GoalStateWsPayload,
MessageDeliveryStatus, MessageDeliveryStatus,
RecoveryState,
UIMediaAttachment, UIMediaAttachment,
UIMessage, UIMessage,
WorkspaceScopePayload, WorkspaceScopePayload,
@@ -247,9 +244,6 @@ export function useNanobotStream(
runStartedAt: number | null; runStartedAt: number | null;
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */ /** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
goalState: GoalStateWsPayload | undefined; goalState: GoalStateWsPayload | undefined;
recoveryState: RecoveryState | null;
continueRecovery: () => Promise<void>;
dismissRecovery: () => Promise<void>;
send: ( send: (
content: string, content: string,
images?: SendAttachment[], images?: SendAttachment[],
@@ -268,7 +262,6 @@ export function useNanobotStream(
dismissStreamError: () => void; dismissStreamError: () => void;
} { } {
const { client } = useClient(); const { client } = useClient();
const { t } = useTranslation();
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null; const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
const [messages, setMessages] = useState<UIMessage[]>(initialMessages); const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId); const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
@@ -280,7 +273,6 @@ export function useNanobotStream(
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */ /** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt); const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined); const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
const [recoveryState, setRecoveryState] = useState<RecoveryState | null>(null);
const [streamError, setStreamError] = useState<StreamError | null>(null); const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null); const buffer = useRef<StreamBuffer | null>(null);
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null); const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
@@ -296,16 +288,6 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []); 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(() => { const clearPendingStreamWork = useCallback(() => {
if (streamFrameRef.current !== null) { if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current); window.cancelAnimationFrame(streamFrameRef.current);
@@ -657,7 +639,6 @@ export function useNanobotStream(
setStreamError(null); setStreamError(null);
setRunStartedAt(restoredRunStartedAt); setRunStartedAt(restoredRunStartedAt);
setGoalState(chatId ? client.getGoalState(chatId) : undefined); setGoalState(chatId ? client.getGoalState(chatId) : undefined);
setRecoveryState(null);
buffer.current = null; buffer.current = null;
activeAssistantRef.current = null; activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear(); closedAssistantStreamIdsRef.current.clear();
@@ -865,71 +846,10 @@ export function useNanobotStream(
return finalized; return finalized;
}); });
suppressStreamUntilTurnEndRef.current = false; suppressStreamUntilTurnEndRef.current = false;
notifyInBackground(t("recovery.completed", { defaultValue: "Task completed" }));
onTurnEnd?.(); onTurnEnd?.();
return; 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 (ev.event === "message") {
if ( if (
suppressStreamUntilTurnEndRef.current && suppressStreamUntilTurnEndRef.current &&
@@ -1142,10 +1062,8 @@ export function useNanobotStream(
ensureActivitySegmentId, ensureActivitySegmentId,
flushPendingStreamEvents, flushPendingStreamEvents,
isSideChannelEvent, isSideChannelEvent,
notifyInBackground,
onTurnEnd, onTurnEnd,
schedulePendingStreamFlush, schedulePendingStreamFlush,
t,
]); ]);
const send = useCallback( const send = useCallback(
@@ -1255,32 +1173,12 @@ export function useNanobotStream(
[client], [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 { return {
messages, messages,
messagesReady: messageOwnerChatId === chatId, messagesReady: messageOwnerChatId === chatId,
isStreaming, isStreaming,
runStartedAt, runStartedAt,
goalState, goalState,
recoveryState,
continueRecovery,
dismissRecovery,
send, send,
transcribeAudio, transcribeAudio,
stop, stop,
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import {
LOCAL_PREFS_CHANGED_EVENT,
readLocalPreferences,
type LocalPreferences,
} from "@/lib/local-preferences";
export function useShowTurnUsage(): boolean {
const [showTurnUsage, setShowTurnUsage] = useState(
() => readLocalPreferences().showTurnUsage,
);
useEffect(() => {
const refresh = () => setShowTurnUsage(readLocalPreferences().showTurnUsage);
const refreshFromLocalPreferenceEvent = (event: Event) => {
const detail = (event as CustomEvent<Partial<LocalPreferences> | undefined>).detail;
setShowTurnUsage(
detail ? detail.showTurnUsage === true : readLocalPreferences().showTurnUsage,
);
};
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
window.addEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
return () => {
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
window.removeEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
};
}, []);
return showTurnUsage;
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { normalizeWorkbenchState } from "@/components/workbench/workbench-model"
import { fetchSidebarState } from "@/lib/api"; import { fetchSidebarState } from "@/lib/api";
import type { ChatSummary, SidebarStatePayload } from "@/lib/types"; import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = { export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
schema_version: 1, schema_version: 1,
pinned_keys: [], pinned_keys: [],
archived_keys: [], archived_keys: [],
@@ -74,7 +74,7 @@ function boolMap(value: unknown): Record<string, boolean> {
return out; return out;
} }
function normalizeSidebarState(raw: unknown): SidebarStatePayload { export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } }; return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } };
} }
+1 -1
View File
@@ -51,7 +51,7 @@ export function normalizeLocale(
return baseMatch?.code ?? defaultLocale; return baseMatch?.code ?? defaultLocale;
} }
function readStoredLocale(): SupportedLocale | null { export function readStoredLocale(): SupportedLocale | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
try { try {
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY); const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
+3 -1
View File
@@ -10,6 +10,7 @@ import {
applyDocumentLocale, applyDocumentLocale,
defaultLocale, defaultLocale,
fallbackLocale, fallbackLocale,
LOCALE_STORAGE_KEY,
normalizeLocale, normalizeLocale,
persistLocale, persistLocale,
resolveInitialLocale, resolveInitialLocale,
@@ -46,7 +47,7 @@ export function currentLocale(): SupportedLocale {
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale); return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
} }
async function loadLocaleResources( export async function loadLocaleResources(
locale: SupportedLocale, locale: SupportedLocale,
): Promise<LocaleResource> { ): Promise<LocaleResource> {
const existing = resourcePromises.get(locale); const existing = resourcePromises.get(locale);
@@ -130,4 +131,5 @@ function syncLocaleSideEffects(language: string) {
persistLocale(locale); persistLocale(locale);
} }
export { LOCALE_STORAGE_KEY };
export default i18n; export default i18n;
+9 -18
View File
@@ -196,9 +196,9 @@
"density": "Density", "density": "Density",
"activityMode": "Activity detail", "activityMode": "Activity detail",
"fileEditDisplay": "File edit display", "fileEditDisplay": "File edit display",
"showTurnUsage": "Show per-turn token usage",
"codeWrap": "Code wrapping", "codeWrap": "Code wrapping",
"brandLogos": "Brand logos", "brandLogos": "Brand logos",
"browserNotifications": "Task notifications",
"maxResults": "Max results", "maxResults": "Max results",
"timeout": "Timeout", "timeout": "Timeout",
"jinaReader": "Jina reader", "jinaReader": "Jina reader",
@@ -242,9 +242,9 @@
"density": "Stored only in this browser.", "density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "Choose how much agent activity chrome to show by default.",
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.", "fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
"showTurnUsage": "Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.", "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.", "maxResults": "Results returned by each web_search call.",
"timeout": "Seconds before a search provider request times out.", "timeout": "Seconds before a search provider request times out.",
"jinaReader": "Use Jina Reader for web_fetch when available.", "jinaReader": "Use Jina Reader for web_fetch when available.",
@@ -1015,8 +1015,7 @@
"activity": { "activity": {
"running": "Agent running", "running": "Agent running",
"complete": "Agent finished", "complete": "Agent finished",
"updated": "New activity", "updated": "New activity"
"recovery": "This conversation needs your attention"
}, },
"pin": "Pin", "pin": "Pin",
"unpin": "Unpin", "unpin": "Unpin",
@@ -1434,23 +1433,15 @@
"fileEditOpenFile": "Open file", "fileEditOpenFile": "Open file",
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.", "fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
"usage": { "usage": {
"context": "Context now: {{tokens}}{{capacity}}", "context": "{{tokens}} context",
"requests": "{{count}} calls this turn", "turnInput": "{{tokens}} turn input",
"request": "{{count}} request",
"requests": "{{count}} requests",
"output": "{{tokens}} out",
"cached": "{{percent}}% cached",
"estimated": "Includes estimated usage" "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": { "lightbox": {
"title": "Image preview", "title": "Image preview",
"open": "View image", "open": "View image",
+9 -18
View File
@@ -142,6 +142,7 @@
"density": "Densidad", "density": "Densidad",
"activityMode": "Detalle de actividad", "activityMode": "Detalle de actividad",
"fileEditDisplay": "Vista de edición de archivos", "fileEditDisplay": "Vista de edición de archivos",
"showTurnUsage": "Mostrar uso de tokens por turno",
"codeWrap": "Ajuste de código", "codeWrap": "Ajuste de código",
"maxResults": "Resultados máximos", "maxResults": "Resultados máximos",
"timeout": "Tiempo de espera", "timeout": "Tiempo de espera",
@@ -161,7 +162,6 @@
"webuiDefaultAccess": "Acceso predeterminado", "webuiDefaultAccess": "Acceso predeterminado",
"currentModel": "Configuración actual", "currentModel": "Configuración actual",
"brandLogos": "Logos de marca", "brandLogos": "Logos de marca",
"browserNotifications": "Notificaciones de tareas",
"cliAppsCatalog": "Catálogo", "cliAppsCatalog": "Catálogo",
"cliAppsFilter": "Filtro", "cliAppsFilter": "Filtro",
"engine": "Motor", "engine": "Motor",
@@ -186,6 +186,7 @@
"density": "Solo se guarda en este navegador.", "density": "Solo se guarda en este navegador.",
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.", "activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.", "fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.",
"showTurnUsage": "Muestra el contexto, la entrada acumulada, la salida, la tasa de caché y las llamadas al modelo debajo de los mensajes del asistente. Solo se guarda en este navegador.",
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.", "codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
"maxResults": "Resultados devueltos por cada llamada web_search.", "maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -205,7 +206,6 @@
"selectedModelProvider": "Definido por el modelo seleccionado.", "selectedModelProvider": "Definido por el modelo seleccionado.",
"selectedModelValue": "Definido por el modelo seleccionado.", "selectedModelValue": "Definido por el modelo seleccionado.",
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.", "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.", "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.", "cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
"logs": "Abre la carpeta de registros del motor nativo.", "logs": "Abre la carpeta de registros del motor nativo.",
@@ -1002,8 +1002,7 @@
"activity": { "activity": {
"running": "Agente en ejecución", "running": "Agente en ejecución",
"complete": "Agente terminado", "complete": "Agente terminado",
"updated": "Nueva actividad", "updated": "Nueva actividad"
"recovery": "Esta conversación requiere tu atención"
}, },
"pin": "Fijar", "pin": "Fijar",
"unpin": "Desfijar", "unpin": "Desfijar",
@@ -1404,8 +1403,12 @@
"fileEditOpenFile": "Abrir archivo", "fileEditOpenFile": "Abrir archivo",
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.", "fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
"usage": { "usage": {
"context": "Contexto más reciente: {{tokens}}{{capacity}} tokens", "context": "{{tokens}} de contexto",
"requests": "{{count}} solicitudes al modelo", "turnInput": "{{tokens}} de entrada en el turno",
"request": "{{count}} solicitud",
"requests": "{{count}} solicitudes",
"output": "{{tokens}} de salida",
"cached": "{{percent}}% en caché",
"estimated": "Incluye uso estimado" "estimated": "Incluye uso estimado"
}, },
"activityThinkingFor": "Pensando durante {{duration}}", "activityThinkingFor": "Pensando durante {{duration}}",
@@ -1426,18 +1429,6 @@
"automationSourceFallback": "Automatización", "automationSourceFallback": "Automatización",
"automationTriggered": "Activada automáticamente" "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": { "lightbox": {
"title": "Vista previa de imagen", "title": "Vista previa de imagen",
"open": "Ver imagen", "open": "Ver imagen",
+9 -18
View File
@@ -142,6 +142,7 @@
"density": "Densité", "density": "Densité",
"activityMode": "Détail dactivité", "activityMode": "Détail dactivité",
"fileEditDisplay": "Affichage des modifications de fichiers", "fileEditDisplay": "Affichage des modifications de fichiers",
"showTurnUsage": "Afficher lutilisation des tokens par tour",
"codeWrap": "Retour à la ligne du code", "codeWrap": "Retour à la ligne du code",
"maxResults": "Résultats max.", "maxResults": "Résultats max.",
"timeout": "Délai dattente", "timeout": "Délai dattente",
@@ -161,7 +162,6 @@
"webuiDefaultAccess": "Accès par défaut", "webuiDefaultAccess": "Accès par défaut",
"currentModel": "Configuration actuelle", "currentModel": "Configuration actuelle",
"brandLogos": "Logos de marque", "brandLogos": "Logos de marque",
"browserNotifications": "Notifications de tâches",
"cliAppsCatalog": "Catalogue", "cliAppsCatalog": "Catalogue",
"cliAppsFilter": "Filtre", "cliAppsFilter": "Filtre",
"engine": "Moteur", "engine": "Moteur",
@@ -186,6 +186,7 @@
"density": "Enregistré seulement dans ce navigateur.", "density": "Enregistré seulement dans ce navigateur.",
"activityMode": "Choisissez le niveau de détail de lactivité de lagent affiché par défaut.", "activityMode": "Choisissez le niveau de détail de lactivité de lagent affiché par défaut.",
"fileEditDisplay": "Choisissez si lactivité de modification affiche le nombre de lignes ou les différences.", "fileEditDisplay": "Choisissez si lactivité de modification affiche le nombre de lignes ou les différences.",
"showTurnUsage": "Affiche le contexte, lentrée cumulée, la sortie, le taux de cache et le nombre dappels au modèle sous les messages de lassistant. Enregistré uniquement dans ce navigateur.",
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.", "codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
"maxResults": "Résultats renvoyés par chaque appel web_search.", "maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.", "timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
@@ -205,7 +206,6 @@
"selectedModelProvider": "Défini par le modèle sélectionné.", "selectedModelProvider": "Défini par le modèle sélectionné.",
"selectedModelValue": "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.", "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.", "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é.", "cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
"logs": "Ouvre le dossier des journaux du moteur natif.", "logs": "Ouvre le dossier des journaux du moteur natif.",
@@ -1001,8 +1001,7 @@
"activity": { "activity": {
"running": "Agent en cours", "running": "Agent en cours",
"complete": "Agent terminé", "complete": "Agent terminé",
"updated": "Nouvelle activité", "updated": "Nouvelle activité"
"recovery": "Cette conversation nécessite votre attention"
}, },
"pin": "Épingler", "pin": "Épingler",
"unpin": "Désépingler", "unpin": "Désépingler",
@@ -1403,8 +1402,12 @@
"fileEditOpenFile": "Ouvrir le fichier", "fileEditOpenFile": "Ouvrir le fichier",
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.", "fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
"usage": { "usage": {
"context": "Dernier contexte : {{tokens}}{{capacity}} tokens", "context": "{{tokens}} de contexte",
"requests": "{{count}} requêtes au modèle", "turnInput": "{{tokens}} en entrée sur ce tour",
"request": "{{count}} requête",
"requests": "{{count}} requêtes",
"output": "{{tokens}} en sortie",
"cached": "{{percent}} % en cache",
"estimated": "Inclut une estimation de lutilisation" "estimated": "Inclut une estimation de lutilisation"
}, },
"activityThinkingFor": "Réflexion pendant {{duration}}", "activityThinkingFor": "Réflexion pendant {{duration}}",
@@ -1425,18 +1428,6 @@
"automationSourceFallback": "Automatisation", "automationSourceFallback": "Automatisation",
"automationTriggered": "Déclenché automatiquement" "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": { "lightbox": {
"title": "Aperçu de limage", "title": "Aperçu de limage",
"open": "Voir limage", "open": "Voir limage",
+9 -18
View File
@@ -142,6 +142,7 @@
"density": "Kerapatan", "density": "Kerapatan",
"activityMode": "Detail aktivitas", "activityMode": "Detail aktivitas",
"fileEditDisplay": "Tampilan perubahan file", "fileEditDisplay": "Tampilan perubahan file",
"showTurnUsage": "Tampilkan penggunaan token per giliran",
"codeWrap": "Bungkus kode", "codeWrap": "Bungkus kode",
"maxResults": "Hasil maksimum", "maxResults": "Hasil maksimum",
"timeout": "Batas waktu", "timeout": "Batas waktu",
@@ -161,7 +162,6 @@
"webuiDefaultAccess": "Akses bawaan", "webuiDefaultAccess": "Akses bawaan",
"currentModel": "Konfigurasi saat ini", "currentModel": "Konfigurasi saat ini",
"brandLogos": "Logo merek", "brandLogos": "Logo merek",
"browserNotifications": "Notifikasi tugas",
"cliAppsCatalog": "Katalog", "cliAppsCatalog": "Katalog",
"cliAppsFilter": "Saring", "cliAppsFilter": "Saring",
"engine": "Mesin", "engine": "Mesin",
@@ -186,6 +186,7 @@
"density": "Hanya disimpan di browser ini.", "density": "Hanya disimpan di browser ini.",
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.", "activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
"fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.", "fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.",
"showTurnUsage": "Tampilkan konteks, input kumulatif, output, rasio cache, dan jumlah panggilan model di bawah pesan asisten. Hanya disimpan di browser ini.",
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.", "codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.", "maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.", "timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
@@ -205,7 +206,6 @@
"selectedModelProvider": "Ditentukan oleh model yang dipilih.", "selectedModelProvider": "Ditentukan oleh model yang dipilih.",
"selectedModelValue": "Ditentukan oleh model yang dipilih.", "selectedModelValue": "Ditentukan oleh model yang dipilih.",
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.", "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.", "cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.", "cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
"logs": "Buka folder log mesin asli.", "logs": "Buka folder log mesin asli.",
@@ -1001,8 +1001,7 @@
"activity": { "activity": {
"running": "Agen sedang berjalan", "running": "Agen sedang berjalan",
"complete": "Agen selesai", "complete": "Agen selesai",
"updated": "Aktivitas baru", "updated": "Aktivitas baru"
"recovery": "Percakapan ini memerlukan perhatian Anda"
}, },
"pin": "Sematkan", "pin": "Sematkan",
"unpin": "Lepas sematan", "unpin": "Lepas sematan",
@@ -1403,8 +1402,12 @@
"fileEditOpenFile": "Buka file", "fileEditOpenFile": "Buka file",
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.", "fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
"usage": { "usage": {
"context": "Konteks terbaru: {{tokens}}{{capacity}} token", "context": "Konteks {{tokens}}",
"requests": "{{count}} permintaan model", "turnInput": "Input giliran {{tokens}}",
"request": "{{count}} permintaan",
"requests": "{{count}} permintaan",
"output": "Output {{tokens}}",
"cached": "{{percent}}% di-cache",
"estimated": "Termasuk penggunaan perkiraan" "estimated": "Termasuk penggunaan perkiraan"
}, },
"activityThinkingFor": "Berpikir selama {{duration}}", "activityThinkingFor": "Berpikir selama {{duration}}",
@@ -1425,18 +1428,6 @@
"automationSourceFallback": "Otomatisasi", "automationSourceFallback": "Otomatisasi",
"automationTriggered": "Dipicu otomatis" "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": { "lightbox": {
"title": "Pratinjau gambar", "title": "Pratinjau gambar",
"open": "Lihat gambar", "open": "Lihat gambar",
+9 -18
View File
@@ -142,6 +142,7 @@
"density": "表示密度", "density": "表示密度",
"activityMode": "アクティビティ詳細", "activityMode": "アクティビティ詳細",
"fileEditDisplay": "ファイル編集表示", "fileEditDisplay": "ファイル編集表示",
"showTurnUsage": "ターンごとのトークン使用量を表示",
"codeWrap": "コードの折り返し", "codeWrap": "コードの折り返し",
"maxResults": "最大結果数", "maxResults": "最大結果数",
"timeout": "タイムアウト", "timeout": "タイムアウト",
@@ -161,7 +162,6 @@
"webuiDefaultAccess": "既定の権限", "webuiDefaultAccess": "既定の権限",
"currentModel": "現在の設定", "currentModel": "現在の設定",
"brandLogos": "ブランドロゴ", "brandLogos": "ブランドロゴ",
"browserNotifications": "タスク通知",
"cliAppsCatalog": "カタログ", "cliAppsCatalog": "カタログ",
"cliAppsFilter": "フィルター", "cliAppsFilter": "フィルター",
"engine": "エンジン", "engine": "エンジン",
@@ -186,6 +186,7 @@
"density": "このブラウザーにのみ保存されます。", "density": "このブラウザーにのみ保存されます。",
"activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。", "activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。",
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。", "fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
"showTurnUsage": "アシスタントのメッセージの下に、コンテキスト、累積入力、出力、キャッシュ率、モデル呼び出し回数を表示します。このブラウザーにのみ保存されます。",
"codeWrap": "小さな画面でも長いコード行を読みやすくします。", "codeWrap": "小さな画面でも長いコード行を読みやすくします。",
"maxResults": "各 web_search 呼び出しで返す結果数です。", "maxResults": "各 web_search 呼び出しで返す結果数です。",
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。", "timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
@@ -205,7 +206,6 @@
"selectedModelProvider": "選択したモデルによって設定されます。", "selectedModelProvider": "選択したモデルによって設定されます。",
"selectedModelValue": "選択したモデルによって設定されます。", "selectedModelValue": "選択したモデルによって設定されます。",
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。", "brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
"browserNotifications": "このページがバックグラウンドにある場合のみ通知します。既定ではオフです。",
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。", "cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。", "cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
"logs": "ネイティブエンジンのログフォルダーを開きます。", "logs": "ネイティブエンジンのログフォルダーを開きます。",
@@ -1001,8 +1001,7 @@
"activity": { "activity": {
"running": "エージェント実行中", "running": "エージェント実行中",
"complete": "エージェント完了", "complete": "エージェント完了",
"updated": "新しいアクティビティ", "updated": "新しいアクティビティ"
"recovery": "この会話には対応が必要です"
}, },
"pin": "ピン留め", "pin": "ピン留め",
"unpin": "ピン留めを解除", "unpin": "ピン留めを解除",
@@ -1403,8 +1402,12 @@
"fileEditOpenFile": "ファイルを開く", "fileEditOpenFile": "ファイルを開く",
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。", "fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
"usage": { "usage": {
"context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン", "context": "コンテキスト {{tokens}}",
"requests": "モデルリクエスト {{count}} 回", "turnInput": "ターン入力 {{tokens}}",
"request": "リクエスト {{count}} 回",
"requests": "リクエスト {{count}} 回",
"output": "出力 {{tokens}}",
"cached": "キャッシュ {{percent}}%",
"estimated": "推定使用量を含みます" "estimated": "推定使用量を含みます"
}, },
"activityThinkingFor": "{{duration}}考えています", "activityThinkingFor": "{{duration}}考えています",
@@ -1425,18 +1428,6 @@
"automationSourceFallback": "自動化", "automationSourceFallback": "自動化",
"automationTriggered": "自動実行" "automationTriggered": "自動実行"
}, },
"recovery": {
"actionFailed": "復元操作に失敗しました。もう一度お試しください。",
"interrupted": "タスクが中断されました",
"completed": "タスクが完了しました",
"failed": "タスクの復元に失敗しました",
"failedHelp": "保存されたタスクを安全に復元できませんでした。続行前に確認してください。",
"resuming": "中断されたタスクを復元しています…",
"review": "続行する前にタスクを確認してください。ツールは自動的に再実行されません。",
"safeResume": "保存された会話コンテキストから続行しています。",
"dismiss": "閉じる",
"continue": "続行"
},
"lightbox": { "lightbox": {
"title": "画像プレビュー", "title": "画像プレビュー",
"open": "画像を表示", "open": "画像を表示",
+9 -18
View File
@@ -142,6 +142,7 @@
"density": "밀도", "density": "밀도",
"activityMode": "활동 상세", "activityMode": "활동 상세",
"fileEditDisplay": "파일 편집 표시", "fileEditDisplay": "파일 편집 표시",
"showTurnUsage": "턴별 토큰 사용량 표시",
"codeWrap": "코드 줄바꿈", "codeWrap": "코드 줄바꿈",
"maxResults": "최대 결과 수", "maxResults": "최대 결과 수",
"timeout": "타임아웃", "timeout": "타임아웃",
@@ -161,7 +162,6 @@
"webuiDefaultAccess": "기본 권한", "webuiDefaultAccess": "기본 권한",
"currentModel": "현재 구성", "currentModel": "현재 구성",
"brandLogos": "브랜드 로고", "brandLogos": "브랜드 로고",
"browserNotifications": "작업 알림",
"cliAppsCatalog": "카탈로그", "cliAppsCatalog": "카탈로그",
"cliAppsFilter": "필터", "cliAppsFilter": "필터",
"engine": "엔진", "engine": "엔진",
@@ -186,6 +186,7 @@
"density": "이 브라우저에만 저장됩니다.", "density": "이 브라우저에만 저장됩니다.",
"activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.", "activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.",
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.", "fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.",
"showTurnUsage": "어시스턴트 메시지 아래에 컨텍스트, 누적 입력, 출력, 캐시 비율, 모델 호출 횟수를 표시합니다. 이 브라우저에만 저장됩니다.",
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.", "codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.", "maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.", "timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
@@ -205,7 +206,6 @@
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.", "selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
"selectedModelValue": "선택한 모델에 의해 설정됩니다.", "selectedModelValue": "선택한 모델에 의해 설정됩니다.",
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.", "brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
"browserNotifications": "이 페이지가 백그라운드에 있을 때만 알립니다. 기본값은 꺼짐입니다.",
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.", "cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.", "cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
"logs": "네이티브 엔진 로그 폴더를 엽니다.", "logs": "네이티브 엔진 로그 폴더를 엽니다.",
@@ -1001,8 +1001,7 @@
"activity": { "activity": {
"running": "에이전트 실행 중", "running": "에이전트 실행 중",
"complete": "에이전트 완료", "complete": "에이전트 완료",
"updated": "새 활동", "updated": "새 활동"
"recovery": "이 대화에는 확인이 필요합니다"
}, },
"pin": "고정", "pin": "고정",
"unpin": "고정 해제", "unpin": "고정 해제",
@@ -1403,8 +1402,12 @@
"fileEditOpenFile": "파일 열기", "fileEditOpenFile": "파일 열기",
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.", "fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
"usage": { "usage": {
"context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰", "context": "컨텍스트 {{tokens}}",
"requests": "모델 요청 {{count}}", "turnInput": "턴 입력 {{tokens}}",
"request": "요청 {{count}}회",
"requests": "요청 {{count}}회",
"output": "출력 {{tokens}}",
"cached": "캐시됨 {{percent}}%",
"estimated": "예상 사용량 포함" "estimated": "예상 사용량 포함"
}, },
"activityThinkingFor": "{{duration}} 동안 생각 중", "activityThinkingFor": "{{duration}} 동안 생각 중",
@@ -1425,18 +1428,6 @@
"automationSourceFallback": "자동화", "automationSourceFallback": "자동화",
"automationTriggered": "자동 실행됨" "automationTriggered": "자동 실행됨"
}, },
"recovery": {
"actionFailed": "복구 작업에 실패했습니다. 다시 시도하세요.",
"interrupted": "작업이 중단됨",
"completed": "작업 완료",
"failed": "작업 복구 실패",
"failedHelp": "저장된 작업을 안전하게 복구할 수 없습니다. 계속하기 전에 검토하세요.",
"resuming": "중단된 작업을 복구하는 중…",
"review": "계속하기 전에 작업을 검토하세요. 도구는 자동으로 다시 실행되지 않습니다.",
"safeResume": "저장된 대화 컨텍스트에서 계속합니다.",
"dismiss": "닫기",
"continue": "계속"
},
"lightbox": { "lightbox": {
"title": "이미지 미리보기", "title": "이미지 미리보기",
"open": "이미지 보기", "open": "이미지 보기",
+9 -18
View File
@@ -196,9 +196,9 @@
"density": "Densidade", "density": "Densidade",
"activityMode": "Detalhe de atividade", "activityMode": "Detalhe de atividade",
"fileEditDisplay": "Exibição de edição de arquivo", "fileEditDisplay": "Exibição de edição de arquivo",
"showTurnUsage": "Mostrar uso de tokens por turno",
"codeWrap": "Quebra de linha no código", "codeWrap": "Quebra de linha no código",
"brandLogos": "Logos de marca", "brandLogos": "Logos de marca",
"browserNotifications": "Notificações de tarefas",
"maxResults": "Máx. de resultados", "maxResults": "Máx. de resultados",
"timeout": "Tempo limite", "timeout": "Tempo limite",
"jinaReader": "Leitor Jina", "jinaReader": "Leitor Jina",
@@ -242,9 +242,9 @@
"density": "Armazenado apenas neste navegador.", "density": "Armazenado apenas neste navegador.",
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.", "activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.", "fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
"showTurnUsage": "Mostra contexto, entrada acumulada, saída, taxa de cache e chamadas do modelo abaixo das mensagens do assistente. Salvo somente neste navegador.",
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.", "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.", "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.", "maxResults": "Resultados retornados por cada chamada de web_search.",
"timeout": "Segundos antes de uma requisição de busca expirar.", "timeout": "Segundos antes de uma requisição de busca expirar.",
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.", "jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
@@ -1015,8 +1015,7 @@
"activity": { "activity": {
"running": "Agente em execução", "running": "Agente em execução",
"complete": "Agente finalizado", "complete": "Agente finalizado",
"updated": "Nova atividade", "updated": "Nova atividade"
"recovery": "Esta conversa precisa da sua atenção"
}, },
"pin": "Fixar", "pin": "Fixar",
"unpin": "Desafixar", "unpin": "Desafixar",
@@ -1434,23 +1433,15 @@
"fileEditOpenFile": "Abrir arquivo", "fileEditOpenFile": "Abrir arquivo",
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.", "fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.",
"usage": { "usage": {
"context": "Contexto atual: {{tokens}}{{capacity}}", "context": "{{tokens}} de contexto",
"requests": "{{count}} chamadas nesta rodada", "turnInput": "{{tokens}} de entrada na rodada",
"request": "{{count}} chamada",
"requests": "{{count}} chamadas",
"output": "{{tokens}} de saída",
"cached": "{{percent}}% em cache",
"estimated": "Inclui uso estimado" "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": { "lightbox": {
"title": "Pré-visualização de imagem", "title": "Pré-visualização de imagem",
"open": "Ver imagem", "open": "Ver imagem",
+9 -18
View File
@@ -142,6 +142,7 @@
"density": "Mật độ", "density": "Mật độ",
"activityMode": "Chi tiết hoạt động", "activityMode": "Chi tiết hoạt động",
"fileEditDisplay": "Hiển thị sửa tệp", "fileEditDisplay": "Hiển thị sửa tệp",
"showTurnUsage": "Hiển thị mức dùng token theo lượt",
"codeWrap": "Xuống dòng mã", "codeWrap": "Xuống dòng mã",
"maxResults": "Kết quả tối đa", "maxResults": "Kết quả tối đa",
"timeout": "Thời gian chờ", "timeout": "Thời gian chờ",
@@ -161,7 +162,6 @@
"webuiDefaultAccess": "Quyền mặc định", "webuiDefaultAccess": "Quyền mặc định",
"currentModel": "Cấu hình hiện tại", "currentModel": "Cấu hình hiện tại",
"brandLogos": "Logo thương hiệu", "brandLogos": "Logo thương hiệu",
"browserNotifications": "Thông báo tác vụ",
"cliAppsCatalog": "Danh mục", "cliAppsCatalog": "Danh mục",
"cliAppsFilter": "Bộ lọc", "cliAppsFilter": "Bộ lọc",
"engine": "Bộ máy", "engine": "Bộ máy",
@@ -186,6 +186,7 @@
"density": "Chỉ lưu trong trình duyệt này.", "density": "Chỉ lưu trong trình duyệt này.",
"activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.", "activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.",
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.", "fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.",
"showTurnUsage": "Hiển thị ngữ cảnh, đầu vào tích lũy, đầu ra, tỷ lệ bộ nhớ đệm và số lần gọi mô hình bên dưới tin nhắn của trợ lý. Chỉ được lưu trong trình duyệt này.",
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.", "codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
"maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.", "maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.",
"timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.", "timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.",
@@ -205,7 +206,6 @@
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.", "selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
"selectedModelValue": "Đượ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.", "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.", "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.", "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.", "logs": "Mở thư mục nhật ký của bộ máy gốc.",
@@ -1001,8 +1001,7 @@
"activity": { "activity": {
"running": "Tác nhân đang chạy", "running": "Tác nhân đang chạy",
"complete": "Tác nhân đã hoàn tất", "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", "pin": "Ghim",
"unpin": "Bỏ ghim", "unpin": "Bỏ ghim",
@@ -1403,8 +1402,12 @@
"fileEditOpenFile": "Mở tệp", "fileEditOpenFile": "Mở tệp",
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.", "fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
"usage": { "usage": {
"context": "Ngữ cảnh gần nhất: {{tokens}}{{capacity}} token", "context": "Ngữ cảnh {{tokens}}",
"requests": "{{count}} yêu cầu mô hình", "turnInput": "Đầu vào lượt {{tokens}}",
"request": "{{count}} yêu cầu",
"requests": "{{count}} yêu cầu",
"output": "Đầu ra {{tokens}}",
"cached": "{{percent}}% đã lưu đệm",
"estimated": "Bao gồm mức sử dụng ước tính" "estimated": "Bao gồm mức sử dụng ước tính"
}, },
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}", "activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
@@ -1425,18 +1428,6 @@
"automationSourceFallback": "Tự động hóa", "automationSourceFallback": "Tự động hóa",
"automationTriggered": "Tự động kích hoạt" "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": { "lightbox": {
"title": "Xem trước ảnh", "title": "Xem trước ảnh",
"open": "Xem ảnh", "open": "Xem ảnh",
+9 -18
View File
@@ -196,9 +196,9 @@
"density": "密度", "density": "密度",
"activityMode": "活动详情", "activityMode": "活动详情",
"fileEditDisplay": "文件编辑显示", "fileEditDisplay": "文件编辑显示",
"showTurnUsage": "显示每轮 Token 用量",
"codeWrap": "代码换行", "codeWrap": "代码换行",
"brandLogos": "品牌 Logo", "brandLogos": "品牌 Logo",
"browserNotifications": "任务通知",
"maxResults": "最大结果数", "maxResults": "最大结果数",
"timeout": "超时", "timeout": "超时",
"jinaReader": "Jina 阅读器", "jinaReader": "Jina 阅读器",
@@ -242,9 +242,9 @@
"density": "只保存在此浏览器中。", "density": "只保存在此浏览器中。",
"activityMode": "选择默认显示多少智能体活动详情。", "activityMode": "选择默认显示多少智能体活动详情。",
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。", "fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
"showTurnUsage": "在助手消息下显示上下文、累计输入、输出、缓存率和模型调用次数;仅保存在此浏览器。",
"codeWrap": "让长代码行在小屏幕上也易读。", "codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。", "brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
"browserNotifications": "仅在页面位于后台时通知,默认关闭。",
"maxResults": "每次 web_search 调用返回的结果数。", "maxResults": "每次 web_search 调用返回的结果数。",
"timeout": "搜索提供商请求超时前等待的秒数。", "timeout": "搜索提供商请求超时前等待的秒数。",
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。", "jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
@@ -1015,8 +1015,7 @@
"activity": { "activity": {
"running": "智能体正在运行", "running": "智能体正在运行",
"complete": "智能体已完成", "complete": "智能体已完成",
"updated": "有新内容", "updated": "有新内容"
"recovery": "此对话需要你的处理"
}, },
"pin": "置顶", "pin": "置顶",
"unpin": "取消置顶", "unpin": "取消置顶",
@@ -1434,23 +1433,15 @@
"fileEditOpenFile": "打开文件", "fileEditOpenFile": "打开文件",
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。", "fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
"usage": { "usage": {
"context": "当前上下文{{tokens}}{{capacity}}", "context": "上下文 {{tokens}}",
"requests": "本轮 {{count}} 次模型调用", "turnInput": "本轮输入 {{tokens}}",
"request": "{{count}} 次模型调用",
"requests": "{{count}} 次模型调用",
"output": "输出 {{tokens}}",
"cached": "缓存 {{percent}}%",
"estimated": "包含估算用量" "estimated": "包含估算用量"
} }
}, },
"recovery": {
"actionFailed": "恢复操作失败,请重试。",
"interrupted": "任务已中断",
"completed": "任务已完成",
"failed": "任务恢复失败",
"failedHelp": "无法安全恢复已保存的任务,继续前请先检查。",
"resuming": "正在恢复中断的任务…",
"review": "继续前请检查任务。工具不会被自动重放。",
"safeResume": "正在从已保存的对话上下文继续。",
"dismiss": "忽略",
"continue": "继续"
},
"lightbox": { "lightbox": {
"title": "图片预览", "title": "图片预览",
"open": "查看图片", "open": "查看图片",
+8 -17
View File
@@ -142,6 +142,7 @@
"density": "密度", "density": "密度",
"activityMode": "活動細節", "activityMode": "活動細節",
"fileEditDisplay": "檔案編輯顯示", "fileEditDisplay": "檔案編輯顯示",
"showTurnUsage": "顯示每輪 Token 用量",
"codeWrap": "程式碼換行", "codeWrap": "程式碼換行",
"maxResults": "最大結果數", "maxResults": "最大結果數",
"timeout": "逾時", "timeout": "逾時",
@@ -161,7 +162,6 @@
"webuiDefaultAccess": "預設存取權", "webuiDefaultAccess": "預設存取權",
"currentModel": "目前設定", "currentModel": "目前設定",
"brandLogos": "品牌 Logo", "brandLogos": "品牌 Logo",
"browserNotifications": "任務通知",
"cliAppsCatalog": "目錄", "cliAppsCatalog": "目錄",
"cliAppsFilter": "篩選", "cliAppsFilter": "篩選",
"engine": "引擎", "engine": "引擎",
@@ -186,6 +186,7 @@
"density": "只儲存在此瀏覽器中。", "density": "只儲存在此瀏覽器中。",
"activityMode": "選擇預設顯示多少智能體活動細節。", "activityMode": "選擇預設顯示多少智能體活動細節。",
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。", "fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
"showTurnUsage": "在助理訊息下顯示上下文、累計輸入、輸出、快取率和模型呼叫次數;僅儲存在此瀏覽器。",
"codeWrap": "讓長程式碼行在小螢幕上也易讀。", "codeWrap": "讓長程式碼行在小螢幕上也易讀。",
"maxResults": "每次呼叫 web_search 所回傳的結果數。", "maxResults": "每次呼叫 web_search 所回傳的結果數。",
"timeout": "搜尋供應商請求逾時前的秒數。", "timeout": "搜尋供應商請求逾時前的秒數。",
@@ -205,7 +206,6 @@
"selectedModelProvider": "由選取的模型決定。", "selectedModelProvider": "由選取的模型決定。",
"selectedModelValue": "由選取的模型決定。", "selectedModelValue": "由選取的模型決定。",
"brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。", "brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。",
"browserNotifications": "僅在頁面位於背景時通知,預設關閉。",
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。", "cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。",
"cliAppsFilter": "依應用程式、類別或功能搜尋。", "cliAppsFilter": "依應用程式、類別或功能搜尋。",
"logs": "開啟原生引擎日誌資料夾。", "logs": "開啟原生引擎日誌資料夾。",
@@ -1001,8 +1001,7 @@
"activity": { "activity": {
"running": "智能體正在執行", "running": "智能體正在執行",
"complete": "智能體已完成", "complete": "智能體已完成",
"updated": "有新內容", "updated": "有新內容"
"recovery": "此對話需要你的處理"
}, },
"pin": "置頂", "pin": "置頂",
"unpin": "取消置頂", "unpin": "取消置頂",
@@ -1402,8 +1401,12 @@
"fileEditOpenFile": "開啟檔案", "fileEditOpenFile": "開啟檔案",
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。", "fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
"usage": { "usage": {
"context": "最近一次上下文{{tokens}}{{capacity}} tokens", "context": "上下文 {{tokens}}",
"turnInput": "本輪輸入 {{tokens}}",
"request": "{{count}} 次模型請求",
"requests": "{{count}} 次模型請求", "requests": "{{count}} 次模型請求",
"output": "輸出 {{tokens}}",
"cached": "快取 {{percent}}%",
"estimated": "包含估算用量" "estimated": "包含估算用量"
}, },
"activityThinkingFor": "思考中,已 {{duration}}", "activityThinkingFor": "思考中,已 {{duration}}",
@@ -1425,18 +1428,6 @@
"automationTriggered": "已自動觸發", "automationTriggered": "已自動觸發",
"askAboutSelection": "繼續提問" "askAboutSelection": "繼續提問"
}, },
"recovery": {
"actionFailed": "復原操作失敗,請再試一次。",
"interrupted": "任務已中斷",
"completed": "任務已完成",
"failed": "任務復原失敗",
"failedHelp": "無法安全復原已儲存的任務,繼續前請先檢查。",
"resuming": "正在復原中斷的任務…",
"review": "繼續前請檢查任務。工具不會自動重播。",
"safeResume": "正在從已儲存的對話上下文繼續。",
"dismiss": "略過",
"continue": "繼續"
},
"lightbox": { "lightbox": {
"title": "圖片預覽", "title": "圖片預覽",
"open": "檢視圖片", "open": "檢視圖片",
+53 -64
View File
@@ -1,32 +1,20 @@
import type { UIMessage } from "@/lib/types"; import type { UIMessage } from "@/lib/types";
/** A completed turn has two surfaces: one activity container and one final /** A completed turn has two surfaces: one activity container and one final
* answer. Answer text is never inferred to be activity merely because a later * answer. An active turn temporarily preserves arrival order so visible
* tool event arrived; explicit reasoning/activity fields own that distinction. */ * Markdown never moves when a later tool starts. */
export type TurnUnit = export type TurnUnit =
| { | {
type: "activity"; type: "activity";
messages: UIMessage[]; messages: UIMessage[];
/** Number of raw UI messages represented by this display unit. */
sourceMessageCount: number;
turnLatencyMs?: number; turnLatencyMs?: number;
startedAtMs?: 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 { export function isReasoningOnlyAssistant(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace") return false; if (message.role !== "assistant" || message.kind === "trace") return false;
if ( if (message.activityKind === "model" || message.content.trim().length > 0) return false;
message.activityKind === "model"
|| message.content.trim().length > 0
|| !!message.media?.length
|| !!message.images?.length
) return false;
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming); return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
} }
@@ -54,9 +42,10 @@ export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
* *
* user [one live/completed activity surface] [one final answer] * user [one live/completed activity surface] [one final answer]
* *
* A provider may emit multiple answer segments around tool activity. They are * Assistant text that is followed by another activity is an intermediate model
* merged into the one final answer instead of being reclassified as reasoning; * segment. It remains in the activity timeline, where the renderer keeps its
* only explicit reasoning, trace, and model-activity rows enter the fold. * normal Markdown surface, but it never creates a second answer bubble. This
* is the same causal model used by Codex-style transcripts.
*/ */
export function normalizeActivityTimeline( export function normalizeActivityTimeline(
messages: UIMessage[], messages: UIMessage[],
@@ -74,41 +63,50 @@ export function normalizeActivityTimeline(
} }
const ordered = orderMessagesByTurnSeq(turnMessages); 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 activity: UIMessage[] = [];
const answers: UIMessage[] = []; const answers: UIMessage[] = [];
let activitySourceMessageCount = 0; ordered.forEach((message, index) => {
for (const message of ordered) {
if (isRawActivity(message)) { if (isRawActivity(message)) {
activity.push(message); activity.push(message);
activitySourceMessageCount += 1;
} else if (isAssistantAnswer(message)) { } else if (isAssistantAnswer(message)) {
if (message.reasoning?.trim() || message.reasoningStreaming) { 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)); activity.push(reasoningOnlyMessageFromAnswer(message));
} }
if (hasFinalAnswer && index === finalAnswerIndex) {
answers.push(stripInlineReasoning(message)); answers.push(stripInlineReasoning(message));
} else {
activity.push(modelActivitySnippet(message));
}
} else { } else {
activity.push(message); activity.push(message);
activitySourceMessageCount += 1;
}
} }
});
if (activity.length) { if (activity.length) {
units.push({ units.push({
type: "activity", type: "activity",
messages: activity, messages: activity,
sourceMessageCount: activitySourceMessageCount,
turnLatencyMs: activityTurnLatencyMs(activity, ordered), turnLatencyMs: activityTurnLatencyMs(activity, ordered),
startedAtMs: activeTurnStartedAtMs, startedAtMs: activeTurnStartedAtMs,
}); });
} }
if (answers.length) { if (answers.length) {
units.push({ units.push({ type: "message", message: mergeAssistantAnswers(answers) });
type: "message",
message: mergeAssistantAnswers(answers),
sourceMessageCount: answers.length,
});
} }
turnMessages = []; turnMessages = [];
@@ -119,7 +117,7 @@ export function normalizeActivityTimeline(
for (const message of messages) { for (const message of messages) {
if (message.role === "user") { if (message.role === "user") {
flushTurn(); flushTurn();
units.push({ type: "message", message, sourceMessageCount: 1 }); units.push({ type: "message", message });
activeTurnId = message.turnId; activeTurnId = message.turnId;
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt); activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
continue; continue;
@@ -134,16 +132,15 @@ export function normalizeActivityTimeline(
} }
/** /**
* Keep an in-flight turn in arrival order. Answer, reasoning, and activity * Keep an in-flight turn in arrival order. Until ``turn_end`` there is no
* semantics are explicit, but a later tool event can still arrive after * reliable way to know whether an assistant text segment is the final answer
* already-visible answer Markdown. Reparenting on each event would make that * or commentary before another tool call. Reclassifying it when that tool
* Markdown tree jump between containers. * arrives makes an already-visible Markdown tree jump between containers.
* *
* Completed turns still use ``normalizeActivityTimeline`` and collapse into * Completed turns still use ``normalizeActivityTimeline`` and collapse into
* one audit surface plus the merged answer. While the turn is active, answer * one audit surface plus the final answer. While the turn is active, text and
* text and contiguous activity phases stay in arrival order. A later tool * contiguous activity phases stay in arrival order. A later tool therefore
* therefore appends a Working surface after existing Markdown instead of * appends a Working surface after existing Markdown instead of reparenting it.
* reparenting it.
*/ */
export function projectActivityTimeline( export function projectActivityTimeline(
messages: UIMessage[], messages: UIMessage[],
@@ -179,29 +176,23 @@ function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
const prompt = messages[0]; const prompt = messages[0];
const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined; const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined;
let activity: UIMessage[] = []; let activity: UIMessage[] = [];
let activitySourceMessageCount = 0;
if (prompt?.role === "user") { if (prompt?.role === "user") units.push({ type: "message", message: prompt });
units.push({ type: "message", message: prompt, sourceMessageCount: 1 });
}
const flushActivity = () => { const flushActivity = () => {
if (!activity.length) return; if (!activity.length) return;
units.push({ units.push({
type: "activity", type: "activity",
messages: activity, messages: activity,
sourceMessageCount: activitySourceMessageCount,
turnLatencyMs: activityTurnLatencyMs(activity, activity), turnLatencyMs: activityTurnLatencyMs(activity, activity),
startedAtMs, startedAtMs,
}); });
activity = []; activity = [];
activitySourceMessageCount = 0;
}; };
for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) { for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) {
if (isRawActivity(message)) { if (isRawActivity(message)) {
activity.push(message); activity.push(message);
activitySourceMessageCount += 1;
continue; continue;
} }
if (isAssistantAnswer(message)) { if (isAssistantAnswer(message)) {
@@ -209,15 +200,10 @@ function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
activity.push(reasoningOnlyMessageFromAnswer(message)); activity.push(reasoningOnlyMessageFromAnswer(message));
} }
flushActivity(); flushActivity();
units.push({ units.push({ type: "message", message: stripInlineReasoning(message) });
type: "message",
message: stripInlineReasoning(message),
sourceMessageCount: 1,
});
continue; continue;
} }
activity.push(message); activity.push(message);
activitySourceMessageCount += 1;
} }
flushActivity(); flushActivity();
@@ -229,16 +215,7 @@ function isRawActivity(message: UIMessage): boolean {
} }
function isAssistantAnswer(message: UIMessage): boolean { function isAssistantAnswer(message: UIMessage): boolean {
if (message.role !== "assistant" || message.kind === "trace" || message.activityKind === "model") { return message.role === "assistant" && message.kind !== "trace" && message.content.trim().length > 0;
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[] { function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
@@ -271,6 +248,18 @@ function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
return merged; 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 { function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
return { return {
id: `${message.id}-reasoning`, id: `${message.id}-reasoning`,
+1 -1
View File
@@ -3,7 +3,7 @@ export type AnsiSegment = {
style?: AnsiStyle; style?: AnsiStyle;
}; };
type AnsiStyle = { export type AnsiStyle = {
backgroundColor?: string; backgroundColor?: string;
color?: string; color?: string;
fontStyle?: "italic"; fontStyle?: "italic";
-3
View File
@@ -22,7 +22,6 @@ import type {
ProviderOAuthCompletionResult, ProviderOAuthCompletionResult,
ProviderOAuthLoginResult, ProviderOAuthLoginResult,
ProviderSettingsUpdate, ProviderSettingsUpdate,
RecoveryState,
SessionDeleteResult, SessionDeleteResult,
SessionHandle, SessionHandle,
SessionAutomationsPayload, SessionAutomationsPayload,
@@ -193,7 +192,6 @@ export async function listSessions(
preview?: string; preview?: string;
model_preset?: string | null; model_preset?: string | null;
run_started_at?: number | null; run_started_at?: number | null;
recovery_state?: RecoveryState | null;
workspace_scope?: WorkspaceScopePayload | null; workspace_scope?: WorkspaceScopePayload | null;
handle?: SessionHandle | null; handle?: SessionHandle | null;
}; };
@@ -214,7 +212,6 @@ export async function listSessions(
preview: s.preview ?? "", preview: s.preview ?? "",
modelPreset: s.model_preset ?? null, modelPreset: s.model_preset ?? null,
runStartedAt: s.run_started_at ?? null, runStartedAt: s.run_started_at ?? null,
recoveryState: s.recovery_state ?? null,
workspaceScope: s.workspace_scope ?? null, workspaceScope: s.workspace_scope ?? null,
handle, handle,
}; };
+1 -1
View File
@@ -1,4 +1,4 @@
const DEFAULT_HTTP_TIMEOUT_MS = 20_000; export const DEFAULT_HTTP_TIMEOUT_MS = 20_000;
export async function fetchWithTimeout( export async function fetchWithTimeout(
input: RequestInfo | URL, input: RequestInfo | URL,
+1 -1
View File
@@ -11,7 +11,7 @@ import {
type EncodeResponse, type EncodeResponse,
} from "@/workers/imageEncode.worker"; } from "@/workers/imageEncode.worker";
export type { EncodeResponse, EncodeFailure } from "@/workers/imageEncode.worker"; export type { EncodeResponse, EncodeSuccess, EncodeFailure } from "@/workers/imageEncode.worker";
type Pending = { type Pending = {
resolve: (r: EncodeResponse) => void; resolve: (r: EncodeResponse) => void;

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