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