mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0228b126ae | ||
|
|
4fd21097e1 | ||
|
|
1581544b7f |
@@ -209,13 +209,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
|||||||
nanobot agent
|
nanobot agent
|
||||||
```
|
```
|
||||||
|
|
||||||
This opens the native terminal client with the launch directory as its workspace. It shares saved conversations and the local gateway with the WebUI.
|
This opens the native terminal client with the configured model and tools, using the launch directory as its workspace. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. Each launch starts a new session; `--session` selects an existing WebSocket session, while `--workspace` overrides the launch directory. Use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. `nanobot agent` and `nanobot webui` share one on-demand local gateway: either command can start it, each launcher releases only its own client, and the last interactive launcher to exit stops it. Use `/detach` to close the TUI while keeping the gateway and any active agent turn running in the background; after the terminal is restored, nanobot prints the exact `nanobot gateway stop` command for that config and workspace. Use `nanobot gateway --background` to start persistently before opening a client. Type `exit` or press `Ctrl+C` when you are done; after the terminal is restored, nanobot prints a ready-to-run `nanobot agent --session ...` command that resumes the session. Use `nanobot agent --classic` for the legacy Python prompt.
|
||||||
|
|
||||||
- Type `/` to discover commands, `/sessions` to switch conversations, or `@` to mention an app, MCP server, or saved session.
|
|
||||||
- Press `Enter` to send or steer, `Tab` to queue a follow-up, and `Shift+Enter` to add a newline (`Ctrl+J` works in terminals that cannot distinguish modified Enter keys).
|
|
||||||
- Use `/detach` to leave the current task running, or start with `nanobot gateway --background` when nanobot should stay online after all local clients exit.
|
|
||||||
|
|
||||||
Each launch starts a new session by default. Use `--session` to resume one and `--workspace` to choose another workspace. See the [CLI reference](./docs/cli-reference.md#agent-cli) for session branching, diffs, history, shortcuts, gateway lifecycle, and compatibility options.
|
|
||||||
|
|
||||||
For one request and an immediate exit, use:
|
For one request and an immediate exit, use:
|
||||||
|
|
||||||
|
|||||||
+143
-112
@@ -79,15 +79,6 @@ from nanobot.session.model_selection import (
|
|||||||
SESSION_MODEL_PRESET_METADATA_KEY,
|
SESSION_MODEL_PRESET_METADATA_KEY,
|
||||||
model_preset_from_metadata,
|
model_preset_from_metadata,
|
||||||
)
|
)
|
||||||
from nanobot.session.recovery import (
|
|
||||||
PENDING_FOLLOWUP_ID_KEY,
|
|
||||||
RECOVERY_INBOUND_METADATA_KEY,
|
|
||||||
RecoveryAdmission,
|
|
||||||
acknowledge_pending_followups,
|
|
||||||
record_pending_followup,
|
|
||||||
restore_pending_interruption,
|
|
||||||
restore_runtime_checkpoint,
|
|
||||||
)
|
|
||||||
from nanobot.session.summary import SessionSummary
|
from nanobot.session.summary import SessionSummary
|
||||||
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
||||||
from nanobot.utils.cancellation import task_is_cancelling
|
from nanobot.utils.cancellation import task_is_cancelling
|
||||||
@@ -300,14 +291,12 @@ class AgentLoop:
|
|||||||
restart_mode: str = "auto",
|
restart_mode: str = "auto",
|
||||||
local_trigger_store: LocalTriggerStore | None = None,
|
local_trigger_store: LocalTriggerStore | None = None,
|
||||||
idle_compact_check_interval_seconds: int = 0,
|
idle_compact_check_interval_seconds: int = 0,
|
||||||
recovery_admission: RecoveryAdmission | None = None,
|
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
_tc = tools_config or ToolsConfig()
|
_tc = tools_config or ToolsConfig()
|
||||||
defaults = AgentDefaults()
|
defaults = AgentDefaults()
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._recovery_admission = recovery_admission
|
|
||||||
if turn_delivery_factory is not None:
|
if turn_delivery_factory is not None:
|
||||||
if turn_delivery_factory.bus is not bus:
|
if turn_delivery_factory.bus is not bus:
|
||||||
raise ValueError("turn delivery factory must use the agent message bus")
|
raise ValueError("turn delivery factory must use the agent message bus")
|
||||||
@@ -420,7 +409,6 @@ class AgentLoop:
|
|||||||
# When a session has an active task, new messages for that session
|
# When a session has an active task, new messages for that session
|
||||||
# are routed here instead of creating a new task.
|
# are routed here instead of creating a new task.
|
||||||
self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
|
self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
|
||||||
self._preserve_inflight_turns_on_shutdown = False
|
|
||||||
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
|
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
|
||||||
self._cron_turns = CronTurnCoordinator(
|
self._cron_turns = CronTurnCoordinator(
|
||||||
publish_inbound=self.bus.publish_inbound,
|
publish_inbound=self.bus.publish_inbound,
|
||||||
@@ -738,9 +726,6 @@ class AgentLoop:
|
|||||||
extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
extra[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
||||||
session.add_message("user", text, **extra)
|
session.add_message("user", text, **extra)
|
||||||
self._mark_pending_user_turn(session)
|
self._mark_pending_user_turn(session)
|
||||||
followup_id = msg.metadata.get(PENDING_FOLLOWUP_ID_KEY)
|
|
||||||
if isinstance(followup_id, str) and followup_id:
|
|
||||||
acknowledge_pending_followups(session, [followup_id])
|
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -1076,9 +1061,6 @@ class AgentLoop:
|
|||||||
row["subagent_task_id"] = task_id
|
row["subagent_task_id"] = task_id
|
||||||
row[HIDDEN_HISTORY_META] = subagent_marker
|
row[HIDDEN_HISTORY_META] = subagent_marker
|
||||||
row["injected_event"] = "subagent_result"
|
row["injected_event"] = "subagent_result"
|
||||||
followup_id = metadata.get(PENDING_FOLLOWUP_ID_KEY)
|
|
||||||
if isinstance(followup_id, str) and followup_id:
|
|
||||||
row[PENDING_FOLLOWUP_ID_KEY] = followup_id
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
@@ -1303,23 +1285,6 @@ class AgentLoop:
|
|||||||
break
|
break
|
||||||
if deferred:
|
if deferred:
|
||||||
continue
|
continue
|
||||||
routed_msg = msg
|
|
||||||
if effective_key != msg.session_key:
|
|
||||||
routed_msg = dataclasses.replace(
|
|
||||||
msg,
|
|
||||||
session_key_override=effective_key,
|
|
||||||
)
|
|
||||||
# A newer WebUI message must supersede an explicit recovery
|
|
||||||
# before it is injected into that recovery's pending queue.
|
|
||||||
# Without this admission point, a recovered turn could finish
|
|
||||||
# first and only then observe the user's newer request.
|
|
||||||
if (
|
|
||||||
effective_key in self._pending_queues
|
|
||||||
and msg.channel == "websocket"
|
|
||||||
and self._recovery_admission is not None
|
|
||||||
and not await self._recovery_admission.admit(routed_msg)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
# If this session already has an active pending queue (i.e. a task
|
# If this session already has an active pending queue (i.e. a task
|
||||||
# is processing this session), route the message there for mid-turn
|
# is processing this session), route the message there for mid-turn
|
||||||
# injection instead of creating a competing task.
|
# injection instead of creating a competing task.
|
||||||
@@ -1332,18 +1297,12 @@ class AgentLoop:
|
|||||||
self.commands.dispatch,
|
self.commands.dispatch,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
pending_msg = routed_msg
|
pending_msg = msg
|
||||||
session = self.sessions.get_or_create(effective_key)
|
if effective_key != msg.session_key:
|
||||||
followup_id = record_pending_followup(session, pending_msg)
|
|
||||||
if followup_id is not None:
|
|
||||||
pending_msg = dataclasses.replace(
|
pending_msg = dataclasses.replace(
|
||||||
pending_msg,
|
msg,
|
||||||
metadata={
|
session_key_override=effective_key,
|
||||||
**pending_msg.metadata,
|
|
||||||
PENDING_FOLLOWUP_ID_KEY: followup_id,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
self.sessions.save(session)
|
|
||||||
try:
|
try:
|
||||||
self._pending_queues[effective_key].put_nowait(pending_msg)
|
self._pending_queues[effective_key].put_nowait(pending_msg)
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
@@ -1351,7 +1310,6 @@ class AgentLoop:
|
|||||||
"Pending queue full for session {}, falling back to queued task",
|
"Pending queue full for session {}, falling back to queued task",
|
||||||
effective_key,
|
effective_key,
|
||||||
)
|
)
|
||||||
msg = pending_msg
|
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Routed follow-up message to pending queue for session {}",
|
"Routed follow-up message to pending queue for session {}",
|
||||||
@@ -1361,45 +1319,17 @@ class AgentLoop:
|
|||||||
# Compute the effective session key before dispatching
|
# Compute the effective session key before dispatching
|
||||||
# This ensures /stop command can find tasks correctly when unified session is enabled
|
# This ensures /stop command can find tasks correctly when unified session is enabled
|
||||||
task = asyncio.create_task(self._dispatch(msg))
|
task = asyncio.create_task(self._dispatch(msg))
|
||||||
active_tasks: set[asyncio.Task[Any]] = self._active_tasks.setdefault(
|
active_tasks = self._active_tasks.setdefault(effective_key, set())
|
||||||
effective_key,
|
|
||||||
set(),
|
|
||||||
)
|
|
||||||
active_tasks.add(task)
|
active_tasks.add(task)
|
||||||
task.add_done_callback(active_tasks.discard)
|
task.add_done_callback(active_tasks.discard)
|
||||||
finally:
|
finally:
|
||||||
await self.aclose()
|
await self.aclose()
|
||||||
|
|
||||||
def preserve_inflight_turns_on_shutdown(self) -> None:
|
|
||||||
"""Keep durable checkpoints when the owning gateway exits.
|
|
||||||
|
|
||||||
Normal cancellation intentionally materializes partial output so a
|
|
||||||
user-stopped turn leaves a readable conversation. Gateway lifecycle
|
|
||||||
shutdown is different: RecoveryCoordinator needs the checkpoint intact
|
|
||||||
to safely offer the unfinished turn for explicit continuation later.
|
|
||||||
"""
|
|
||||||
self._preserve_inflight_turns_on_shutdown = True
|
|
||||||
|
|
||||||
async def _dispatch(self, msg: InboundMessage) -> None:
|
async def _dispatch(self, msg: InboundMessage) -> None:
|
||||||
"""Process a message: per-session serial, cross-session concurrent."""
|
"""Process a message: per-session serial, cross-session concurrent."""
|
||||||
session_key = self._effective_session_key(msg)
|
session_key = self._effective_session_key(msg)
|
||||||
if session_key != msg.session_key:
|
if session_key != msg.session_key:
|
||||||
msg = dataclasses.replace(msg, session_key_override=session_key)
|
msg = dataclasses.replace(msg, session_key_override=session_key)
|
||||||
recovery_task_registered = False
|
|
||||||
recovery_admission = self._recovery_admission
|
|
||||||
current_task: asyncio.Task[Any] | None = None
|
|
||||||
if recovery_admission is not None:
|
|
||||||
recovery_id = msg.metadata.get(RECOVERY_INBOUND_METADATA_KEY)
|
|
||||||
if isinstance(recovery_id, str) and recovery_id:
|
|
||||||
current_task = asyncio.current_task()
|
|
||||||
if current_task is not None:
|
|
||||||
recovery_admission.register_recovery_task(session_key, current_task)
|
|
||||||
recovery_task_registered = True
|
|
||||||
if not await recovery_admission.admit(msg):
|
|
||||||
logger.info("Skipped stale recovery for session {}", session_key)
|
|
||||||
if recovery_task_registered and current_task is not None:
|
|
||||||
recovery_admission.unregister_recovery_task(session_key, current_task)
|
|
||||||
return
|
|
||||||
lock = self._get_session_lock(session_key)
|
lock = self._get_session_lock(session_key)
|
||||||
gate = self._concurrency_gate or nullcontext()
|
gate = self._concurrency_gate or nullcontext()
|
||||||
|
|
||||||
@@ -1443,14 +1373,14 @@ class AgentLoop:
|
|||||||
session_key,
|
session_key,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
# An explicit turn stop materializes partial context so
|
# Preserve partial context from the interrupted turn so
|
||||||
# the next prompt can see completed tool results. Gateway
|
# the user does not lose tool results and assistant
|
||||||
# shutdown keeps the durable checkpoint untouched instead,
|
# messages accumulated before /stop. The checkpoint was
|
||||||
# allowing RecoveryCoordinator to offer Continue safely.
|
# already persisted to session metadata by
|
||||||
if (
|
# _emit_checkpoint during tool execution; materializing
|
||||||
session_key in self._discarding_sessions
|
# it into session history now makes it visible in the
|
||||||
or self._preserve_inflight_turns_on_shutdown
|
# next conversation turn.
|
||||||
):
|
if session_key in self._discarding_sessions:
|
||||||
raise
|
raise
|
||||||
try:
|
try:
|
||||||
key = self._effective_session_key(msg)
|
key = self._effective_session_key(msg)
|
||||||
@@ -1507,12 +1437,6 @@ class AgentLoop:
|
|||||||
await delivery.idle()
|
await delivery.idle()
|
||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._publish_next_deferred_automation_turn(session_key)
|
||||||
finally:
|
finally:
|
||||||
if (
|
|
||||||
recovery_task_registered
|
|
||||||
and current_task is not None
|
|
||||||
and recovery_admission is not None
|
|
||||||
):
|
|
||||||
recovery_admission.unregister_recovery_task(session_key, current_task)
|
|
||||||
if pending is None:
|
if pending is None:
|
||||||
await delivery.idle()
|
await delivery.idle()
|
||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._publish_next_deferred_automation_turn(session_key)
|
||||||
@@ -1814,10 +1738,7 @@ class AgentLoop:
|
|||||||
|
|
||||||
if self._restore_runtime_checkpoint(session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
if (
|
if self._restore_pending_user_turn(session):
|
||||||
RECOVERY_INBOUND_METADATA_KEY not in msg.metadata
|
|
||||||
and restore_pending_interruption(session)
|
|
||||||
):
|
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
async def _compact_session(self, ctx: TurnContext) -> None:
|
async def _compact_session(self, ctx: TurnContext) -> None:
|
||||||
@@ -2172,21 +2093,8 @@ class AgentLoop:
|
|||||||
if m.get("role") == "tool" and m.get("tool_call_id")
|
if m.get("role") == "tool" and m.get("tool_call_id")
|
||||||
}
|
}
|
||||||
last_assistant_idx: int | None = None
|
last_assistant_idx: int | None = None
|
||||||
saved_followup_ids: set[str] = set()
|
|
||||||
for m in messages[skip:]:
|
for m in messages[skip:]:
|
||||||
entry = dict(m)
|
entry = dict(m)
|
||||||
followup_id_value = cast(object, entry.pop(PENDING_FOLLOWUP_ID_KEY, None))
|
|
||||||
followup_ids = (
|
|
||||||
[followup_id_value]
|
|
||||||
if isinstance(followup_id_value, str)
|
|
||||||
else [
|
|
||||||
followup_id
|
|
||||||
for followup_id in cast(list[object], followup_id_value)
|
|
||||||
if isinstance(followup_id, str)
|
|
||||||
]
|
|
||||||
if isinstance(followup_id_value, list)
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
internal_meta = cast(object, entry.pop("_meta", None))
|
internal_meta = cast(object, entry.pop("_meta", None))
|
||||||
runtime_context_meta = (
|
runtime_context_meta = (
|
||||||
cast(dict[str, Any], internal_meta).get(
|
cast(dict[str, Any], internal_meta).get(
|
||||||
@@ -2239,8 +2147,6 @@ class AgentLoop:
|
|||||||
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
entry[RUNTIME_CONTEXT_HISTORY_META] = runtime_context_meta
|
||||||
entry.setdefault("timestamp", datetime.now().isoformat())
|
entry.setdefault("timestamp", datetime.now().isoformat())
|
||||||
session.messages.append(entry)
|
session.messages.append(entry)
|
||||||
if role == "user":
|
|
||||||
saved_followup_ids.update(followup_id for followup_id in followup_ids if followup_id)
|
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
last_assistant_idx = len(session.messages) - 1
|
last_assistant_idx = len(session.messages) - 1
|
||||||
declared_tool_call_ids.update(
|
declared_tool_call_ids.update(
|
||||||
@@ -2255,8 +2161,6 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
if turn_latency_ms is not None and last_assistant_idx is not None:
|
if turn_latency_ms is not None and last_assistant_idx is not None:
|
||||||
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
|
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
|
||||||
if saved_followup_ids:
|
|
||||||
acknowledge_pending_followups(session, saved_followup_ids)
|
|
||||||
session.updated_at = datetime.now()
|
session.updated_at = datetime.now()
|
||||||
|
|
||||||
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
|
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
|
||||||
@@ -2291,7 +2195,7 @@ class AgentLoop:
|
|||||||
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
|
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
|
||||||
"""Persist the latest in-flight turn state into session metadata."""
|
"""Persist the latest in-flight turn state into session metadata."""
|
||||||
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
|
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
|
||||||
self.sessions.save_runtime_checkpoint(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
def _mark_pending_user_turn(self, session: Session) -> None:
|
def _mark_pending_user_turn(self, session: Session) -> None:
|
||||||
session.metadata[self._PENDING_USER_TURN_KEY] = True
|
session.metadata[self._PENDING_USER_TURN_KEY] = True
|
||||||
@@ -2303,9 +2207,136 @@ class AgentLoop:
|
|||||||
if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
|
if self._RUNTIME_CHECKPOINT_KEY in session.metadata:
|
||||||
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None)
|
session.metadata.pop(self._RUNTIME_CHECKPOINT_KEY, None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _checkpoint_message_key(message: dict[str, Any]) -> tuple[Any, ...]:
|
||||||
|
return (
|
||||||
|
message.get("role"),
|
||||||
|
message.get("content"),
|
||||||
|
message.get("tool_call_id"),
|
||||||
|
message.get("name"),
|
||||||
|
message.get("tool_calls"),
|
||||||
|
message.get("reasoning_content"),
|
||||||
|
message.get("thinking_blocks"),
|
||||||
|
)
|
||||||
|
|
||||||
def _restore_runtime_checkpoint(self, session: Session) -> bool:
|
def _restore_runtime_checkpoint(self, session: Session) -> bool:
|
||||||
"""Materialize an unfinished turn into session history before a new request."""
|
"""Materialize an unfinished turn into session history before a new request."""
|
||||||
return restore_runtime_checkpoint(session)
|
from datetime import datetime
|
||||||
|
|
||||||
|
checkpoint = cast(
|
||||||
|
object,
|
||||||
|
session.metadata.get(self._RUNTIME_CHECKPOINT_KEY),
|
||||||
|
)
|
||||||
|
if not isinstance(checkpoint, dict):
|
||||||
|
return False
|
||||||
|
checkpoint_data = cast(dict[str, Any], checkpoint)
|
||||||
|
|
||||||
|
assistant_message = cast(object, checkpoint_data.get("assistant_message"))
|
||||||
|
completed_tool_results = cast(
|
||||||
|
Iterable[object],
|
||||||
|
checkpoint_data.get("completed_tool_results") or [],
|
||||||
|
)
|
||||||
|
pending_tool_calls = cast(
|
||||||
|
Iterable[object],
|
||||||
|
checkpoint_data.get("pending_tool_calls") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
restored_messages: list[dict[str, Any]] = []
|
||||||
|
if isinstance(assistant_message, dict):
|
||||||
|
restored = dict(cast(dict[str, Any], assistant_message))
|
||||||
|
restored.setdefault("timestamp", datetime.now().isoformat())
|
||||||
|
restored_messages.append(restored)
|
||||||
|
for message in completed_tool_results:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
restored = dict(cast(dict[str, Any], message))
|
||||||
|
restored.setdefault("timestamp", datetime.now().isoformat())
|
||||||
|
restored_messages.append(restored)
|
||||||
|
for tool_call in pending_tool_calls:
|
||||||
|
if not isinstance(tool_call, dict):
|
||||||
|
continue
|
||||||
|
tool_call_data = cast(dict[str, Any], tool_call)
|
||||||
|
tool_id = tool_call_data.get("id")
|
||||||
|
function_data = cast(
|
||||||
|
dict[str, Any],
|
||||||
|
tool_call_data.get("function") or {},
|
||||||
|
)
|
||||||
|
name = function_data.get("name") or "tool"
|
||||||
|
restored_messages.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"tool_call_id": tool_id,
|
||||||
|
"name": name,
|
||||||
|
"content": "Error: Task interrupted before this tool finished.",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
overlap = 0
|
||||||
|
max_overlap = min(len(session.messages), len(restored_messages))
|
||||||
|
for size in range(max_overlap, 0, -1):
|
||||||
|
existing = session.messages[-size:]
|
||||||
|
restored = restored_messages[:size]
|
||||||
|
if all(
|
||||||
|
self._checkpoint_message_key(left) == self._checkpoint_message_key(right)
|
||||||
|
for left, right in zip(existing, restored)
|
||||||
|
):
|
||||||
|
overlap = size
|
||||||
|
break
|
||||||
|
appended_messages = restored_messages[overlap:]
|
||||||
|
session.messages.extend(appended_messages)
|
||||||
|
assistant_message_data = (
|
||||||
|
cast(dict[str, Any], assistant_message)
|
||||||
|
if isinstance(assistant_message, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
provider_state_is_synchronized = (
|
||||||
|
checkpoint_data.get(self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY)
|
||||||
|
== self._PROVIDER_STATE_CHECKPOINT_VERSION
|
||||||
|
)
|
||||||
|
phase = checkpoint_data.get("phase")
|
||||||
|
exact_final_response = (
|
||||||
|
phase == "final_response"
|
||||||
|
and assistant_message_data is not None
|
||||||
|
and assistant_message_data.get("role") == "assistant"
|
||||||
|
and not bool(checkpoint_data.get("completed_tool_results"))
|
||||||
|
and not bool(checkpoint_data.get("pending_tool_calls"))
|
||||||
|
)
|
||||||
|
exact_completed_tools = (
|
||||||
|
phase == "tools_completed"
|
||||||
|
and assistant_message_data is not None
|
||||||
|
and assistant_message_data.get("role") == "assistant"
|
||||||
|
and not bool(checkpoint_data.get("pending_tool_calls"))
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
provider_state_is_synchronized
|
||||||
|
and (exact_final_response or exact_completed_tools)
|
||||||
|
):
|
||||||
|
session.provider_state = None
|
||||||
|
|
||||||
|
self._clear_pending_user_turn(session)
|
||||||
|
self._clear_runtime_checkpoint(session)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _restore_pending_user_turn(self, session: Session) -> bool:
|
||||||
|
"""Close a turn that only persisted the user message before crashing."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
if not session.metadata.get(self._PENDING_USER_TURN_KEY):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if session.messages and session.messages[-1].get("role") == "user":
|
||||||
|
session.messages.append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Error: Task interrupted before a response was generated.",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session.provider_state = None
|
||||||
|
session.updated_at = datetime.now()
|
||||||
|
|
||||||
|
self._clear_pending_user_turn(session)
|
||||||
|
return True
|
||||||
|
|
||||||
async def process_direct(
|
async def process_direct(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ from nanobot.runtime_context import (
|
|||||||
reattach_runtime_context,
|
reattach_runtime_context,
|
||||||
)
|
)
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
|
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
IncrementalThinkExtractor,
|
IncrementalThinkExtractor,
|
||||||
build_assistant_message,
|
build_assistant_message,
|
||||||
@@ -235,23 +234,6 @@ class AgentRunner:
|
|||||||
merged.get("content"),
|
merged.get("content"),
|
||||||
injection.get("content"),
|
injection.get("content"),
|
||||||
)
|
)
|
||||||
followup_id = injection.get(PENDING_FOLLOWUP_ID_KEY)
|
|
||||||
if isinstance(followup_id, str) and followup_id:
|
|
||||||
existing = cast(object, merged.get(PENDING_FOLLOWUP_ID_KEY))
|
|
||||||
followup_ids = (
|
|
||||||
[existing]
|
|
||||||
if isinstance(existing, str)
|
|
||||||
else [
|
|
||||||
item
|
|
||||||
for item in cast(list[object], existing)
|
|
||||||
if isinstance(item, str)
|
|
||||||
]
|
|
||||||
if isinstance(existing, list)
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
if followup_id not in followup_ids:
|
|
||||||
followup_ids.append(followup_id)
|
|
||||||
merged[PENDING_FOLLOWUP_ID_KEY] = followup_ids
|
|
||||||
messages[-1] = merged
|
messages[-1] = merged
|
||||||
continue
|
continue
|
||||||
messages.append(injection)
|
messages.append(injection)
|
||||||
|
|||||||
@@ -62,15 +62,6 @@ class TurnEndEvent(OutboundEvent):
|
|||||||
context_window_tokens: int | None = None
|
context_window_tokens: int | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RecoveryStateEvent(OutboundEvent):
|
|
||||||
status: str
|
|
||||||
recovery_id: str
|
|
||||||
reason: str | None = None
|
|
||||||
attempts: int = 0
|
|
||||||
can_continue: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class GoalStatusEvent(OutboundEvent):
|
class GoalStatusEvent(OutboundEvent):
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -145,9 +145,14 @@ class _FakeChannel:
|
|||||||
class _FakeInteractionResponse:
|
class _FakeInteractionResponse:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.messages: list[dict] = []
|
self.messages: list[dict] = []
|
||||||
|
self._done = False
|
||||||
|
|
||||||
async def send_message(self, content: str, *, ephemeral: bool = False) -> None:
|
async def send_message(self, content: str, *, ephemeral: bool = False) -> None:
|
||||||
self.messages.append({"content": content, "ephemeral": ephemeral})
|
self.messages.append({"content": content, "ephemeral": ephemeral})
|
||||||
|
self._done = True
|
||||||
|
|
||||||
|
def is_done(self) -> bool:
|
||||||
|
return self._done
|
||||||
|
|
||||||
|
|
||||||
def _make_interaction(
|
def _make_interaction(
|
||||||
|
|||||||
@@ -104,9 +104,6 @@ class ChannelManager:
|
|||||||
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
webui_mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||||
webui_mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
webui_mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
||||||
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
webui_recovery_action: (
|
|
||||||
Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None
|
|
||||||
) = None,
|
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
):
|
):
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
@@ -129,7 +126,6 @@ class ChannelManager:
|
|||||||
self._webui_mcp_runtime_status = webui_mcp_runtime_status
|
self._webui_mcp_runtime_status = webui_mcp_runtime_status
|
||||||
self._webui_mcp_reload = webui_mcp_reload
|
self._webui_mcp_reload = webui_mcp_reload
|
||||||
self._webui_skill_state_action = webui_skill_state_action
|
self._webui_skill_state_action = webui_skill_state_action
|
||||||
self._webui_recovery_action = webui_recovery_action
|
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._channel_owners: dict[str, str] = {}
|
self._channel_owners: dict[str, str] = {}
|
||||||
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
|
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
|
||||||
@@ -201,7 +197,6 @@ class ChannelManager:
|
|||||||
mcp_runtime_status=self._webui_mcp_runtime_status,
|
mcp_runtime_status=self._webui_mcp_runtime_status,
|
||||||
mcp_reload=self._webui_mcp_reload,
|
mcp_reload=self._webui_mcp_reload,
|
||||||
skill_state_action=self._webui_skill_state_action,
|
skill_state_action=self._webui_skill_state_action,
|
||||||
recovery_action=self._webui_recovery_action,
|
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
kwargs["gateway"] = gateway
|
kwargs["gateway"] = gateway
|
||||||
@@ -620,12 +615,6 @@ class ChannelManager:
|
|||||||
if target is None:
|
if target is None:
|
||||||
logger.warning("Restart notice target channel is not enabled: {}", notice.channel)
|
logger.warning("Restart notice target channel is not enabled: {}", notice.channel)
|
||||||
return
|
return
|
||||||
if notice.channel == "websocket":
|
|
||||||
# Reconnect and recovery are already represented by WebSocket
|
|
||||||
# protocol state. A generic restart-complete notice must not
|
|
||||||
# masquerade as a recovery transition and overwrite a real
|
|
||||||
# awaiting-user checkpoint in connected clients.
|
|
||||||
return
|
|
||||||
|
|
||||||
while not target.is_running:
|
while not target.is_running:
|
||||||
remaining = deadline - loop.time()
|
remaining = deadline - loop.time()
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class MattermostConfig(Base):
|
|||||||
include_thread_context: bool = True
|
include_thread_context: bool = True
|
||||||
thread_context_limit: int = 20
|
thread_context_limit: int = 20
|
||||||
streaming: bool = True
|
streaming: bool = True
|
||||||
|
streaming_max_chars: int = 16000
|
||||||
react_emoji: str = "eyes"
|
react_emoji: str = "eyes"
|
||||||
done_emoji: str = "white_check_mark"
|
done_emoji: str = "white_check_mark"
|
||||||
send_progress: bool = True
|
send_progress: bool = True
|
||||||
@@ -105,6 +106,7 @@ class MattermostChannel(BaseChannel):
|
|||||||
self._ws_task: asyncio.Task[None] | None = None
|
self._ws_task: asyncio.Task[None] | None = None
|
||||||
self._self_id: str | None = None
|
self._self_id: str | None = None
|
||||||
self._self_username: str | None = None
|
self._self_username: str | None = None
|
||||||
|
self._self_email: str | None = None
|
||||||
self._usernames: dict[str, str] = {}
|
self._usernames: dict[str, str] = {}
|
||||||
self._user_emails: dict[str, str] = {}
|
self._user_emails: dict[str, str] = {}
|
||||||
self._channel_types: dict[str, str] = {}
|
self._channel_types: dict[str, str] = {}
|
||||||
@@ -136,6 +138,7 @@ class MattermostChannel(BaseChannel):
|
|||||||
me = cast(dict[str, Any], resp.json())
|
me = cast(dict[str, Any], resp.json())
|
||||||
self._self_id = me.get("id")
|
self._self_id = me.get("id")
|
||||||
self._self_username = me.get("username")
|
self._self_username = me.get("username")
|
||||||
|
self._self_email = me.get("email", "")
|
||||||
self.logger.info("bot @{} connected", self._self_username)
|
self.logger.info("bot @{} connected", self._self_username)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error("Failed to identify bot user: {}", e)
|
self.logger.error("Failed to identify bot user: {}", e)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class _FakeHTTPClient:
|
|||||||
self.delete_calls: list[dict[str, Any]] = []
|
self.delete_calls: list[dict[str, Any]] = []
|
||||||
self._get_responses: dict[str, Any] = {}
|
self._get_responses: dict[str, Any] = {}
|
||||||
self._post_responses: dict[str, Any] = {}
|
self._post_responses: dict[str, Any] = {}
|
||||||
|
self._put_responses: dict[str, Any] = {}
|
||||||
|
self._delete_status: int | None = None
|
||||||
|
|
||||||
def _req(self, method: str, path: str) -> httpx.Request:
|
def _req(self, method: str, path: str) -> httpx.Request:
|
||||||
return httpx.Request(method, f"https://chat.example.com{path}")
|
return httpx.Request(method, f"https://chat.example.com{path}")
|
||||||
@@ -44,6 +46,12 @@ class _FakeHTTPClient:
|
|||||||
def set_post_response(self, path: str, data: Any) -> None:
|
def set_post_response(self, path: str, data: Any) -> None:
|
||||||
self._post_responses[path] = data
|
self._post_responses[path] = data
|
||||||
|
|
||||||
|
def set_put_response(self, path: str, data: Any) -> None:
|
||||||
|
self._put_responses[path] = data
|
||||||
|
|
||||||
|
def set_delete_status(self, status: int) -> None:
|
||||||
|
self._delete_status = status
|
||||||
|
|
||||||
async def get(self, path: str, **kwargs) -> httpx.Response:
|
async def get(self, path: str, **kwargs) -> httpx.Response:
|
||||||
self.get_calls.append({"path": path, **kwargs})
|
self.get_calls.append({"path": path, **kwargs})
|
||||||
data = self._get_responses.get(path, {"id": "resp_" + path.split("/")[-1]})
|
data = self._get_responses.get(path, {"id": "resp_" + path.split("/")[-1]})
|
||||||
@@ -63,11 +71,13 @@ class _FakeHTTPClient:
|
|||||||
|
|
||||||
async def put(self, path: str, *, json: dict[str, Any] | None = None, **kwargs) -> httpx.Response:
|
async def put(self, path: str, *, json: dict[str, Any] | None = None, **kwargs) -> httpx.Response:
|
||||||
self.put_calls.append({"path": path, "json": json})
|
self.put_calls.append({"path": path, "json": json})
|
||||||
return self._resp(200, {"id": path.split("/")[-1]}, "PUT", path)
|
data = self._put_responses.get(path, {"id": path.split("/")[-1]})
|
||||||
|
return self._resp(200, data, "PUT", path)
|
||||||
|
|
||||||
async def delete(self, path: str, **kwargs) -> httpx.Response:
|
async def delete(self, path: str, **kwargs) -> httpx.Response:
|
||||||
self.delete_calls.append({"path": path})
|
self.delete_calls.append({"path": path})
|
||||||
return self._resp(200, {}, "DELETE", path)
|
status = self._delete_status if self._delete_status is not None else 200
|
||||||
|
return self._resp(status, {}, "DELETE", path)
|
||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
pass
|
pass
|
||||||
@@ -109,6 +119,7 @@ def test_config_defaults():
|
|||||||
assert config.server_url == ""
|
assert config.server_url == ""
|
||||||
assert config.token == ""
|
assert config.token == ""
|
||||||
assert config.streaming is True
|
assert config.streaming is True
|
||||||
|
assert config.streaming_max_chars == 16000
|
||||||
assert config.send_tool_hints is True
|
assert config.send_tool_hints is True
|
||||||
assert config.dm.enabled is True
|
assert config.dm.enabled is True
|
||||||
assert config.dm.policy == "open"
|
assert config.dm.policy == "open"
|
||||||
@@ -139,6 +150,7 @@ def test_config_camelcase_aliases():
|
|||||||
"serverUrl": "https://mm.example.com",
|
"serverUrl": "https://mm.example.com",
|
||||||
"token": "abc123",
|
"token": "abc123",
|
||||||
"allowFromMatchMode": "username",
|
"allowFromMatchMode": "username",
|
||||||
|
"streamingMaxChars": 8000,
|
||||||
"replyInThread": False,
|
"replyInThread": False,
|
||||||
"sendToolHints": False,
|
"sendToolHints": False,
|
||||||
}
|
}
|
||||||
@@ -146,6 +158,7 @@ def test_config_camelcase_aliases():
|
|||||||
assert config.server_url == "https://mm.example.com"
|
assert config.server_url == "https://mm.example.com"
|
||||||
assert config.token == "abc123"
|
assert config.token == "abc123"
|
||||||
assert config.allow_from_match_mode == "username"
|
assert config.allow_from_match_mode == "username"
|
||||||
|
assert config.streaming_max_chars == 8000
|
||||||
assert config.reply_in_thread is False
|
assert config.reply_in_thread is False
|
||||||
assert config.send_tool_hints is False
|
assert config.send_tool_hints is False
|
||||||
|
|
||||||
@@ -181,6 +194,7 @@ async def test_start_identifies_bot():
|
|||||||
|
|
||||||
assert channel._self_id == "botuserid123"
|
assert channel._self_id == "botuserid123"
|
||||||
assert channel._self_username == "nanobot"
|
assert channel._self_username == "nanobot"
|
||||||
|
assert channel._self_email == "bot@example.com"
|
||||||
assert not start_task.done()
|
assert not start_task.done()
|
||||||
user_me_calls = [c for c in fake.get_calls[calls_before:] if "/api/v4/users/me" in c["path"]]
|
user_me_calls = [c for c in fake.get_calls[calls_before:] if "/api/v4/users/me" in c["path"]]
|
||||||
assert len(user_me_calls) == 1
|
assert len(user_me_calls) == 1
|
||||||
@@ -660,7 +674,7 @@ async def test_stream_end_adds_done_emoji():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stream_chunk_boundary_finalizes_and_creates_new():
|
async def test_stream_chunk_boundary_finalizes_and_creates_new():
|
||||||
channel, fake = _make_channel()
|
channel, fake = _make_channel({"streamingMaxChars": 10})
|
||||||
channel._self_id = "bot_id"
|
channel._self_id = "bot_id"
|
||||||
fake.set_post_response("/api/v4/posts", {"id": "post_1"})
|
fake.set_post_response("/api/v4/posts", {"id": "post_1"})
|
||||||
|
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ class MochatChannel(BaseChannel):
|
|||||||
self.config: MochatConfig = config
|
self.config: MochatConfig = config
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
self._socket: Any = None
|
self._socket: Any = None
|
||||||
self._ws_ready = False
|
self._ws_connected = self._ws_ready = False
|
||||||
|
|
||||||
self._state_dir = get_runtime_subdir("mochat")
|
self._state_dir = get_runtime_subdir("mochat")
|
||||||
self._cursor_path = self._state_dir / "session_cursors.json"
|
self._cursor_path = self._state_dir / "session_cursors.json"
|
||||||
@@ -346,7 +346,7 @@ class MochatChannel(BaseChannel):
|
|||||||
if self._http:
|
if self._http:
|
||||||
await self._http.aclose()
|
await self._http.aclose()
|
||||||
self._http = None
|
self._http = None
|
||||||
self._ws_ready = False
|
self._ws_connected = self._ws_ready = False
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send outbound message to session or panel."""
|
"""Send outbound message to session or panel."""
|
||||||
@@ -422,7 +422,7 @@ class MochatChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def connect() -> None:
|
async def connect() -> None:
|
||||||
self._ws_ready = False
|
self._ws_connected, self._ws_ready = True, False
|
||||||
self.logger.info("websocket connected")
|
self.logger.info("websocket connected")
|
||||||
subscribed = await self._subscribe_all()
|
subscribed = await self._subscribe_all()
|
||||||
self._ws_ready = subscribed
|
self._ws_ready = subscribed
|
||||||
@@ -431,7 +431,7 @@ class MochatChannel(BaseChannel):
|
|||||||
async def disconnect() -> None:
|
async def disconnect() -> None:
|
||||||
if not self._running:
|
if not self._running:
|
||||||
return
|
return
|
||||||
self._ws_ready = False
|
self._ws_connected = self._ws_ready = False
|
||||||
self.logger.warning("websocket disconnected")
|
self.logger.warning("websocket disconnected")
|
||||||
await self._ensure_fallback_workers()
|
await self._ensure_fallback_workers()
|
||||||
|
|
||||||
|
|||||||
@@ -363,6 +363,13 @@ def test_reported_daily_brief_pattern():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_chunk_styles(text: str, max_len: int) -> tuple[list[str], list[list[str]]]:
|
||||||
|
"""Helper: full markdown → signal pipeline, including chunking."""
|
||||||
|
plain, styles = _markdown_to_signal(text)
|
||||||
|
chunks = split_message(plain, max_len) if plain else [""]
|
||||||
|
return chunks, _partition_styles(plain, chunks, styles)
|
||||||
|
|
||||||
|
|
||||||
def test_partition_styles_single_chunk_passthrough():
|
def test_partition_styles_single_chunk_passthrough():
|
||||||
plain, styles = _markdown_to_signal("**bold** plain *it*")
|
plain, styles = _markdown_to_signal("**bold** plain *it*")
|
||||||
parts = _partition_styles(plain, [plain], styles)
|
parts = _partition_styles(plain, [plain], styles)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class SlackConfig(Base):
|
|||||||
webhook_path: str = "/slack/events"
|
webhook_path: str = "/slack/events"
|
||||||
bot_token: str = ""
|
bot_token: str = ""
|
||||||
app_token: str = ""
|
app_token: str = ""
|
||||||
|
user_token_read_only: bool = True
|
||||||
reply_in_thread: bool = True
|
reply_in_thread: bool = True
|
||||||
react_emoji: str = "eyes"
|
react_emoji: str = "eyes"
|
||||||
done_emoji: str = "white_check_mark"
|
done_emoji: str = "white_check_mark"
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ from nanobot.bus.outbound_events import (
|
|||||||
GoalStateSyncEvent,
|
GoalStateSyncEvent,
|
||||||
GoalStatusEvent,
|
GoalStatusEvent,
|
||||||
ProgressEvent,
|
ProgressEvent,
|
||||||
RecoveryStateEvent,
|
|
||||||
RuntimeModelUpdatedEvent,
|
RuntimeModelUpdatedEvent,
|
||||||
SessionUpdatedEvent,
|
SessionUpdatedEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
@@ -56,7 +55,6 @@ from nanobot.security.workspace_access import (
|
|||||||
)
|
)
|
||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.model_selection import model_preset_from_metadata
|
from nanobot.session.model_selection import model_preset_from_metadata
|
||||||
from nanobot.session.recovery import recovery_state_from_metadata
|
|
||||||
from nanobot.session.webui_turns import (
|
from nanobot.session.webui_turns import (
|
||||||
clear_websocket_turn_if_current,
|
clear_websocket_turn_if_current,
|
||||||
clear_websocket_turns,
|
clear_websocket_turns,
|
||||||
@@ -455,9 +453,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
|
self.logger.warning("ignoring invalid model preset metadata for chat_id={}", chat_id)
|
||||||
fields["model_preset"] = None
|
fields["model_preset"] = None
|
||||||
if isinstance(metadata, dict):
|
if isinstance(metadata, dict):
|
||||||
recovery_state = recovery_state_from_metadata(metadata)
|
|
||||||
if recovery_state is not None:
|
|
||||||
fields["recovery_state"] = recovery_state
|
|
||||||
usage = metadata.get("_last_usage")
|
usage = metadata.get("_last_usage")
|
||||||
if isinstance(usage, dict):
|
if isinstance(usage, dict):
|
||||||
sanitized_usage: dict[str, int | float] = {}
|
sanitized_usage: dict[str, int | float] = {}
|
||||||
@@ -1745,10 +1740,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
provenance=event.provenance,
|
provenance=event.provenance,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if isinstance(event, RecoveryStateEvent):
|
|
||||||
if conns:
|
|
||||||
await self.send_recovery_state(msg.chat_id, event)
|
|
||||||
return
|
|
||||||
if isinstance(event, GoalStateSyncEvent):
|
if isinstance(event, GoalStateSyncEvent):
|
||||||
if conns:
|
if conns:
|
||||||
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
||||||
@@ -2066,27 +2057,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" turn_end ")
|
await self._safe_send_to(connection, raw, label=" turn_end ")
|
||||||
|
|
||||||
async def send_recovery_state(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
event: RecoveryStateEvent,
|
|
||||||
) -> None:
|
|
||||||
"""Publish one structured recovery transition without chat pollution."""
|
|
||||||
body: dict[str, Any] = {
|
|
||||||
"event": "recovery_state",
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"status": event.status,
|
|
||||||
"recovery_id": event.recovery_id,
|
|
||||||
"attempts": event.attempts,
|
|
||||||
}
|
|
||||||
if event.reason:
|
|
||||||
body["reason"] = event.reason
|
|
||||||
if event.can_continue is not None:
|
|
||||||
body["can_continue"] = event.can_continue
|
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
|
||||||
for connection in list(self._subs.get(chat_id, ())):
|
|
||||||
await self._safe_send_to(connection, raw, label=" recovery_state ")
|
|
||||||
|
|
||||||
async def send_goal_state(self, chat_id: str, blob: dict[str, Any]) -> None:
|
async def send_goal_state(self, chat_id: str, blob: dict[str, Any]) -> None:
|
||||||
"""Push persisted goal-state snapshot for *chat_id* (multi-chat isolation)."""
|
"""Push persisted goal-state snapshot for *chat_id* (multi-chat isolation)."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ from nanobot.bus.outbound_events import (
|
|||||||
GoalStateSyncEvent,
|
GoalStateSyncEvent,
|
||||||
GoalStatusEvent,
|
GoalStatusEvent,
|
||||||
ProgressEvent,
|
ProgressEvent,
|
||||||
RecoveryStateEvent,
|
|
||||||
RuntimeModelUpdatedEvent,
|
RuntimeModelUpdatedEvent,
|
||||||
SessionUpdatedEvent,
|
SessionUpdatedEvent,
|
||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
@@ -2721,39 +2720,6 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_recovery_state_is_a_structured_event_not_assistant_text() -> None:
|
|
||||||
bus = MagicMock()
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(bus),
|
|
||||||
)
|
|
||||||
mock_ws = AsyncMock()
|
|
||||||
channel._attach(mock_ws, "chat-1")
|
|
||||||
|
|
||||||
await channel.send(OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="",
|
|
||||||
event=RecoveryStateEvent(
|
|
||||||
status="awaiting_user",
|
|
||||||
recovery_id="recovery-1",
|
|
||||||
reason="tool_state_unknown",
|
|
||||||
attempts=1,
|
|
||||||
),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert _sent_ws_payloads(mock_ws) == [{
|
|
||||||
"event": "recovery_state",
|
|
||||||
"chat_id": "chat-1",
|
|
||||||
"status": "awaiting_user",
|
|
||||||
"recovery_id": "recovery-1",
|
|
||||||
"reason": "tool_state_unknown",
|
|
||||||
"attempts": 1,
|
|
||||||
}]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
|
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ def _make_handler(
|
|||||||
channel_feature_action: Any | None = None,
|
channel_feature_action: Any | None = None,
|
||||||
channel_runtime_status: Any | None = None,
|
channel_runtime_status: Any | None = None,
|
||||||
mcp_reload: Any | None = None,
|
mcp_reload: Any | None = None,
|
||||||
recovery_action: Any | None = None,
|
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
config = WebSocketConfig.model_validate(cfg) if isinstance(cfg, dict) else cfg
|
||||||
workspace = workspace_path or Path.cwd()
|
workspace = workspace_path or Path.cwd()
|
||||||
@@ -104,7 +103,6 @@ def _make_handler(
|
|||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
mcp_reload=mcp_reload,
|
||||||
recovery_action=recovery_action,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -123,7 +121,6 @@ def _ch(
|
|||||||
channel_feature_action: Any | None = None,
|
channel_feature_action: Any | None = None,
|
||||||
channel_runtime_status: Any | None = None,
|
channel_runtime_status: Any | None = None,
|
||||||
mcp_reload: Any | None = None,
|
mcp_reload: Any | None = None,
|
||||||
recovery_action: Any | None = None,
|
|
||||||
**extra: Any,
|
**extra: Any,
|
||||||
) -> WebSocketChannel:
|
) -> WebSocketChannel:
|
||||||
cfg: dict[str, Any] = {
|
cfg: dict[str, Any] = {
|
||||||
@@ -148,7 +145,6 @@ def _ch(
|
|||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
mcp_reload=mcp_reload,
|
||||||
recovery_action=recovery_action,
|
|
||||||
)
|
)
|
||||||
return InProcessHttpChannel(cfg, bus, gateway=gateway)
|
return InProcessHttpChannel(cfg, bus, gateway=gateway)
|
||||||
|
|
||||||
@@ -1248,6 +1244,39 @@ async def test_pairing_routes_require_token_and_approve_or_deny(
|
|||||||
assert "Missing pairing code" in missing_code.text
|
assert "Missing pairing code" in missing_code.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_service_settings_read_api_key_from_webui_payload(bus: MagicMock) -> None:
|
||||||
|
channel = _ch(bus)
|
||||||
|
request = _FakeReq(path="/api/settings/api-service/start")
|
||||||
|
setattr(
|
||||||
|
request,
|
||||||
|
"_nanobot_webui_mutation_payload",
|
||||||
|
{"host": "0.0.0.0", "port": 8900, "timeout": 120, "api_key": "secret-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
query = channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
|
||||||
|
|
||||||
|
assert query == {
|
||||||
|
"host": ["0.0.0.0"],
|
||||||
|
"port": ["8900"],
|
||||||
|
"timeout": ["120"],
|
||||||
|
"api_key": ["secret-token"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_service_settings_reject_non_string_api_key(bus: MagicMock) -> None:
|
||||||
|
from nanobot.webui.settings_api import WebUISettingsError
|
||||||
|
|
||||||
|
channel = _ch(bus)
|
||||||
|
request = _FakeReq(path="/api/settings/api-service/start")
|
||||||
|
setattr(
|
||||||
|
request,
|
||||||
|
"_nanobot_webui_mutation_payload",
|
||||||
|
{"host": "127.0.0.1", "api_key": 123},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(WebUISettingsError, match="API key must be a string"):
|
||||||
|
channel.gateway.http.settings_routes._parse_api_service_settings_query(request)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_nanobot_feature_remote_install_requires_opt_in(
|
async def test_nanobot_feature_remote_install_requires_opt_in(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
@@ -3246,28 +3275,6 @@ async def _webui_mutate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_recovery_mutation_uses_authenticated_websocket_action(bus: MagicMock) -> None:
|
|
||||||
recovery_action = AsyncMock(return_value={
|
|
||||||
"status": "resuming",
|
|
||||||
"recovery_id": "recovery-1",
|
|
||||||
})
|
|
||||||
channel = _ch(bus, recovery_action=recovery_action)
|
|
||||||
|
|
||||||
response = await _webui_mutate(
|
|
||||||
channel,
|
|
||||||
"recovery.continue",
|
|
||||||
{"chat_id": "chat-1", "recovery_id": "recovery-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["status"] == "resuming"
|
|
||||||
recovery_action.assert_awaited_once_with(
|
|
||||||
"continue",
|
|
||||||
{"chat_id": "chat-1", "recovery_id": "recovery-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_workspace_folder_picker_is_local_authenticated_mutation(
|
async def test_workspace_folder_picker_is_local_authenticated_mutation(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
|
|||||||
@@ -202,6 +202,12 @@ class WsTestClient:
|
|||||||
assert msg.event == "delta", f"Expected 'delta' event, got '{msg.event}'"
|
assert msg.event == "delta", f"Expected 'delta' event, got '{msg.event}'"
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
async def recv_stream_end(self, timeout: float = 10.0) -> WsMessage:
|
||||||
|
"""Receive and validate a 'stream_end' event."""
|
||||||
|
msg = await self.recv(timeout)
|
||||||
|
assert msg.event == "stream_end", f"Expected 'stream_end' event, got '{msg.event}'"
|
||||||
|
return msg
|
||||||
|
|
||||||
async def collect_stream(self, timeout: float = 10.0) -> list[WsMessage]:
|
async def collect_stream(self, timeout: float = 10.0) -> list[WsMessage]:
|
||||||
"""Collect all deltas and the final stream_end into a list."""
|
"""Collect all deltas and the final stream_end into a list."""
|
||||||
messages: list[WsMessage] = []
|
messages: list[WsMessage] = []
|
||||||
@@ -226,6 +232,10 @@ class WsTestClient:
|
|||||||
"""Send a JSON frame."""
|
"""Send a JSON frame."""
|
||||||
await self.ws.send(json.dumps(data, ensure_ascii=False))
|
await self.ws.send(json.dumps(data, ensure_ascii=False))
|
||||||
|
|
||||||
|
async def send_content(self, content: str) -> None:
|
||||||
|
"""Send content in the preferred JSON format ``{"content": ...}``."""
|
||||||
|
await self.send_json({"content": content})
|
||||||
|
|
||||||
# -- Connection introspection -----------------------------------------
|
# -- Connection introspection -----------------------------------------
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ from nanobot.cli.agent import agent # noqa: E402
|
|||||||
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
||||||
from nanobot.cli.gateway_runtime import _run_gateway # noqa: E402
|
from nanobot.cli.gateway_runtime import _run_gateway # noqa: E402
|
||||||
from nanobot.cli.log_control import _set_nanobot_logs # noqa: E402
|
from nanobot.cli.log_control import _set_nanobot_logs # noqa: E402
|
||||||
from nanobot.cli.process_identity import set_cli_process_identity # noqa: E402
|
|
||||||
from nanobot.cli.provider import provider_app # noqa: E402
|
from nanobot.cli.provider import provider_app # noqa: E402
|
||||||
from nanobot.cli.runtime_config import ( # noqa: E402
|
from nanobot.cli.runtime_config import ( # noqa: E402
|
||||||
_load_inspection_config,
|
_load_inspection_config,
|
||||||
@@ -100,17 +99,12 @@ def version_callback(value: bool):
|
|||||||
|
|
||||||
@app.callback()
|
@app.callback()
|
||||||
def main(
|
def main(
|
||||||
ctx: typer.Context,
|
|
||||||
version: bool = typer.Option(
|
version: bool = typer.Option(
|
||||||
None, "--version", "-v", callback=version_callback, is_eager=True
|
None, "--version", "-v", callback=version_callback, is_eager=True
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
"""nanobot - Personal AI Assistant."""
|
"""nanobot - Personal AI Assistant."""
|
||||||
# Editable/source installs can retain an older generated console script that
|
pass
|
||||||
# imports this Typer app directly instead of ``nanobot.cli.entry``. Keep the
|
|
||||||
# role identity correct until that launcher is regenerated.
|
|
||||||
command = ctx.invoked_subcommand
|
|
||||||
set_cli_process_identity([command] if command else sys.argv[1:])
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
from nanobot.cli.process_identity import set_cli_process_identity
|
|
||||||
|
|
||||||
|
|
||||||
def _native_tui_candidate(args: list[str]) -> bool:
|
def _native_tui_candidate(args: list[str]) -> bool:
|
||||||
"""Return whether ``agent`` can start without the classic agent stack."""
|
"""Return whether ``agent`` can start without the classic agent stack."""
|
||||||
@@ -36,7 +34,6 @@ def _configure_windows_console() -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Dispatch native TUI startup without importing the complete CLI graph."""
|
"""Dispatch native TUI startup without importing the complete CLI graph."""
|
||||||
set_cli_process_identity(sys.argv[1:])
|
|
||||||
_configure_windows_console()
|
_configure_windows_console()
|
||||||
if _native_tui_candidate(sys.argv[1:]):
|
if _native_tui_candidate(sys.argv[1:]):
|
||||||
import typer
|
import typer
|
||||||
|
|||||||
@@ -322,7 +322,6 @@ def _run_gateway(
|
|||||||
from nanobot.providers.fallback_provider import FallbackProvider
|
from nanobot.providers.fallback_provider import FallbackProvider
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.recovery import RecoveryCoordinator
|
|
||||||
from nanobot.session.webui_turns import (
|
from nanobot.session.webui_turns import (
|
||||||
WebuiTurnCoordinator,
|
WebuiTurnCoordinator,
|
||||||
WebuiTurnRoutePolicy,
|
WebuiTurnRoutePolicy,
|
||||||
@@ -423,12 +422,6 @@ def _run_gateway(
|
|||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
mcp_provider = MCPProvider.from_config(config, tools)
|
mcp_provider = MCPProvider.from_config(config, tools)
|
||||||
|
|
||||||
recovery = RecoveryCoordinator(
|
|
||||||
sessions=session_manager,
|
|
||||||
bus=bus,
|
|
||||||
unified_session=config.agents.defaults.unified_session,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop.from_config(
|
agent = AgentLoop.from_config(
|
||||||
config, bus,
|
config, bus,
|
||||||
@@ -447,7 +440,6 @@ def _run_gateway(
|
|||||||
local_trigger_store=trigger_store,
|
local_trigger_store=trigger_store,
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
tool_registry=tools,
|
tool_registry=tools,
|
||||||
recovery_admission=recovery,
|
|
||||||
)
|
)
|
||||||
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
|
||||||
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
|
||||||
@@ -456,7 +448,6 @@ def _run_gateway(
|
|||||||
bus=bus,
|
bus=bus,
|
||||||
sessions=session_manager,
|
sessions=session_manager,
|
||||||
schedule_background=_schedule_webui_background,
|
schedule_background=_schedule_webui_background,
|
||||||
recovery=recovery,
|
|
||||||
)
|
)
|
||||||
webui_turn_coordinator.subscribe(runtime_events)
|
webui_turn_coordinator.subscribe(runtime_events)
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -692,7 +683,6 @@ def _run_gateway(
|
|||||||
webui_mcp_runtime_status=mcp_provider.runtime_status,
|
webui_mcp_runtime_status=mcp_provider.runtime_status,
|
||||||
webui_mcp_reload=mcp_provider.reload,
|
webui_mcp_reload=mcp_provider.reload,
|
||||||
webui_skill_state_action=_webui_skill_state_action,
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
webui_recovery_action=recovery.handle_action,
|
|
||||||
config_path=Path(config_path),
|
config_path=Path(config_path),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -859,7 +849,6 @@ def _run_gateway(
|
|||||||
tasks: list[asyncio.Task[Any]] = []
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
shutdown_task: asyncio.Task[Any] | None = None
|
shutdown_task: asyncio.Task[Any] | None = None
|
||||||
runtime_tasks: asyncio.Future[list[Any]] | None = None
|
runtime_tasks: asyncio.Future[list[Any]] | None = None
|
||||||
startup_complete = False
|
|
||||||
shutdown_event = asyncio.Event()
|
shutdown_event = asyncio.Event()
|
||||||
cli_terminal._ensure_interactive_tty_mode()
|
cli_terminal._ensure_interactive_tty_mode()
|
||||||
restore_shutdown_handlers = _install_gateway_shutdown_handlers(
|
restore_shutdown_handlers = _install_gateway_shutdown_handlers(
|
||||||
@@ -872,10 +861,6 @@ def _run_gateway(
|
|||||||
await cron.start()
|
await cron.start()
|
||||||
# Re-read once on first admission to close the watcher subscription window.
|
# Re-read once on first admission to close the watcher subscription window.
|
||||||
agent.runtime_resolver.invalidate()
|
agent.runtime_resolver.invalidate()
|
||||||
# Recovery must finish before WebSocket and other channels begin
|
|
||||||
# accepting new input. That makes a new user message reliably
|
|
||||||
# supersede an old recoverable turn instead of racing its queue.
|
|
||||||
await recovery.scan()
|
|
||||||
async def _run_agent() -> None:
|
async def _run_agent() -> None:
|
||||||
try:
|
try:
|
||||||
await mcp_provider.connect()
|
await mcp_provider.connect()
|
||||||
@@ -930,7 +915,6 @@ def _run_gateway(
|
|||||||
name="nanobot-webui-dev-server",
|
name="nanobot-webui-dev-server",
|
||||||
))
|
))
|
||||||
runtime_tasks = asyncio.gather(*tasks)
|
runtime_tasks = asyncio.gather(*tasks)
|
||||||
startup_complete = True
|
|
||||||
shutdown_task = asyncio.create_task(
|
shutdown_task = asyncio.create_task(
|
||||||
shutdown_event.wait(),
|
shutdown_event.wait(),
|
||||||
name="nanobot-gateway-shutdown",
|
name="nanobot-gateway-shutdown",
|
||||||
@@ -952,10 +936,6 @@ def _run_gateway(
|
|||||||
|
|
||||||
console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
|
console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
|
||||||
console.print(traceback.format_exc())
|
console.print(traceback.format_exc())
|
||||||
if not startup_complete:
|
|
||||||
# Do not report a successful gateway command when startup
|
|
||||||
# failed before any runtime task or listener was created.
|
|
||||||
raise typer.Exit(1)
|
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
if shutdown_task and not shutdown_task.done():
|
if shutdown_task and not shutdown_task.done():
|
||||||
@@ -963,10 +943,6 @@ def _run_gateway(
|
|||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
await shutdown_task
|
await shutdown_task
|
||||||
cron.stop()
|
cron.stop()
|
||||||
# A gateway exit interrupts ownership of active turns; it is
|
|
||||||
# not the same as the user stopping a turn. Keep checkpoints
|
|
||||||
# so the next gateway can offer an explicit Continue action.
|
|
||||||
agent.preserve_inflight_turns_on_shutdown()
|
|
||||||
agent.stop()
|
agent.stop()
|
||||||
# Cancel runtime tasks first, then deterministically close
|
# Cancel runtime tasks first, then deterministically close
|
||||||
# exec/MCP resources while the event loop is still alive.
|
# exec/MCP resources while the event loop is still alive.
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
"""Give nanobot processes recognizable operating-system names."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
_ROLES: Final = {"agent", "gateway", "webui"}
|
|
||||||
|
|
||||||
|
|
||||||
def _set_process_title(title: str) -> None:
|
|
||||||
# Process titles are short; do not trade Linux /proc environment visibility for
|
|
||||||
# extra title storage. setproctitle reads this switch when it is imported.
|
|
||||||
os.environ.setdefault("SPT_NOENV", "1")
|
|
||||||
from setproctitle import setproctitle
|
|
||||||
|
|
||||||
setproctitle(title)
|
|
||||||
|
|
||||||
|
|
||||||
def set_cli_process_identity(args: list[str]) -> None:
|
|
||||||
"""Name this CLI process after the nanobot role it is running."""
|
|
||||||
if os.name == "nt":
|
|
||||||
# Windows process managers use the console launcher's executable name,
|
|
||||||
# which packaging already generates as ``nanobot.exe``.
|
|
||||||
return
|
|
||||||
role = args[0] if args and args[0] in _ROLES else None
|
|
||||||
_set_process_title(f"nanobot-{role}" if role else "nanobot")
|
|
||||||
|
|
||||||
|
|
||||||
def named_executable(executable: str, *, name: str, directory: Path) -> str:
|
|
||||||
"""Return a stable POSIX symlink whose basename identifies a child process."""
|
|
||||||
if os.name == "nt":
|
|
||||||
return executable
|
|
||||||
try:
|
|
||||||
target = Path(executable).resolve(strict=True)
|
|
||||||
digest = hashlib.sha256(os.fsencode(target)).hexdigest()[:12]
|
|
||||||
link_dir = directory / digest
|
|
||||||
link = link_dir / name
|
|
||||||
link_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
if link.is_symlink() and link.resolve(strict=False) == target:
|
|
||||||
return str(link)
|
|
||||||
if link.exists():
|
|
||||||
return executable
|
|
||||||
pending = link.with_name(f".{name}.{os.getpid()}")
|
|
||||||
pending.unlink(missing_ok=True)
|
|
||||||
pending.symlink_to(target)
|
|
||||||
os.replace(pending, link)
|
|
||||||
except OSError:
|
|
||||||
return executable
|
|
||||||
return str(link)
|
|
||||||
@@ -17,7 +17,6 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
from nanobot.cli.process_identity import named_executable
|
|
||||||
from nanobot.cli.runtime_config import _model_display
|
from nanobot.cli.runtime_config import _model_display
|
||||||
from nanobot.cli.webui_support import (
|
from nanobot.cli.webui_support import (
|
||||||
_gateway_health_ready,
|
_gateway_health_ready,
|
||||||
@@ -230,12 +229,7 @@ def _resolve_source_tui_command(source_dir: Path, bun: str) -> list[str]:
|
|||||||
detail = (install.stderr or install.stdout).strip().splitlines()
|
detail = (install.stderr or install.stdout).strip().splitlines()
|
||||||
suffix = f": {detail[-1]}" if detail else ""
|
suffix = f": {detail[-1]}" if detail else ""
|
||||||
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
|
raise TuiUnavailableError(f"could not install TUI dependencies{suffix}")
|
||||||
executable = named_executable(
|
return [bun, str(source_dir / "src" / "index.ts")]
|
||||||
bun,
|
|
||||||
name="nanobot-tui",
|
|
||||||
directory=get_data_dir() / "run" / "executables",
|
|
||||||
)
|
|
||||||
return [executable, str(source_dir / "src" / "index.ts")]
|
|
||||||
|
|
||||||
|
|
||||||
def _download_release_tui(asset: str) -> Path | None:
|
def _download_release_tui(asset: str) -> Path | None:
|
||||||
|
|||||||
@@ -96,6 +96,22 @@ class ManagedProcessRuntime(Generic[_StartOptionsT]):
|
|||||||
# it; poll() both reaps it and reports the real lifecycle state.
|
# it; poll() both reaps it and reports the real lifecycle state.
|
||||||
self._owned_process: Any | None = None
|
self._owned_process: Any | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def refresh_state_pid(cls, *, paths: ProcessRuntimePaths) -> None:
|
||||||
|
"""Update a managed state file after the recorded process restarts."""
|
||||||
|
if not paths.state_path.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
state = json.loads(paths.state_path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return
|
||||||
|
state["pid"] = os.getpid()
|
||||||
|
runtime = cls(paths=paths)
|
||||||
|
state.pop("stable_identity", None)
|
||||||
|
state.update(runtime.process_identity_record(os.getpid()))
|
||||||
|
state["started_at"] = _utc_now()
|
||||||
|
runtime._write_state(state)
|
||||||
|
|
||||||
def start_background(self, options: _StartOptionsT) -> ProcessResult:
|
def start_background(self, options: _StartOptionsT) -> ProcessResult:
|
||||||
"""Start the configured command as a detached process."""
|
"""Start the configured command as a detached process."""
|
||||||
with self._lifecycle_lock():
|
with self._lifecycle_lock():
|
||||||
|
|||||||
+2
-135
@@ -48,21 +48,15 @@ _SESSION_PREVIEW_MAX_CHARS = 120
|
|||||||
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
||||||
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
||||||
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
|
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
|
||||||
_RUNTIME_CHECKPOINT_DATA_ERRORS = (OSError, *_SESSION_DATA_ERRORS)
|
|
||||||
_PROVIDER_STATE_RECORD_TYPE = "provider_state"
|
_PROVIDER_STATE_RECORD_TYPE = "provider_state"
|
||||||
_PROVIDER_STATE_RECORD_PREFIX_RE = re.compile(
|
_PROVIDER_STATE_RECORD_PREFIX_RE = re.compile(
|
||||||
r'^\s*\{\s*"_type"\s*:\s*"provider_state"\s*(?:,|\})'
|
r'^\s*\{\s*"_type"\s*:\s*"provider_state"\s*(?:,|\})'
|
||||||
)
|
)
|
||||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
|
||||||
_RUNTIME_CHECKPOINT_VERSION = 1
|
|
||||||
_RUNTIME_CHECKPOINT_SUFFIX = ".checkpoint.json"
|
|
||||||
_FORK_VOLATILE_METADATA_KEYS = {
|
_FORK_VOLATILE_METADATA_KEYS = {
|
||||||
"goal_state",
|
"goal_state",
|
||||||
"pending_user_turn",
|
"pending_user_turn",
|
||||||
"pending_user_followups",
|
|
||||||
"runtime_checkpoint",
|
"runtime_checkpoint",
|
||||||
"session_handle",
|
"session_handle",
|
||||||
"webui_recovery",
|
|
||||||
"thread_goal",
|
"thread_goal",
|
||||||
"title",
|
"title",
|
||||||
"title_user_edited",
|
"title_user_edited",
|
||||||
@@ -1007,9 +1001,6 @@ class JsonlSessionStore:
|
|||||||
def get_session_path(self, key: str) -> Path:
|
def get_session_path(self, key: str) -> Path:
|
||||||
return self.sessions_dir / f"{self.storage_key(key)}.jsonl"
|
return self.sessions_dir / f"{self.storage_key(key)}.jsonl"
|
||||||
|
|
||||||
def get_runtime_checkpoint_path(self, key: str) -> Path:
|
|
||||||
return self.sessions_dir / f"{self.storage_key(key)}{_RUNTIME_CHECKPOINT_SUFFIX}"
|
|
||||||
|
|
||||||
def get_legacy_lossy_path(self, key: str) -> Path:
|
def get_legacy_lossy_path(self, key: str) -> Path:
|
||||||
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
||||||
|
|
||||||
@@ -1075,7 +1066,7 @@ class JsonlSessionStore:
|
|||||||
else:
|
else:
|
||||||
messages.append(data)
|
messages.append(data)
|
||||||
|
|
||||||
session = Session(
|
return Session(
|
||||||
key=key,
|
key=key,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
created_at=created_at or datetime.now(),
|
created_at=created_at or datetime.now(),
|
||||||
@@ -1084,8 +1075,6 @@ class JsonlSessionStore:
|
|||||||
last_consolidated=last_consolidated,
|
last_consolidated=last_consolidated,
|
||||||
provider_state=provider_state,
|
provider_state=provider_state,
|
||||||
)
|
)
|
||||||
self._overlay_runtime_checkpoint_unlocked(session, path)
|
|
||||||
return session
|
|
||||||
except _SESSION_DATA_ERRORS as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Failed to load session {}: {}", key, e)
|
logger.warning("Failed to load session {}: {}", key, e)
|
||||||
repaired = self._repair_unlocked(key)
|
repaired = self._repair_unlocked(key)
|
||||||
@@ -1170,7 +1159,7 @@ class JsonlSessionStore:
|
|||||||
if not messages and not metadata and provider_state is None:
|
if not messages and not metadata and provider_state is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
session = Session(
|
return Session(
|
||||||
key=key,
|
key=key,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
created_at=created_at or datetime.now(),
|
created_at=created_at or datetime.now(),
|
||||||
@@ -1179,8 +1168,6 @@ class JsonlSessionStore:
|
|||||||
last_consolidated=last_consolidated,
|
last_consolidated=last_consolidated,
|
||||||
provider_state=provider_state,
|
provider_state=provider_state,
|
||||||
)
|
)
|
||||||
self._overlay_runtime_checkpoint_unlocked(session, path)
|
|
||||||
return session
|
|
||||||
except _SESSION_DATA_ERRORS as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Repair failed for session {}: {}", key, e)
|
logger.warning("Repair failed for session {}: {}", key, e)
|
||||||
return None
|
return None
|
||||||
@@ -1199,105 +1186,6 @@ class JsonlSessionStore:
|
|||||||
with self._session_files_lock:
|
with self._session_files_lock:
|
||||||
self._save_unlocked(session, fsync=fsync)
|
self._save_unlocked(session, fsync=fsync)
|
||||||
|
|
||||||
def save_runtime_checkpoint(self, session: Session) -> None:
|
|
||||||
"""Atomically persist only the volatile in-flight turn state.
|
|
||||||
|
|
||||||
A checkpoint is written several times during a tool-heavy turn. Keeping it
|
|
||||||
beside the append history avoids copying the full transcript at each safe
|
|
||||||
recovery boundary.
|
|
||||||
"""
|
|
||||||
with self._session_files_lock:
|
|
||||||
path = self.get_session_path(session.key)
|
|
||||||
if not path.exists():
|
|
||||||
# A user turn normally creates the session first. Internal callers
|
|
||||||
# may checkpoint a fresh session, so establish the durable base once.
|
|
||||||
self._save_unlocked(session)
|
|
||||||
return
|
|
||||||
|
|
||||||
checkpoint = session.metadata.get(_RUNTIME_CHECKPOINT_KEY)
|
|
||||||
if not isinstance(checkpoint, dict):
|
|
||||||
self.get_runtime_checkpoint_path(session.key).unlink(missing_ok=True)
|
|
||||||
return
|
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"version": _RUNTIME_CHECKPOINT_VERSION,
|
|
||||||
"session_key": session.key,
|
|
||||||
"base_updated_at": session.updated_at.isoformat(),
|
|
||||||
"base_message_count": len(session.messages),
|
|
||||||
"checkpoint": checkpoint,
|
|
||||||
"provider_state": (
|
|
||||||
session.provider_state.to_private_record()
|
|
||||||
if session.provider_state is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
target = self.get_runtime_checkpoint_path(session.key)
|
|
||||||
tmp = target.with_name(f".{target.name}.{secrets.token_hex(8)}.tmp")
|
|
||||||
try:
|
|
||||||
with open(tmp, "x", encoding="utf-8") as handle:
|
|
||||||
os.chmod(tmp, 0o600)
|
|
||||||
json.dump(
|
|
||||||
payload,
|
|
||||||
handle,
|
|
||||||
ensure_ascii=False,
|
|
||||||
separators=(",", ":"),
|
|
||||||
)
|
|
||||||
os.replace(tmp, target)
|
|
||||||
finally:
|
|
||||||
tmp.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def _overlay_runtime_checkpoint_unlocked(self, session: Session, main_path: Path) -> None:
|
|
||||||
checkpoint_path = self.get_runtime_checkpoint_path(session.key)
|
|
||||||
try:
|
|
||||||
checkpoint_stat = checkpoint_path.lstat()
|
|
||||||
if not stat.S_ISREG(checkpoint_stat.st_mode):
|
|
||||||
logger.warning(
|
|
||||||
"Ignoring non-regular runtime checkpoint for session {}",
|
|
||||||
session.key,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
# A complete session save supersedes an older sidecar. This comparison
|
|
||||||
# closes the small crash window between replacing the JSONL and unlinking
|
|
||||||
# its previous checkpoint.
|
|
||||||
if main_path.stat().st_mtime_ns > checkpoint_stat.st_mtime_ns:
|
|
||||||
checkpoint_path.unlink(missing_ok=True)
|
|
||||||
return
|
|
||||||
raw = _json_object(json.loads(checkpoint_path.read_text(encoding="utf-8")))
|
|
||||||
if (
|
|
||||||
raw.get("version") != _RUNTIME_CHECKPOINT_VERSION
|
|
||||||
or raw.get("session_key") != session.key
|
|
||||||
or raw.get("base_updated_at") != session.updated_at.isoformat()
|
|
||||||
or raw.get("base_message_count") != len(session.messages)
|
|
||||||
or not isinstance(raw.get("checkpoint"), dict)
|
|
||||||
):
|
|
||||||
checkpoint_path.unlink(missing_ok=True)
|
|
||||||
return
|
|
||||||
provider_record = raw.get("provider_state")
|
|
||||||
provider_state = (
|
|
||||||
None
|
|
||||||
if provider_record is None
|
|
||||||
else ProviderConversationState.from_private_record(provider_record)
|
|
||||||
)
|
|
||||||
if provider_record is not None and provider_state is None:
|
|
||||||
raise ValueError("invalid checkpoint provider state")
|
|
||||||
session.metadata[_RUNTIME_CHECKPOINT_KEY] = cast(
|
|
||||||
dict[str, Any], raw["checkpoint"]
|
|
||||||
)
|
|
||||||
session.provider_state = provider_state
|
|
||||||
except FileNotFoundError:
|
|
||||||
return
|
|
||||||
except _RUNTIME_CHECKPOINT_DATA_ERRORS as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Ignoring invalid runtime checkpoint for session {}: {}",
|
|
||||||
session.key,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
# Atomic writes mean a malformed target cannot become valid later.
|
|
||||||
# Remove it once so future loads do not repeatedly parse and log it.
|
|
||||||
with suppress(OSError):
|
|
||||||
if checkpoint_path.is_file() and not checkpoint_path.is_symlink():
|
|
||||||
checkpoint_path.unlink()
|
|
||||||
|
|
||||||
def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
|
def _save_unlocked(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
path = self.get_session_path(session.key)
|
path = self.get_session_path(session.key)
|
||||||
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
tmp_path = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
||||||
@@ -1327,10 +1215,6 @@ class JsonlSessionStore:
|
|||||||
|
|
||||||
os.replace(tmp_path, path)
|
os.replace(tmp_path, path)
|
||||||
|
|
||||||
# The full record now contains the authoritative checkpoint state (or
|
|
||||||
# its removal), so an older volatile overlay is no longer needed.
|
|
||||||
self.get_runtime_checkpoint_path(session.key).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
if fsync:
|
if fsync:
|
||||||
with suppress(PermissionError):
|
with suppress(PermissionError):
|
||||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||||
@@ -1394,7 +1278,6 @@ class JsonlSessionStore:
|
|||||||
def _delete_unlocked(self, key: str) -> bool:
|
def _delete_unlocked(self, key: str) -> bool:
|
||||||
paths = [
|
paths = [
|
||||||
self.get_session_path(key),
|
self.get_session_path(key),
|
||||||
self.get_runtime_checkpoint_path(key),
|
|
||||||
self.get_legacy_lossy_path(key),
|
self.get_legacy_lossy_path(key),
|
||||||
self.get_legacy_session_path(key),
|
self.get_legacy_session_path(key),
|
||||||
]
|
]
|
||||||
@@ -1702,10 +1585,6 @@ class SessionManager:
|
|||||||
"""Get the collision-resistant workspace path for a session."""
|
"""Get the collision-resistant workspace path for a session."""
|
||||||
return self._jsonl_store.get_session_path(key)
|
return self._jsonl_store.get_session_path(key)
|
||||||
|
|
||||||
def _get_runtime_checkpoint_path(self, key: str) -> Path:
|
|
||||||
"""Get the private in-flight checkpoint path for a session."""
|
|
||||||
return self._jsonl_store.get_runtime_checkpoint_path(key)
|
|
||||||
|
|
||||||
def _get_legacy_lossy_path(self, key: str) -> Path:
|
def _get_legacy_lossy_path(self, key: str) -> Path:
|
||||||
"""Previous workspace session path using lossy ':' to '_' replacement."""
|
"""Previous workspace session path using lossy ':' to '_' replacement."""
|
||||||
return self._jsonl_store.get_legacy_lossy_path(key)
|
return self._jsonl_store.get_legacy_lossy_path(key)
|
||||||
@@ -1774,18 +1653,6 @@ class SessionManager:
|
|||||||
self._store.save(session, fsync=fsync)
|
self._store.save(session, fsync=fsync)
|
||||||
self._remember(session)
|
self._remember(session)
|
||||||
|
|
||||||
def save_runtime_checkpoint(self, session: Session) -> None:
|
|
||||||
"""Persist volatile recovery state without rewriting long history."""
|
|
||||||
if not session.policy.persist:
|
|
||||||
return
|
|
||||||
if self._store is self._jsonl_store:
|
|
||||||
self._jsonl_store.save_runtime_checkpoint(session)
|
|
||||||
self._remember(session)
|
|
||||||
return
|
|
||||||
# Third-party stores keep their existing all-or-nothing semantics until
|
|
||||||
# they opt into a dedicated checkpoint primitive.
|
|
||||||
self.save(session)
|
|
||||||
|
|
||||||
def rename_model_preset(self, old_name: str, new_name: str) -> int:
|
def rename_model_preset(self, old_name: str, new_name: str) -> int:
|
||||||
"""Rename a session-scoped model preset across durable and live sessions."""
|
"""Rename a session-scoped model preset across durable and live sessions."""
|
||||||
if old_name == new_name:
|
if old_name == new_name:
|
||||||
|
|||||||
@@ -1,939 +0,0 @@
|
|||||||
"""Durable, side-effect-safe recovery for interrupted WebUI turns.
|
|
||||||
|
|
||||||
The coordinator owns restart policy. AgentLoop only exposes checkpoint
|
|
||||||
materialization and an admission hook, so transport code never has to guess
|
|
||||||
whether an interrupted tool call is safe to replay.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import dataclasses
|
|
||||||
import json
|
|
||||||
from collections.abc import Iterable, Mapping
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Protocol, cast
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
RecoveryStateEvent,
|
|
||||||
SessionUpdatedEvent,
|
|
||||||
outbound_message_for_event,
|
|
||||||
)
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.session import turn_continuation
|
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
|
|
||||||
from nanobot.session.manager import Session, SessionManager
|
|
||||||
from nanobot.webui.metadata import WEBUI_TURN_METADATA_KEY
|
|
||||||
|
|
||||||
RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
|
||||||
PENDING_USER_TURN_KEY = "pending_user_turn"
|
|
||||||
RECOVERY_METADATA_KEY = "webui_recovery"
|
|
||||||
RECOVERY_INBOUND_METADATA_KEY = "_webui_recovery_id"
|
|
||||||
PENDING_FOLLOWUPS_KEY = "pending_user_followups"
|
|
||||||
PENDING_FOLLOWUP_ID_KEY = "_recovery_followup_id"
|
|
||||||
PROVIDER_STATE_CHECKPOINT_VERSION_KEY = "provider_state_checkpoint_version"
|
|
||||||
PROVIDER_STATE_CHECKPOINT_VERSION = "v1"
|
|
||||||
|
|
||||||
_RECOVERY_STATUSES = frozenset({"resuming", "awaiting_user", "recovered", "failed"})
|
|
||||||
_UNCERTAIN_TOOL_PHASES = frozenset({"awaiting_tools"})
|
|
||||||
_KNOWN_CHECKPOINT_PHASES = frozenset(
|
|
||||||
{"final_response", "tools_completed", "awaiting_tools", "error"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RecoveryActionError(ValueError):
|
|
||||||
"""A stale or malformed recovery action from an authenticated WebUI."""
|
|
||||||
|
|
||||||
def __init__(self, message: str, *, status: int = 400) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.status = status
|
|
||||||
|
|
||||||
|
|
||||||
class RecoveryAdmission(Protocol):
|
|
||||||
"""Narrow AgentLoop boundary for explicit recovery validation."""
|
|
||||||
|
|
||||||
async def admit(self, message: InboundMessage) -> bool: ...
|
|
||||||
|
|
||||||
def register_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None: ...
|
|
||||||
|
|
||||||
def unregister_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
def record_pending_followup(session: Session, message: InboundMessage) -> str | None:
|
|
||||||
"""Durably journal a WebUI follow-up before injecting it into a live turn."""
|
|
||||||
if message.channel != "websocket":
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
metadata_value: object = json.loads(json.dumps(message.metadata))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
logger.warning("Skipping non-serializable WebUI follow-up for recovery")
|
|
||||||
return None
|
|
||||||
if not isinstance(metadata_value, dict):
|
|
||||||
return None
|
|
||||||
metadata = cast(dict[str, Any], metadata_value)
|
|
||||||
existing_id = metadata.pop(PENDING_FOLLOWUP_ID_KEY, None)
|
|
||||||
followup_id = (
|
|
||||||
existing_id
|
|
||||||
if isinstance(existing_id, str) and existing_id
|
|
||||||
else uuid4().hex
|
|
||||||
)
|
|
||||||
records = _pending_followup_records(session)
|
|
||||||
if any(record.get("id") == followup_id for record in records):
|
|
||||||
return followup_id
|
|
||||||
records.append(
|
|
||||||
{
|
|
||||||
"id": followup_id,
|
|
||||||
"sender_id": message.sender_id,
|
|
||||||
"chat_id": message.chat_id,
|
|
||||||
"content": message.content,
|
|
||||||
"media": list(message.media or []),
|
|
||||||
"metadata": metadata,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
# This journal is the recovery source of truth, not a mirror of the
|
|
||||||
# bounded in-memory injection queue. A queued turn can receive more
|
|
||||||
# follow-ups than the live queue accepts; dropping older journal entries
|
|
||||||
# would make those acknowledged user messages unrecoverable after a
|
|
||||||
# gateway restart. Entries are removed only once their user rows are
|
|
||||||
# committed by ``acknowledge_pending_followups``.
|
|
||||||
session.metadata[PENDING_FOLLOWUPS_KEY] = records
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
return followup_id
|
|
||||||
|
|
||||||
|
|
||||||
def pending_followups(session: Session) -> list[InboundMessage]:
|
|
||||||
"""Decode still-unacknowledged follow-ups from durable session metadata."""
|
|
||||||
messages: list[InboundMessage] = []
|
|
||||||
for record in _pending_followup_records(session):
|
|
||||||
followup_id = cast(object, record.get("id"))
|
|
||||||
sender_id = cast(object, record.get("sender_id"))
|
|
||||||
chat_id = cast(object, record.get("chat_id"))
|
|
||||||
content = cast(object, record.get("content"))
|
|
||||||
metadata = cast(object, record.get("metadata"))
|
|
||||||
if (
|
|
||||||
not isinstance(followup_id, str)
|
|
||||||
or not followup_id
|
|
||||||
or not isinstance(sender_id, str)
|
|
||||||
or not sender_id
|
|
||||||
or not isinstance(chat_id, str)
|
|
||||||
or not chat_id
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if not isinstance(content, str) or not isinstance(metadata, dict):
|
|
||||||
continue
|
|
||||||
media_value = cast(object, record.get("media"))
|
|
||||||
media = (
|
|
||||||
[item for item in cast(list[object], media_value) if isinstance(item, str)]
|
|
||||||
if isinstance(media_value, list)
|
|
||||||
else []
|
|
||||||
)
|
|
||||||
messages.append(
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id=sender_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
content=content,
|
|
||||||
media=media,
|
|
||||||
metadata={**cast(dict[str, Any], metadata), PENDING_FOLLOWUP_ID_KEY: followup_id},
|
|
||||||
session_key_override=session.key,
|
|
||||||
require_existing_session=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return messages
|
|
||||||
|
|
||||||
|
|
||||||
def acknowledge_pending_followups(session: Session, followup_ids: Iterable[str]) -> None:
|
|
||||||
"""Remove journal entries whose user rows were committed to history."""
|
|
||||||
acknowledged = set(followup_ids)
|
|
||||||
if not acknowledged:
|
|
||||||
return
|
|
||||||
records = [record for record in _pending_followup_records(session) if record.get("id") not in acknowledged]
|
|
||||||
if records:
|
|
||||||
session.metadata[PENDING_FOLLOWUPS_KEY] = records
|
|
||||||
else:
|
|
||||||
session.metadata.pop(PENDING_FOLLOWUPS_KEY, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _pending_followup_records(session: Session) -> list[dict[str, Any]]:
|
|
||||||
raw = cast(object, session.metadata.get(PENDING_FOLLOWUPS_KEY))
|
|
||||||
if not isinstance(raw, list):
|
|
||||||
return []
|
|
||||||
values = cast(list[object], raw)
|
|
||||||
return [cast(dict[str, Any], value) for value in values if isinstance(value, dict)]
|
|
||||||
|
|
||||||
|
|
||||||
def _checkpoint_message_key(message: Mapping[str, Any]) -> tuple[Any, ...]:
|
|
||||||
return (
|
|
||||||
message.get("role"),
|
|
||||||
message.get("content"),
|
|
||||||
message.get("tool_call_id"),
|
|
||||||
message.get("name"),
|
|
||||||
message.get("tool_calls"),
|
|
||||||
message.get("reasoning_content"),
|
|
||||||
message.get("thinking_blocks"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _checkpoint_tool_call_ids(
|
|
||||||
value: object,
|
|
||||||
*,
|
|
||||||
result_rows: bool = False,
|
|
||||||
) -> list[str] | None:
|
|
||||||
"""Validate checkpoint tool rows and return their stable IDs."""
|
|
||||||
if not isinstance(value, list):
|
|
||||||
return None
|
|
||||||
ids: list[str] = []
|
|
||||||
for raw in cast(list[object], value):
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
return None
|
|
||||||
row = cast(dict[str, Any], raw)
|
|
||||||
id_key = "tool_call_id" if result_rows else "id"
|
|
||||||
call_id = cast(object, row.get(id_key))
|
|
||||||
if not isinstance(call_id, str) or not call_id:
|
|
||||||
return None
|
|
||||||
if result_rows:
|
|
||||||
if row.get("role") != "tool":
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
function_value = cast(object, row.get("function"))
|
|
||||||
if not isinstance(function_value, dict):
|
|
||||||
return None
|
|
||||||
function = cast(dict[str, Any], function_value)
|
|
||||||
name = cast(object, function.get("name"))
|
|
||||||
if not isinstance(name, str) or not name:
|
|
||||||
return None
|
|
||||||
ids.append(call_id)
|
|
||||||
return ids if len(ids) == len(set(ids)) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_checkpoint_is_well_formed(checkpoint: Mapping[str, Any]) -> bool:
|
|
||||||
"""Return whether a checkpoint is safe to offer for continuation.
|
|
||||||
|
|
||||||
Restoration stays tolerant so Dismiss can always clear corrupt state.
|
|
||||||
Continue is stricter: silently dropping a malformed tool result could make
|
|
||||||
the model repeat an external side effect.
|
|
||||||
"""
|
|
||||||
assistant_value = cast(object, checkpoint.get("assistant_message"))
|
|
||||||
if not isinstance(assistant_value, dict):
|
|
||||||
return False
|
|
||||||
assistant = cast(dict[str, Any], assistant_value)
|
|
||||||
if assistant.get("role") != "assistant":
|
|
||||||
return False
|
|
||||||
|
|
||||||
completed_ids = _checkpoint_tool_call_ids(
|
|
||||||
cast(object, checkpoint.get("completed_tool_results")),
|
|
||||||
result_rows=True,
|
|
||||||
)
|
|
||||||
pending_ids = _checkpoint_tool_call_ids(
|
|
||||||
cast(object, checkpoint.get("pending_tool_calls")),
|
|
||||||
)
|
|
||||||
if completed_ids is None or pending_ids is None:
|
|
||||||
return False
|
|
||||||
assistant_calls_value = cast(object, assistant.get("tool_calls"))
|
|
||||||
assistant_call_ids = (
|
|
||||||
[]
|
|
||||||
if assistant_calls_value is None
|
|
||||||
else _checkpoint_tool_call_ids(assistant_calls_value)
|
|
||||||
)
|
|
||||||
if assistant_call_ids is None:
|
|
||||||
return False
|
|
||||||
|
|
||||||
phase = checkpoint.get("phase")
|
|
||||||
if phase == "final_response":
|
|
||||||
content = cast(object, assistant.get("content"))
|
|
||||||
return (
|
|
||||||
isinstance(content, str)
|
|
||||||
and bool(content.strip())
|
|
||||||
and not assistant_call_ids
|
|
||||||
and not completed_ids
|
|
||||||
and not pending_ids
|
|
||||||
)
|
|
||||||
if phase == "awaiting_tools":
|
|
||||||
return (
|
|
||||||
bool(assistant_call_ids)
|
|
||||||
and not completed_ids
|
|
||||||
and len(assistant_call_ids) == len(pending_ids)
|
|
||||||
and set(assistant_call_ids) == set(pending_ids)
|
|
||||||
)
|
|
||||||
if phase == "tools_completed":
|
|
||||||
return (
|
|
||||||
bool(assistant_call_ids)
|
|
||||||
and not pending_ids
|
|
||||||
and len(assistant_call_ids) == len(completed_ids)
|
|
||||||
and set(assistant_call_ids) == set(completed_ids)
|
|
||||||
)
|
|
||||||
# Error checkpoints have no current producer contract. Treat legacy or
|
|
||||||
# future instances as review-only until their exact persisted shape is
|
|
||||||
# specified; guessing here could make a partial side effect repeat.
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def restore_runtime_checkpoint(session: Session) -> bool:
|
|
||||||
"""Materialize the durable checkpoint exactly once and clear it.
|
|
||||||
|
|
||||||
Pending tool calls become explicit interrupted tool results. They are
|
|
||||||
never executed here. Provider-native state is retained only for the two
|
|
||||||
checkpoint shapes known to be synchronized with persisted history.
|
|
||||||
"""
|
|
||||||
checkpoint = cast(object, session.metadata.get(RUNTIME_CHECKPOINT_KEY))
|
|
||||||
if not isinstance(checkpoint, dict):
|
|
||||||
return False
|
|
||||||
data = cast(dict[str, Any], checkpoint)
|
|
||||||
assistant = cast(object, data.get("assistant_message"))
|
|
||||||
completed_value = cast(object, data.get("completed_tool_results"))
|
|
||||||
pending_value = cast(object, data.get("pending_tool_calls"))
|
|
||||||
completed = cast(list[object], completed_value) if isinstance(completed_value, list) else []
|
|
||||||
pending = cast(list[object], pending_value) if isinstance(pending_value, list) else []
|
|
||||||
|
|
||||||
restored: list[dict[str, Any]] = []
|
|
||||||
if isinstance(assistant, dict):
|
|
||||||
assistant_row = cast(dict[str, Any], assistant)
|
|
||||||
else:
|
|
||||||
assistant_row = {}
|
|
||||||
if assistant_row.get("role") == "assistant":
|
|
||||||
row = dict(assistant_row)
|
|
||||||
row.setdefault("timestamp", datetime.now().isoformat())
|
|
||||||
restored.append(row)
|
|
||||||
for value in completed:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
continue
|
|
||||||
tool_result = cast(dict[str, Any], value)
|
|
||||||
if tool_result.get("role") != "tool":
|
|
||||||
continue
|
|
||||||
row = dict(tool_result)
|
|
||||||
row.setdefault("timestamp", datetime.now().isoformat())
|
|
||||||
restored.append(row)
|
|
||||||
for value in pending:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
continue
|
|
||||||
tool_call = cast(dict[str, Any], value)
|
|
||||||
tool_call_id = tool_call.get("id")
|
|
||||||
function_value = cast(object, tool_call.get("function"))
|
|
||||||
if not isinstance(tool_call_id, str) or not tool_call_id:
|
|
||||||
continue
|
|
||||||
function = (
|
|
||||||
cast(dict[str, Any], function_value)
|
|
||||||
if isinstance(function_value, dict)
|
|
||||||
else {}
|
|
||||||
)
|
|
||||||
name = function.get("name")
|
|
||||||
restored.append(
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": tool_call_id,
|
|
||||||
"name": name if isinstance(name, str) and name else "tool",
|
|
||||||
"content": "Error: Task interrupted before this tool finished.",
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"_recovery_interrupted": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
overlap = 0
|
|
||||||
for size in range(min(len(session.messages), len(restored)), 0, -1):
|
|
||||||
if all(
|
|
||||||
_checkpoint_message_key(left) == _checkpoint_message_key(right)
|
|
||||||
for left, right in zip(session.messages[-size:], restored[:size])
|
|
||||||
):
|
|
||||||
overlap = size
|
|
||||||
break
|
|
||||||
session.messages.extend(restored[overlap:])
|
|
||||||
|
|
||||||
assistant_data = cast(dict[str, Any], assistant) if isinstance(assistant, dict) else None
|
|
||||||
synchronized = (
|
|
||||||
data.get(PROVIDER_STATE_CHECKPOINT_VERSION_KEY)
|
|
||||||
== PROVIDER_STATE_CHECKPOINT_VERSION
|
|
||||||
)
|
|
||||||
phase = data.get("phase")
|
|
||||||
exact_final = (
|
|
||||||
phase == "final_response"
|
|
||||||
and assistant_data is not None
|
|
||||||
and assistant_data.get("role") == "assistant"
|
|
||||||
and not data.get("completed_tool_results")
|
|
||||||
and not data.get("pending_tool_calls")
|
|
||||||
)
|
|
||||||
exact_tools = (
|
|
||||||
phase == "tools_completed"
|
|
||||||
and assistant_data is not None
|
|
||||||
and assistant_data.get("role") == "assistant"
|
|
||||||
and not data.get("pending_tool_calls")
|
|
||||||
)
|
|
||||||
if not (synchronized and (exact_final or exact_tools)):
|
|
||||||
session.provider_state = None
|
|
||||||
|
|
||||||
session.metadata.pop(PENDING_USER_TURN_KEY, None)
|
|
||||||
session.metadata.pop(RUNTIME_CHECKPOINT_KEY, None)
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _discard_runtime_checkpoint(session: Session) -> bool:
|
|
||||||
"""Drop checkpoint state that cannot be projected into valid history."""
|
|
||||||
if RUNTIME_CHECKPOINT_KEY not in session.metadata:
|
|
||||||
return False
|
|
||||||
session.metadata.pop(RUNTIME_CHECKPOINT_KEY, None)
|
|
||||||
session.provider_state = None
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def restore_pending_interruption(session: Session, *, superseded: bool = False) -> bool:
|
|
||||||
"""Close a persisted user-only turn without pretending it was answered."""
|
|
||||||
if not session.metadata.get(PENDING_USER_TURN_KEY):
|
|
||||||
return False
|
|
||||||
if session.messages and session.messages[-1].get("role") == "user":
|
|
||||||
content = (
|
|
||||||
"Task recovery was superseded by a newer message."
|
|
||||||
if superseded
|
|
||||||
else "Error: Task interrupted before a response was generated."
|
|
||||||
)
|
|
||||||
session.messages.append(
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": content,
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"_recovery_interrupted": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
session.provider_state = None
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
session.metadata.pop(PENDING_USER_TURN_KEY, None)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def append_recovery_interruption(session: Session, *, superseded: bool = False) -> None:
|
|
||||||
"""Close a restored partial turn whose last durable row is not the user message."""
|
|
||||||
if session.messages and session.messages[-1].get("_recovery_interrupted") is True:
|
|
||||||
return
|
|
||||||
session.messages.append(
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": (
|
|
||||||
"Task recovery was superseded by a newer message."
|
|
||||||
if superseded
|
|
||||||
else "Error: Task recovery was interrupted before completion."
|
|
||||||
),
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"_recovery_interrupted": True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
session.provider_state = None
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
|
|
||||||
|
|
||||||
def recovery_state_from_metadata(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
||||||
"""Return a sanitized recovery state suitable for the WebSocket wire."""
|
|
||||||
value = metadata.get(RECOVERY_METADATA_KEY) if metadata else None
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return None
|
|
||||||
state = cast(dict[str, Any], value)
|
|
||||||
status = state.get("status")
|
|
||||||
recovery_id = state.get("recovery_id")
|
|
||||||
if status not in _RECOVERY_STATUSES or not isinstance(recovery_id, str):
|
|
||||||
return None
|
|
||||||
payload: dict[str, Any] = {"status": status, "recovery_id": recovery_id}
|
|
||||||
reason = state.get("reason")
|
|
||||||
if isinstance(reason, str) and reason:
|
|
||||||
payload["reason"] = reason
|
|
||||||
attempts = state.get("attempts")
|
|
||||||
if isinstance(attempts, int) and attempts >= 0:
|
|
||||||
payload["attempts"] = attempts
|
|
||||||
can_continue = state.get("can_continue")
|
|
||||||
if isinstance(can_continue, bool):
|
|
||||||
payload["can_continue"] = can_continue
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(slots=True)
|
|
||||||
class RecoveryCoordinator:
|
|
||||||
"""Classify, announce, and gate durable WebUI turn recovery."""
|
|
||||||
|
|
||||||
sessions: SessionManager
|
|
||||||
bus: MessageBus
|
|
||||||
unified_session: bool = False
|
|
||||||
_active_recovery_tasks: dict[str, asyncio.Task[Any]] = dataclasses.field(
|
|
||||||
default_factory=dict,
|
|
||||||
init=False,
|
|
||||||
repr=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None:
|
|
||||||
"""Track the task that owns an explicit recovery continuation."""
|
|
||||||
self._active_recovery_tasks[session_key] = task
|
|
||||||
|
|
||||||
def unregister_recovery_task(self, session_key: str, task: asyncio.Task[Any]) -> None:
|
|
||||||
"""Drop a recovery task without removing a newer task for the same session."""
|
|
||||||
if self._active_recovery_tasks.get(session_key) is task:
|
|
||||||
self._active_recovery_tasks.pop(session_key, None)
|
|
||||||
|
|
||||||
async def _cancel_active_recovery(self, session_key: str) -> None:
|
|
||||||
"""Stop an explicit continuation before accepting newer user input."""
|
|
||||||
task = self._active_recovery_tasks.get(session_key)
|
|
||||||
if task is None or task is asyncio.current_task() or task.done():
|
|
||||||
return
|
|
||||||
task.cancel()
|
|
||||||
# AgentLoop's cancellation path materializes any partial checkpoint and
|
|
||||||
# releases its pending queue. Wait for that ownership to be released
|
|
||||||
# before the newer message is routed.
|
|
||||||
await asyncio.gather(task, return_exceptions=True)
|
|
||||||
|
|
||||||
async def scan(self) -> None:
|
|
||||||
"""Recover every interrupted WebUI session once at gateway startup."""
|
|
||||||
for key in self._recovery_candidates():
|
|
||||||
metadata_payload = self.sessions.read_session_metadata(key)
|
|
||||||
raw_metadata = metadata_payload.get("metadata") if metadata_payload else None
|
|
||||||
metadata = cast(dict[str, Any], raw_metadata) if isinstance(raw_metadata, dict) else {}
|
|
||||||
route = self._websocket_route_for(key, metadata)
|
|
||||||
if route is None:
|
|
||||||
continue
|
|
||||||
unfinished = self._has_unfinished_webui_transcript(key)
|
|
||||||
if not self._needs_recovery(metadata) and not unfinished:
|
|
||||||
continue
|
|
||||||
session = self.sessions.get_or_create(key)
|
|
||||||
try:
|
|
||||||
await self._recover_session(session, route[1])
|
|
||||||
await self._requeue_pending_followups(session)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("failed to recover interrupted WebUI session {}", session.key)
|
|
||||||
state = recovery_state_from_metadata(session.metadata)
|
|
||||||
failed = self._set_state(
|
|
||||||
session,
|
|
||||||
status="failed",
|
|
||||||
recovery_id=cast(str, state["recovery_id"]) if state else uuid4().hex,
|
|
||||||
attempts=cast(int, state.get("attempts", 0)) if state else 0,
|
|
||||||
reason="recovery_failed",
|
|
||||||
can_continue=False,
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(route[1], failed)
|
|
||||||
|
|
||||||
def _recovery_candidates(self) -> list[str]:
|
|
||||||
"""Discover canonical and transcript-only WebUI sessions cheaply."""
|
|
||||||
candidates = dict.fromkeys(
|
|
||||||
key
|
|
||||||
for item in self.sessions.list_sessions()
|
|
||||||
if isinstance((key := item.get("key")), str)
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
# Imported lazily because the sidebar index also projects recovery
|
|
||||||
# metadata. The index is the owner of transcript-only discovery;
|
|
||||||
# duplicating its filename and migration rules here would drift.
|
|
||||||
from nanobot.webui.session_list_index import list_webui_sessions
|
|
||||||
|
|
||||||
for item in list_webui_sessions(self.sessions):
|
|
||||||
key = item.get("key")
|
|
||||||
if isinstance(key, str):
|
|
||||||
candidates.setdefault(key, None)
|
|
||||||
except Exception:
|
|
||||||
# Canonical checkpoint recovery remains available even if the
|
|
||||||
# optional display-history index is corrupt or unavailable.
|
|
||||||
logger.exception("failed to discover transcript-only WebUI sessions")
|
|
||||||
return list(candidates)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _needs_recovery(metadata: Mapping[str, Any]) -> bool:
|
|
||||||
if metadata.get(PENDING_USER_TURN_KEY) is True:
|
|
||||||
return True
|
|
||||||
if isinstance(metadata.get(RUNTIME_CHECKPOINT_KEY), dict):
|
|
||||||
return True
|
|
||||||
followups = metadata.get(PENDING_FOLLOWUPS_KEY)
|
|
||||||
if isinstance(followups, list) and len(cast(list[object], followups)) > 0:
|
|
||||||
return True
|
|
||||||
state = recovery_state_from_metadata(metadata)
|
|
||||||
return bool(state and state["status"] in {"resuming", "awaiting_user", "failed"})
|
|
||||||
|
|
||||||
async def admit(self, message: InboundMessage) -> bool:
|
|
||||||
"""Reject stale queued recoveries and let new user input supersede them."""
|
|
||||||
recovery_id = message.metadata.get(RECOVERY_INBOUND_METADATA_KEY)
|
|
||||||
if isinstance(recovery_id, str):
|
|
||||||
session = self.sessions.get_or_create(message.session_key)
|
|
||||||
state = recovery_state_from_metadata(session.metadata)
|
|
||||||
return bool(
|
|
||||||
state
|
|
||||||
and state["status"] == "resuming"
|
|
||||||
and state["recovery_id"] == recovery_id
|
|
||||||
)
|
|
||||||
if message.channel != "websocket":
|
|
||||||
return True
|
|
||||||
session = self.sessions.get_or_create(message.session_key)
|
|
||||||
state = recovery_state_from_metadata(session.metadata)
|
|
||||||
if state and state["status"] in {"resuming", "awaiting_user", "failed"}:
|
|
||||||
await self._cancel_active_recovery(message.session_key)
|
|
||||||
restore_runtime_checkpoint(session)
|
|
||||||
if not restore_pending_interruption(session, superseded=True):
|
|
||||||
append_recovery_interruption(session, superseded=True)
|
|
||||||
recovered = self._set_state(
|
|
||||||
session,
|
|
||||||
status="recovered",
|
|
||||||
recovery_id=cast(str, state["recovery_id"]),
|
|
||||||
attempts=cast(int, state.get("attempts", 0)),
|
|
||||||
reason="superseded",
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(message.chat_id, recovered)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def turn_completed(self, session_key: str) -> None:
|
|
||||||
"""Resolve a resuming state after the recovered turn commits."""
|
|
||||||
session = self.sessions.get_or_create(session_key)
|
|
||||||
state = recovery_state_from_metadata(session.metadata)
|
|
||||||
if not state or state["status"] != "resuming":
|
|
||||||
return
|
|
||||||
route = self._websocket_route(session)
|
|
||||||
if route is None:
|
|
||||||
return
|
|
||||||
recovered = self._set_state(
|
|
||||||
session,
|
|
||||||
status="recovered",
|
|
||||||
recovery_id=cast(str, state["recovery_id"]),
|
|
||||||
attempts=cast(int, state.get("attempts", 0)),
|
|
||||||
reason="continued",
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(route[1], recovered)
|
|
||||||
|
|
||||||
async def handle_action(self, action: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
"""Apply an authenticated continue/dismiss operation."""
|
|
||||||
chat_id = payload.get("chat_id")
|
|
||||||
recovery_id = payload.get("recovery_id")
|
|
||||||
if not isinstance(chat_id, str) or not chat_id:
|
|
||||||
raise RecoveryActionError("missing chat_id")
|
|
||||||
if not isinstance(recovery_id, str) or not recovery_id:
|
|
||||||
raise RecoveryActionError("missing recovery_id")
|
|
||||||
session = self.sessions.get_or_create(self._session_key(chat_id))
|
|
||||||
state = recovery_state_from_metadata(session.metadata)
|
|
||||||
if not state or state["recovery_id"] != recovery_id:
|
|
||||||
raise RecoveryActionError("recovery state is stale", status=409)
|
|
||||||
|
|
||||||
if action == "dismiss":
|
|
||||||
restore_runtime_checkpoint(session)
|
|
||||||
restore_pending_interruption(session)
|
|
||||||
next_state = self._set_state(
|
|
||||||
session,
|
|
||||||
status="recovered",
|
|
||||||
recovery_id=recovery_id,
|
|
||||||
attempts=cast(int, state.get("attempts", 0)),
|
|
||||||
reason="dismissed",
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, next_state)
|
|
||||||
return next_state
|
|
||||||
if action != "continue":
|
|
||||||
raise RecoveryActionError("unknown recovery action")
|
|
||||||
if state["status"] not in {"awaiting_user", "failed"}:
|
|
||||||
raise RecoveryActionError("recovery is not waiting for confirmation", status=409)
|
|
||||||
if state.get("can_continue") is False:
|
|
||||||
raise RecoveryActionError("recovery context is unavailable", status=409)
|
|
||||||
next_state = self._set_state(
|
|
||||||
session,
|
|
||||||
status="resuming",
|
|
||||||
recovery_id=recovery_id,
|
|
||||||
attempts=cast(int, state.get("attempts", 0)) + 1,
|
|
||||||
reason="user_confirmed",
|
|
||||||
resume_message_count=len(session.messages),
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, next_state)
|
|
||||||
await self._queue_continuation(session, chat_id, next_state)
|
|
||||||
return next_state
|
|
||||||
|
|
||||||
async def _recover_session(self, session: Session, chat_id: str) -> None:
|
|
||||||
checkpoint_value = cast(object, session.metadata.get(RUNTIME_CHECKPOINT_KEY))
|
|
||||||
checkpoint = (
|
|
||||||
cast(dict[str, Any], checkpoint_value)
|
|
||||||
if isinstance(checkpoint_value, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
pending = session.metadata.get(PENDING_USER_TURN_KEY) is True
|
|
||||||
state = recovery_state_from_metadata(session.metadata)
|
|
||||||
if not pending and checkpoint is None:
|
|
||||||
if state and state["status"] == "resuming":
|
|
||||||
resume_count = self._resume_message_count(session)
|
|
||||||
if resume_count is not None and len(session.messages) > resume_count:
|
|
||||||
next_state = self._set_state(
|
|
||||||
session,
|
|
||||||
status="recovered",
|
|
||||||
recovery_id=cast(str, state["recovery_id"]),
|
|
||||||
attempts=cast(int, state.get("attempts", 0)),
|
|
||||||
reason="committed",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
next_state = self._set_state(
|
|
||||||
session,
|
|
||||||
status="awaiting_user",
|
|
||||||
recovery_id=cast(str, state["recovery_id"]),
|
|
||||||
attempts=cast(int, state.get("attempts", 1)),
|
|
||||||
reason="loop_guard",
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, next_state)
|
|
||||||
elif self._has_unfinished_webui_transcript(session.key):
|
|
||||||
# A normal last-client shutdown can materialize the checkpoint
|
|
||||||
# before the process exits. In that path there is no pending
|
|
||||||
# marker left to classify, but the append-only transcript still
|
|
||||||
# contains an activity row without a turn_end. Treat it as an
|
|
||||||
# interrupted turn instead of letting the UI resurrect it as a
|
|
||||||
# forever-running spinner.
|
|
||||||
can_continue = self._has_saved_continuation_context(session)
|
|
||||||
waiting = self._set_state(
|
|
||||||
session,
|
|
||||||
status="awaiting_user",
|
|
||||||
recovery_id=uuid4().hex,
|
|
||||||
attempts=0,
|
|
||||||
reason=(
|
|
||||||
"interrupted_with_saved_context"
|
|
||||||
if can_continue
|
|
||||||
else "interrupted_without_checkpoint"
|
|
||||||
),
|
|
||||||
can_continue=can_continue,
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, waiting)
|
|
||||||
return
|
|
||||||
if state and state["status"] in {"awaiting_user", "failed"}:
|
|
||||||
await self._publish(chat_id, state)
|
|
||||||
return
|
|
||||||
if state and state["status"] == "resuming":
|
|
||||||
restore_runtime_checkpoint(session)
|
|
||||||
restore_pending_interruption(session)
|
|
||||||
waiting = self._set_state(
|
|
||||||
session,
|
|
||||||
status="awaiting_user",
|
|
||||||
recovery_id=cast(str, state["recovery_id"]),
|
|
||||||
attempts=cast(int, state.get("attempts", 1)),
|
|
||||||
reason="loop_guard",
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, waiting)
|
|
||||||
return
|
|
||||||
|
|
||||||
recovery_id = uuid4().hex
|
|
||||||
phase = checkpoint.get("phase") if checkpoint is not None else None
|
|
||||||
pending_calls = checkpoint.get("pending_tool_calls") if checkpoint is not None else None
|
|
||||||
if checkpoint is not None and phase not in _KNOWN_CHECKPOINT_PHASES:
|
|
||||||
_discard_runtime_checkpoint(session)
|
|
||||||
restore_pending_interruption(session)
|
|
||||||
waiting = self._set_state(
|
|
||||||
session,
|
|
||||||
status="awaiting_user",
|
|
||||||
recovery_id=recovery_id,
|
|
||||||
attempts=0,
|
|
||||||
reason="checkpoint_unknown",
|
|
||||||
can_continue=False,
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, waiting)
|
|
||||||
return
|
|
||||||
if checkpoint is not None and not _runtime_checkpoint_is_well_formed(checkpoint):
|
|
||||||
_discard_runtime_checkpoint(session)
|
|
||||||
restore_pending_interruption(session)
|
|
||||||
waiting = self._set_state(
|
|
||||||
session,
|
|
||||||
status="awaiting_user",
|
|
||||||
recovery_id=recovery_id,
|
|
||||||
attempts=0,
|
|
||||||
reason="checkpoint_invalid",
|
|
||||||
can_continue=False,
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, waiting)
|
|
||||||
return
|
|
||||||
if phase == "final_response":
|
|
||||||
restore_runtime_checkpoint(session)
|
|
||||||
recovered = self._set_state(
|
|
||||||
session,
|
|
||||||
status="recovered",
|
|
||||||
recovery_id=recovery_id,
|
|
||||||
attempts=0,
|
|
||||||
reason="answer_restored",
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, recovered)
|
|
||||||
return
|
|
||||||
if phase in _UNCERTAIN_TOOL_PHASES or pending_calls:
|
|
||||||
restore_runtime_checkpoint(session)
|
|
||||||
waiting = self._set_state(
|
|
||||||
session,
|
|
||||||
status="awaiting_user",
|
|
||||||
recovery_id=recovery_id,
|
|
||||||
attempts=0,
|
|
||||||
reason="tool_state_unknown",
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, waiting)
|
|
||||||
return
|
|
||||||
# A gateway restart is a lifecycle boundary. Never enqueue model work
|
|
||||||
# implicitly: even a synchronized checkpoint may sit next to an
|
|
||||||
# external side effect that the user should review first. The final
|
|
||||||
# answer path above only restores persisted output; it never executes.
|
|
||||||
restore_runtime_checkpoint(session)
|
|
||||||
waiting = self._set_state(
|
|
||||||
session,
|
|
||||||
status="awaiting_user",
|
|
||||||
recovery_id=recovery_id,
|
|
||||||
attempts=0,
|
|
||||||
reason="restart_requires_confirmation",
|
|
||||||
)
|
|
||||||
self.sessions.save(session)
|
|
||||||
await self._publish(chat_id, waiting)
|
|
||||||
|
|
||||||
async def _queue_continuation(
|
|
||||||
self,
|
|
||||||
session: Session,
|
|
||||||
chat_id: str,
|
|
||||||
state: Mapping[str, Any],
|
|
||||||
) -> None:
|
|
||||||
recovery_id = cast(str, state["recovery_id"])
|
|
||||||
await self.bus.publish_inbound(
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="system:recovery",
|
|
||||||
chat_id=chat_id,
|
|
||||||
content=(
|
|
||||||
"Continue the interrupted request from the saved conversation context. "
|
|
||||||
"Do not repeat completed work or mention the restart unless it affects the answer."
|
|
||||||
),
|
|
||||||
metadata={
|
|
||||||
"webui": True,
|
|
||||||
"_wants_stream": True,
|
|
||||||
WEBUI_TURN_METADATA_KEY: f"recovery:{recovery_id}",
|
|
||||||
RECOVERY_INBOUND_METADATA_KEY: recovery_id,
|
|
||||||
turn_continuation.INTERNAL_CONTINUATION_META: True,
|
|
||||||
turn_continuation.SKIP_USER_PERSIST_META: True,
|
|
||||||
},
|
|
||||||
session_key_override=session.key,
|
|
||||||
require_existing_session=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _requeue_pending_followups(self, session: Session) -> None:
|
|
||||||
"""Return durable live-turn follow-ups to the bus after a restart."""
|
|
||||||
for message in pending_followups(session):
|
|
||||||
await self.bus.publish_inbound(message)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _resume_message_count(session: Session) -> int | None:
|
|
||||||
raw_value = cast(object, session.metadata.get(RECOVERY_METADATA_KEY))
|
|
||||||
value = cast(dict[str, Any], raw_value) if isinstance(raw_value, dict) else None
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
count = value.get("resume_message_count")
|
|
||||||
return count if isinstance(count, int) and count >= 0 else None
|
|
||||||
|
|
||||||
async def _publish(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
state: Mapping[str, Any],
|
|
||||||
) -> None:
|
|
||||||
"""Publish the recovery state and invalidate its sidebar projection."""
|
|
||||||
await self.bus.publish_outbound(
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id=chat_id,
|
|
||||||
event=RecoveryStateEvent(
|
|
||||||
status=cast(str, state["status"]),
|
|
||||||
recovery_id=cast(str, state["recovery_id"]),
|
|
||||||
reason=cast(str | None, state.get("reason")),
|
|
||||||
attempts=cast(int, state.get("attempts", 0)),
|
|
||||||
can_continue=cast(bool | None, state.get("can_continue")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await self.bus.publish_outbound(
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id=chat_id,
|
|
||||||
event=SessionUpdatedEvent(scope="thread"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _set_state(
|
|
||||||
session: Session,
|
|
||||||
*,
|
|
||||||
status: str,
|
|
||||||
recovery_id: str,
|
|
||||||
attempts: int,
|
|
||||||
reason: str,
|
|
||||||
resume_message_count: int | None = None,
|
|
||||||
can_continue: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
state = {
|
|
||||||
"status": status,
|
|
||||||
"recovery_id": recovery_id,
|
|
||||||
"attempts": max(0, attempts),
|
|
||||||
"reason": reason,
|
|
||||||
"updated_at": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
if not can_continue:
|
|
||||||
state["can_continue"] = False
|
|
||||||
if resume_message_count is not None:
|
|
||||||
state["resume_message_count"] = max(0, resume_message_count)
|
|
||||||
session.metadata[RECOVERY_METADATA_KEY] = state
|
|
||||||
session.updated_at = datetime.now()
|
|
||||||
return state
|
|
||||||
|
|
||||||
def _session_key(self, chat_id: str) -> str:
|
|
||||||
return UNIFIED_SESSION_KEY if self.unified_session else f"websocket:{chat_id}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _has_unfinished_webui_transcript(session_key: str) -> bool:
|
|
||||||
"""Detect a stale WebUI activity tail after an unclean gateway stop.
|
|
||||||
|
|
||||||
The transcript is intentionally consulted only as a last-resort signal:
|
|
||||||
a durable pending turn or runtime checkpoint always takes precedence.
|
|
||||||
This keeps browser disconnects harmless while preventing a materialized
|
|
||||||
partial turn from being presented as active forever after a restart.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from nanobot.webui.transcript import has_unfinished_transcript_tail
|
|
||||||
|
|
||||||
return has_unfinished_transcript_tail(session_key)
|
|
||||||
except (OSError, ValueError, TypeError):
|
|
||||||
# Recovery must fail closed if the optional display transcript is
|
|
||||||
# corrupt or unavailable; the normal checkpoint path still applies.
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _has_saved_continuation_context(session: Session) -> bool:
|
|
||||||
"""Whether an interrupted turn left model-visible context to continue from."""
|
|
||||||
last_user = next(
|
|
||||||
(
|
|
||||||
index
|
|
||||||
for index in range(len(session.messages) - 1, -1, -1)
|
|
||||||
if session.messages[index].get("role") == "user"
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if last_user is None:
|
|
||||||
return False
|
|
||||||
tail = session.messages[last_user + 1 :]
|
|
||||||
return bool(tail) and (
|
|
||||||
tail[-1].get("role") == "tool"
|
|
||||||
or any(message.get("_recovery_interrupted") is True for message in tail)
|
|
||||||
or any(
|
|
||||||
message.get("role") == "assistant" and bool(message.get("tool_calls"))
|
|
||||||
for message in tail
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _websocket_route(session: Session) -> tuple[str, str] | None:
|
|
||||||
return RecoveryCoordinator._websocket_route_for(session.key, session.metadata)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _websocket_route_for(
|
|
||||||
session_key: str,
|
|
||||||
metadata: Mapping[str, Any],
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
if session_key.startswith("websocket:"):
|
|
||||||
chat_id = session_key.split(":", 1)[1]
|
|
||||||
return ("websocket", chat_id) if chat_id else None
|
|
||||||
if session_key == UNIFIED_SESSION_KEY:
|
|
||||||
route = last_channel_from_metadata(metadata)
|
|
||||||
if route and route[0] == "websocket":
|
|
||||||
return route
|
|
||||||
return None
|
|
||||||
@@ -43,7 +43,6 @@ from nanobot.runtime_context import public_history_message
|
|||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.session.recovery import RecoveryCoordinator
|
|
||||||
from nanobot.session.session_handles import session_handle_for_name
|
from nanobot.session.session_handles import session_handle_for_name
|
||||||
from nanobot.session.session_messages import (
|
from nanobot.session.session_messages import (
|
||||||
SessionMessageEnvelope,
|
SessionMessageEnvelope,
|
||||||
@@ -512,7 +511,6 @@ class WebuiTurnCoordinator:
|
|||||||
bus: MessageBus
|
bus: MessageBus
|
||||||
sessions: SessionManager
|
sessions: SessionManager
|
||||||
schedule_background: Callable[[Awaitable[None]], None]
|
schedule_background: Callable[[Awaitable[None]], None]
|
||||||
recovery: RecoveryCoordinator | None = None
|
|
||||||
|
|
||||||
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
|
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
|
||||||
"""Subscribe this coordinator to runtime events."""
|
"""Subscribe this coordinator to runtime events."""
|
||||||
@@ -656,8 +654,6 @@ class WebuiTurnCoordinator:
|
|||||||
event.runtime.context_window_tokens if event.runtime is not None else None
|
event.runtime.context_window_tokens if event.runtime is not None else None
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if self.recovery is not None:
|
|
||||||
await self.recovery.turn_completed(event.context.session_key)
|
|
||||||
self._schedule_title_update_from_event(event)
|
self._schedule_title_update_from_event(event)
|
||||||
|
|
||||||
async def _handle_goal_state_changed(self, event: GoalStateChanged) -> None:
|
async def _handle_goal_state_changed(self, event: GoalStateChanged) -> None:
|
||||||
@@ -689,6 +685,15 @@ class WebuiTurnCoordinator:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def publish_run_status(
|
||||||
|
self,
|
||||||
|
msg: InboundMessage,
|
||||||
|
status: str,
|
||||||
|
*,
|
||||||
|
started_at: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
await publish_turn_run_status(self.bus, msg, status, started_at=started_at)
|
||||||
|
|
||||||
async def handle_turn_end(
|
async def handle_turn_end(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ def build_gateway_services(
|
|||||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||||
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
recovery_action: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None = None,
|
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
settings = WebUISettingsServices.create(
|
settings = WebUISettingsServices.create(
|
||||||
@@ -132,7 +131,6 @@ def build_gateway_services(
|
|||||||
mcp_runtime_status=mcp_runtime_status,
|
mcp_runtime_status=mcp_runtime_status,
|
||||||
mcp_reload=mcp_reload,
|
mcp_reload=mcp_reload,
|
||||||
skill_state_action=skill_state_action,
|
skill_state_action=skill_state_action,
|
||||||
recovery_action=recovery_action,
|
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ class GatewayTokenStore:
|
|||||||
self.api_tokens[token_value] = expiry
|
self.api_tokens[token_value] = expiry
|
||||||
return token_value
|
return token_value
|
||||||
|
|
||||||
|
def take_issued_token_if_valid(self, token_value: str | None) -> bool:
|
||||||
|
return self.take_issued_token_audience(token_value) is not None
|
||||||
|
|
||||||
def take_issued_token_audience(
|
def take_issued_token_audience(
|
||||||
self,
|
self,
|
||||||
token_value: str | None,
|
token_value: str | None,
|
||||||
|
|||||||
@@ -31,9 +31,8 @@ from nanobot.session.manager import (
|
|||||||
_metadata_title, # pyright: ignore[reportPrivateUsage]
|
_metadata_title, # pyright: ignore[reportPrivateUsage]
|
||||||
)
|
)
|
||||||
from nanobot.session.model_selection import model_preset_from_metadata
|
from nanobot.session.model_selection import model_preset_from_metadata
|
||||||
from nanobot.session.recovery import recovery_state_from_metadata
|
|
||||||
|
|
||||||
_INDEX_VERSION = 8
|
_INDEX_VERSION = 7
|
||||||
_INDEX_FILENAME = ".webui_session_index.json"
|
_INDEX_FILENAME = ".webui_session_index.json"
|
||||||
_MODEL_PRESET_FIELD = "model_preset"
|
_MODEL_PRESET_FIELD = "model_preset"
|
||||||
_ROW_SOURCE_FIELD = "_source"
|
_ROW_SOURCE_FIELD = "_source"
|
||||||
@@ -246,7 +245,6 @@ def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dic
|
|||||||
"title": row.get("title", ""),
|
"title": row.get("title", ""),
|
||||||
"preview": row.get("preview", ""),
|
"preview": row.get("preview", ""),
|
||||||
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
|
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
|
||||||
"recovery_state": row.get("recovery_state"),
|
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
|
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
|
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
|
||||||
"path": str(path),
|
"path": str(path),
|
||||||
@@ -487,7 +485,6 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
|
|||||||
"title": _metadata_title(session.metadata),
|
"title": _metadata_title(session.metadata),
|
||||||
"preview": _preview_from_messages(session.messages),
|
"preview": _preview_from_messages(session.messages),
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
||||||
"recovery_state": recovery_state_from_metadata(session.metadata),
|
|
||||||
**_indexed_workspace_scope_fields(session.metadata),
|
**_indexed_workspace_scope_fields(session.metadata),
|
||||||
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
@@ -604,7 +601,6 @@ def _scan_transcript_row(
|
|||||||
"title": "",
|
"title": "",
|
||||||
"preview": preview or fallback_preview,
|
"preview": preview or fallback_preview,
|
||||||
_MODEL_PRESET_FIELD: None,
|
_MODEL_PRESET_FIELD: None,
|
||||||
"recovery_state": None,
|
|
||||||
**_indexed_workspace_scope_fields({}),
|
**_indexed_workspace_scope_fields({}),
|
||||||
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
|
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
|
||||||
"file": stem,
|
"file": stem,
|
||||||
@@ -691,7 +687,6 @@ def _scan_session_row(
|
|||||||
"title": _metadata_title(metadata),
|
"title": _metadata_title(metadata),
|
||||||
"preview": preview or fallback_preview,
|
"preview": preview or fallback_preview,
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
|
||||||
"recovery_state": recovery_state_from_metadata(metadata),
|
|
||||||
**_indexed_workspace_scope_fields(metadata),
|
**_indexed_workspace_scope_fields(metadata),
|
||||||
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from nanobot.webui.nanobot_features_api import (
|
|||||||
nanobot_features_payload,
|
nanobot_features_payload,
|
||||||
)
|
)
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
|
WebUISettingsError,
|
||||||
complete_oauth_provider,
|
complete_oauth_provider,
|
||||||
create_model_configuration,
|
create_model_configuration,
|
||||||
create_provider_settings,
|
create_provider_settings,
|
||||||
@@ -489,6 +490,17 @@ class WebUISettingsRouter:
|
|||||||
lambda: request_image_generation_reload(self.bus),
|
lambda: request_image_generation_reload(self.bus),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _apply_image_generation_runtime_change(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
updated, restart_cleared = (
|
||||||
|
await self._apply_image_generation_runtime_change_result(payload)
|
||||||
|
)
|
||||||
|
if restart_cleared:
|
||||||
|
self._restart_sections.discard("image")
|
||||||
|
return updated
|
||||||
|
|
||||||
async def _reload_mcp_runtime(self) -> dict[str, Any]:
|
async def _reload_mcp_runtime(self) -> dict[str, Any]:
|
||||||
if self._mcp_reload is None:
|
if self._mcp_reload is None:
|
||||||
return {
|
return {
|
||||||
@@ -519,9 +531,47 @@ class WebUISettingsRouter:
|
|||||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
return self._query(request)
|
return self._query(request)
|
||||||
|
|
||||||
|
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
|
return self._query(request)
|
||||||
|
|
||||||
|
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
|
payload = _mutation_payload(request)
|
||||||
|
if payload is not None:
|
||||||
|
api_key = payload.get("api_key")
|
||||||
|
if api_key is not None and not isinstance(api_key, str):
|
||||||
|
raise WebUISettingsError("API service API key must be a string")
|
||||||
|
return self._query(request)
|
||||||
|
|
||||||
def _api_runtime(self) -> ApiRuntime:
|
def _api_runtime(self) -> ApiRuntime:
|
||||||
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
||||||
|
|
||||||
|
def _api_service_payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
last_action: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return capability_domain.api_service_payload(
|
||||||
|
self.settings,
|
||||||
|
self._api_runtime(),
|
||||||
|
last_action=last_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _masked_secret(value: str) -> str | None:
|
||||||
|
return capability_domain.masked_api_secret(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _api_runtime_message(message: str) -> str:
|
||||||
|
return capability_domain.api_runtime_message(message)
|
||||||
|
|
||||||
|
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
|
||||||
|
return self._system.parse_channel_values(
|
||||||
|
SettingsRequest(
|
||||||
|
query=self._query(request),
|
||||||
|
payload=_mutation_payload(request),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def _save_channel_config_values(
|
def _save_channel_config_values(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -560,6 +610,17 @@ class WebUISettingsRouter:
|
|||||||
allow_install=allow_install,
|
allow_install=allow_install,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _feature_runtime_fallback(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
message: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return system_domain.SystemSettingsHandler.feature_runtime_fallback(
|
||||||
|
payload,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
def _allow_feature_package_install(
|
def _allow_feature_package_install(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
|
|||||||
+16
-24
@@ -1870,14 +1870,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
return None
|
return None
|
||||||
return str(last.get("id"))
|
return str(last.get("id"))
|
||||||
|
|
||||||
def close_interrupted_assistant() -> None:
|
def demote_interrupted_assistant(segment: str) -> None:
|
||||||
"""Close an answer segment before tool activity without changing its semantics.
|
|
||||||
|
|
||||||
The wire protocol already marks answer, reasoning, and activity phases.
|
|
||||||
A later tool event does not turn previously emitted answer text into
|
|
||||||
reasoning; preserving ``content`` also keeps live and replay projections
|
|
||||||
equivalent.
|
|
||||||
"""
|
|
||||||
nonlocal buffer_message_id, buffer_parts
|
nonlocal buffer_message_id, buffer_parts
|
||||||
for i in range(len(messages) - 1, -1, -1):
|
for i in range(len(messages) - 1, -1, -1):
|
||||||
candidate = messages[i]
|
candidate = messages[i]
|
||||||
@@ -1893,7 +1886,19 @@ def replay_transcript_to_ui_messages(
|
|||||||
or candidate.get("media")
|
or candidate.get("media")
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
messages[i] = {**candidate, "isStreaming": False}
|
reasoning_parts = [
|
||||||
|
part
|
||||||
|
for part in (candidate.get("reasoning"), content)
|
||||||
|
if isinstance(part, str) and part.strip()
|
||||||
|
]
|
||||||
|
messages[i] = {
|
||||||
|
**candidate,
|
||||||
|
"content": "",
|
||||||
|
"reasoning": "\n\n".join(reasoning_parts),
|
||||||
|
"reasoningStreaming": False,
|
||||||
|
"isStreaming": False,
|
||||||
|
"activitySegmentId": candidate.get("activitySegmentId") or segment,
|
||||||
|
}
|
||||||
if buffer_message_id == candidate.get("id"):
|
if buffer_message_id == candidate.get("id"):
|
||||||
buffer_message_id = None
|
buffer_message_id = None
|
||||||
buffer_parts = []
|
buffer_parts = []
|
||||||
@@ -2064,7 +2069,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
if not segment:
|
if not segment:
|
||||||
segment = _new_activity_segment(activate=False)
|
segment = _new_activity_segment(activate=False)
|
||||||
active_file_edit_segment_id = segment
|
active_file_edit_segment_id = segment
|
||||||
close_interrupted_assistant()
|
demote_interrupted_assistant(segment)
|
||||||
strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields)
|
strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields)
|
||||||
target_index = find_file_edit_trace_index(segment, edits)
|
target_index = find_file_edit_trace_index(segment, edits)
|
||||||
if target_index is not None:
|
if target_index is not None:
|
||||||
@@ -2358,7 +2363,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
if not trace_lines:
|
if not trace_lines:
|
||||||
continue
|
continue
|
||||||
segment = _ensure_activity_segment()
|
segment = _ensure_activity_segment()
|
||||||
close_interrupted_assistant()
|
demote_interrupted_assistant(segment)
|
||||||
last = messages[-1] if messages else None
|
last = messages[-1] if messages else None
|
||||||
if (
|
if (
|
||||||
last
|
last
|
||||||
@@ -2541,19 +2546,6 @@ def has_pending_tool_calls(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def has_unfinished_transcript_tail(session_key: str) -> bool:
|
|
||||||
"""Return whether the active transcript ends in an unfinished turn.
|
|
||||||
|
|
||||||
Recovery runs at gateway startup and only needs the newest, still-active
|
|
||||||
turn. Completed turns are rotated into immutable segment files, so reading
|
|
||||||
every historical segment here would make restart cost grow with the full
|
|
||||||
conversation history.
|
|
||||||
"""
|
|
||||||
return has_pending_tool_calls(
|
|
||||||
_read_transcript_file(webui_transcript_path(session_key))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def completed_turn_ids(lines: list[dict[str, Any]]) -> list[str]:
|
def completed_turn_ids(lines: list[dict[str, Any]]) -> list[str]:
|
||||||
"""Return stable identities for turns with an explicitly persisted completion."""
|
"""Return stable identities for turns with an explicitly persisted completion."""
|
||||||
completed: list[str] = []
|
completed: list[str] = []
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from nanobot.cron.session_turns import is_bound_cron_job
|
|||||||
from nanobot.cron.types import CronJob, CronSchedule
|
from nanobot.cron.types import CronJob, CronSchedule
|
||||||
from nanobot.security.workspace_access import WorkspaceScope
|
from nanobot.security.workspace_access import WorkspaceScope
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.recovery import RecoveryActionError
|
|
||||||
from nanobot.session.session_handles import (
|
from nanobot.session.session_handles import (
|
||||||
SessionHandleResolver,
|
SessionHandleResolver,
|
||||||
)
|
)
|
||||||
@@ -146,8 +145,6 @@ _WEBUI_MUTATION_PATHS = {
|
|||||||
"skill.delete": "/api/webui/skills/delete",
|
"skill.delete": "/api/webui/skills/delete",
|
||||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||||
"workspace.pick_folder": "/api/workspaces/pick-folder",
|
"workspace.pick_folder": "/api/workspaces/pick-folder",
|
||||||
"recovery.continue": "/api/webui/recovery/continue",
|
|
||||||
"recovery.dismiss": "/api/webui/recovery/dismiss",
|
|
||||||
"settings.agent.update": "/api/settings/update",
|
"settings.agent.update": "/api/settings/update",
|
||||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||||
@@ -326,9 +323,6 @@ class GatewayHTTPHandler:
|
|||||||
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
mcp_runtime_status: Callable[[], Mapping[str, str]] | None = None,
|
||||||
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
mcp_reload: Callable[[], Awaitable[dict[str, Any]]] | None = None,
|
||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
recovery_action: (
|
|
||||||
Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]] | None
|
|
||||||
) = None,
|
|
||||||
log: Any = logger,
|
log: Any = logger,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -346,7 +340,6 @@ class GatewayHTTPHandler:
|
|||||||
disabled_skills if disabled_skills is not None else set()
|
disabled_skills if disabled_skills is not None else set()
|
||||||
)
|
)
|
||||||
self.skill_state_action = skill_state_action
|
self.skill_state_action = skill_state_action
|
||||||
self.recovery_action = recovery_action
|
|
||||||
self._skill_install_lock = asyncio.Lock()
|
self._skill_install_lock = asyncio.Lock()
|
||||||
self._folder_picker_lock = asyncio.Lock()
|
self._folder_picker_lock = asyncio.Lock()
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
@@ -461,8 +454,6 @@ class GatewayHTTPHandler:
|
|||||||
return True
|
return True
|
||||||
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
|
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
|
||||||
return True
|
return True
|
||||||
if path in {"/api/webui/recovery/continue", "/api/webui/recovery/dismiss"}:
|
|
||||||
return True
|
|
||||||
return path in {
|
return path in {
|
||||||
"/api/webui/skills/install",
|
"/api/webui/skills/install",
|
||||||
"/api/webui/skills/update",
|
"/api/webui/skills/update",
|
||||||
@@ -516,11 +507,6 @@ class GatewayHTTPHandler:
|
|||||||
if response is not None:
|
if response is not None:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# Recovery routes
|
|
||||||
response = await self._dispatch_recovery_route(request, got)
|
|
||||||
if response is not None:
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Session routes
|
# Session routes
|
||||||
response = await self._dispatch_session_routes(request, got)
|
response = await self._dispatch_session_routes(request, got)
|
||||||
if response is not None:
|
if response is not None:
|
||||||
@@ -714,27 +700,6 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _dispatch_recovery_route(
|
|
||||||
self,
|
|
||||||
request: WsRequest,
|
|
||||||
path: str,
|
|
||||||
) -> Response | None:
|
|
||||||
match = re.fullmatch(r"/api/webui/recovery/(continue|dismiss)", path)
|
|
||||||
if match is None:
|
|
||||||
return None
|
|
||||||
if not getattr(request, _WEBUI_MUTATION_REQUEST_ATTR, False):
|
|
||||||
return _http_error(405, "WebUI recovery actions require an authenticated WebSocket")
|
|
||||||
if self.recovery_action is None:
|
|
||||||
return _http_error(503, "WebUI recovery is unavailable")
|
|
||||||
payload = _mutation_payload(request)
|
|
||||||
if payload is None:
|
|
||||||
return _http_error(400, "invalid recovery payload")
|
|
||||||
try:
|
|
||||||
result = await self.recovery_action(match.group(1), payload)
|
|
||||||
except RecoveryActionError as exc:
|
|
||||||
return _http_error(exc.status, str(exc))
|
|
||||||
return _http_json_response(result)
|
|
||||||
|
|
||||||
async def _handle_session_context_get(self, request: WsRequest, key: str) -> Response:
|
async def _handle_session_context_get(self, request: WsRequest, key: str) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
@@ -781,10 +746,6 @@ class GatewayHTTPHandler:
|
|||||||
for k, v in s.items()
|
for k, v in s.items()
|
||||||
if k != "path" and k not in WEBUI_SESSION_INDEX_INTERNAL_FIELDS
|
if k != "path" and k not in WEBUI_SESSION_INDEX_INTERNAL_FIELDS
|
||||||
}
|
}
|
||||||
# Keep the additive recovery field absent for ordinary sessions so
|
|
||||||
# older clients and compact list responses stay unchanged.
|
|
||||||
if row.get("recovery_state") is None:
|
|
||||||
row.pop("recovery_state", None)
|
|
||||||
chat_id = key.split(":", 1)[1]
|
chat_id = key.split(":", 1)[1]
|
||||||
started_at = websocket_turn_wall_started_at(chat_id)
|
started_at = websocket_turn_wall_started_at(chat_id)
|
||||||
if started_at is not None:
|
if started_at is not None:
|
||||||
|
|||||||
+1
-1
@@ -29,6 +29,7 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.12.0,<3.0.0",
|
"pydantic-settings>=2.12.0,<3.0.0",
|
||||||
# Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16.
|
# Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16.
|
||||||
"websockets>=15.0,<17.0",
|
"websockets>=15.0,<17.0",
|
||||||
|
"websocket-client>=1.9.0,<2.0.0",
|
||||||
"httpx[socks]>=0.28.0,<1.0.0",
|
"httpx[socks]>=0.28.0,<1.0.0",
|
||||||
"ddgs>=9.5.5,<10.0.0",
|
"ddgs>=9.5.5,<10.0.0",
|
||||||
"oauth-cli-kit>=0.1.6,<1.0.0",
|
"oauth-cli-kit>=0.1.6,<1.0.0",
|
||||||
@@ -39,7 +40,6 @@ dependencies = [
|
|||||||
"qrcode[pil]>=8.0",
|
"qrcode[pil]>=8.0",
|
||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"setproctitle>=1.3.7,<2.0.0; sys_platform != 'win32'",
|
|
||||||
"questionary>=2.0.0,<3.0.0",
|
"questionary>=2.0.0,<3.0.0",
|
||||||
"mcp>=1.26.0,<2.0.0",
|
"mcp>=1.26.0,<2.0.0",
|
||||||
"json-repair>=0.57.0,<1.0.0",
|
"json-repair>=0.57.0,<1.0.0",
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ from nanobot.session.keys import (
|
|||||||
UNIFIED_SESSION_KEY,
|
UNIFIED_SESSION_KEY,
|
||||||
)
|
)
|
||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session
|
||||||
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY, PENDING_FOLLOWUPS_KEY
|
|
||||||
from nanobot.session.turn_continuation import (
|
from nanobot.session.turn_continuation import (
|
||||||
INTERNAL_CONTINUATION_META,
|
INTERNAL_CONTINUATION_META,
|
||||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
||||||
@@ -162,35 +161,6 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
|
|||||||
assert message["cron_prompt_ref"] == prompt_ref
|
assert message["cron_prompt_ref"] == prompt_ref
|
||||||
|
|
||||||
|
|
||||||
def test_persist_user_message_acknowledges_durable_followup(tmp_path: Path) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
session = loop.sessions.get_or_create("websocket:chat")
|
|
||||||
session.metadata[PENDING_FOLLOWUPS_KEY] = [
|
|
||||||
{
|
|
||||||
"id": "followup-1",
|
|
||||||
"sender_id": "user",
|
|
||||||
"chat_id": "chat",
|
|
||||||
"content": "queued while busy",
|
|
||||||
"media": [],
|
|
||||||
"metadata": {},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
persisted = loop._persist_user_message_early(
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="chat",
|
|
||||||
content="queued while busy",
|
|
||||||
metadata={PENDING_FOLLOWUP_ID_KEY: "followup-1"},
|
|
||||||
),
|
|
||||||
session,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert persisted is True
|
|
||||||
assert PENDING_FOLLOWUPS_KEY not in session.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
|
def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
session = loop.sessions.get_or_create("websocket:auto")
|
session = loop.sessions.get_or_create("websocket:auto")
|
||||||
@@ -411,34 +381,6 @@ def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
|
|||||||
assert public_history_message(session.messages[0])["content"] == []
|
assert public_history_message(session.messages[0])["content"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_acknowledges_every_merged_recovery_followup() -> None:
|
|
||||||
"""Persisting a merged injected row retires every durable follow-up ID."""
|
|
||||||
loop = _mk_loop()
|
|
||||||
session = Session(
|
|
||||||
key="test:recovery-followups",
|
|
||||||
metadata={
|
|
||||||
PENDING_FOLLOWUPS_KEY: [
|
|
||||||
{"id": "first"},
|
|
||||||
{"id": "second"},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
loop._save_turn(
|
|
||||||
session,
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": "first\n\nsecond",
|
|
||||||
PENDING_FOLLOWUP_ID_KEY: ["first", "second"],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
skip=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert PENDING_FOLLOWUPS_KEY not in session.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None:
|
def test_save_turn_keeps_image_placeholder_and_runtime_context() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:image")
|
session = Session(key="test:image")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ def _make_injection_callback(queue: asyncio.Queue):
|
|||||||
return inject_cb
|
return inject_cb
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(tmp_path, *, recovery_admission=None):
|
def _make_loop(tmp_path):
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
@@ -39,12 +39,7 @@ def _make_loop(tmp_path, *, recovery_admission=None):
|
|||||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
||||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||||
mock_sub_mgr.return_value.close = AsyncMock()
|
mock_sub_mgr.return_value.close = AsyncMock()
|
||||||
loop = AgentLoop(
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
||||||
bus=bus,
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
recovery_admission=recovery_admission,
|
|
||||||
)
|
|
||||||
return loop
|
return loop
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -764,20 +759,6 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_runner_merge_keeps_all_recovery_followup_ids() -> None:
|
|
||||||
"""Merged follow-ups stay acknowledged together after a later save."""
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
from nanobot.session.recovery import PENDING_FOLLOWUP_ID_KEY
|
|
||||||
|
|
||||||
messages = [{"role": "user", "content": "first", PENDING_FOLLOWUP_ID_KEY: "one"}]
|
|
||||||
AgentRunner._append_injected_messages(
|
|
||||||
messages,
|
|
||||||
[{"role": "user", "content": "second", PENDING_FOLLOWUP_ID_KEY: "two"}],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert messages[-1][PENDING_FOLLOWUP_ID_KEY] == ["one", "two"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_runner_merge_preserves_runtime_markers_with_media() -> None:
|
def test_runner_merge_preserves_runtime_markers_with_media() -> None:
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
@@ -986,71 +967,6 @@ async def test_followup_routed_to_pending_queue(tmp_path):
|
|||||||
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_websocket_followup_is_admitted_before_recovery_queue(tmp_path):
|
|
||||||
"""Recovery admission runs before a newer WebUI message is injected."""
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
|
|
||||||
admission = MagicMock()
|
|
||||||
admission.admit = AsyncMock(return_value=True)
|
|
||||||
loop = _make_loop(tmp_path, recovery_admission=admission)
|
|
||||||
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
|
||||||
|
|
||||||
session_key = "websocket:chat"
|
|
||||||
pending = asyncio.Queue(maxsize=20)
|
|
||||||
loop._pending_queues[session_key] = pending
|
|
||||||
|
|
||||||
run_task = asyncio.create_task(loop.run())
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="u",
|
|
||||||
chat_id="chat",
|
|
||||||
content="new request",
|
|
||||||
)
|
|
||||||
await loop.bus.publish_inbound(msg)
|
|
||||||
|
|
||||||
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
|
|
||||||
admission.admit.assert_awaited_once_with(msg)
|
|
||||||
assert queued_msg.content == msg.content
|
|
||||||
assert queued_msg.metadata["_recovery_followup_id"]
|
|
||||||
|
|
||||||
loop.stop()
|
|
||||||
await asyncio.wait_for(run_task, timeout=2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unified_websocket_followup_admits_effective_session(tmp_path):
|
|
||||||
"""Recovery admission and the pending queue must use the same session key."""
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
|
||||||
|
|
||||||
admission = MagicMock()
|
|
||||||
admission.admit = AsyncMock(return_value=True)
|
|
||||||
loop = _make_loop(tmp_path, recovery_admission=admission)
|
|
||||||
loop._unified_session = True
|
|
||||||
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
|
||||||
|
|
||||||
pending = asyncio.Queue(maxsize=20)
|
|
||||||
loop._pending_queues[UNIFIED_SESSION_KEY] = pending
|
|
||||||
|
|
||||||
run_task = asyncio.create_task(loop.run())
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="u",
|
|
||||||
chat_id="chat",
|
|
||||||
content="new request",
|
|
||||||
)
|
|
||||||
await loop.bus.publish_inbound(msg)
|
|
||||||
|
|
||||||
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
|
|
||||||
admitted_msg = admission.admit.await_args.args[0]
|
|
||||||
assert admitted_msg.session_key == UNIFIED_SESSION_KEY
|
|
||||||
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
|
||||||
|
|
||||||
loop.stop()
|
|
||||||
await asyncio.wait_for(run_task, timeout=2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_mid_turn_subagent_result_does_not_resolve_a_new_turn_route(tmp_path):
|
async def test_mid_turn_subagent_result_does_not_resolve_a_new_turn_route(tmp_path):
|
||||||
"""Injected results stay inside the active turn instead of opening a side turn."""
|
"""Injected results stay inside the active turn instead of opening a side turn."""
|
||||||
@@ -1398,51 +1314,6 @@ async def test_pending_queue_full_falls_back_to_queued_task(tmp_path):
|
|||||||
assert pending.qsize() == 1
|
assert pending.qsize() == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pending_queue_overflow_keeps_websocket_followup_durable(tmp_path):
|
|
||||||
"""Fallback dispatch must not acknowledge a WebUI message before it commits."""
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.session.manager import Session
|
|
||||||
from nanobot.session.recovery import pending_followups
|
|
||||||
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
dispatched = asyncio.Event()
|
|
||||||
release_dispatch = asyncio.Event()
|
|
||||||
|
|
||||||
async def _dispatch(_msg):
|
|
||||||
dispatched.set()
|
|
||||||
await release_dispatch.wait()
|
|
||||||
|
|
||||||
loop._dispatch = AsyncMock(side_effect=_dispatch) # type: ignore[method-assign]
|
|
||||||
session = Session(key="websocket:c")
|
|
||||||
loop.sessions.get_or_create.return_value = session
|
|
||||||
pending = asyncio.Queue(maxsize=1)
|
|
||||||
pending.put_nowait(
|
|
||||||
InboundMessage(channel="websocket", sender_id="u", chat_id="c", content="already queued")
|
|
||||||
)
|
|
||||||
loop._pending_queues["websocket:c"] = pending
|
|
||||||
|
|
||||||
run_task = asyncio.create_task(loop.run())
|
|
||||||
await loop.bus.publish_inbound(
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="u",
|
|
||||||
chat_id="c",
|
|
||||||
content="durable follow-up",
|
|
||||||
metadata={"webui": True},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await asyncio.wait_for(dispatched.wait(), timeout=2)
|
|
||||||
|
|
||||||
assert [message.content for message in pending_followups(session)] == ["durable follow-up"]
|
|
||||||
dispatched_msg = loop._dispatch.await_args.args[0]
|
|
||||||
assert dispatched_msg.metadata["_recovery_followup_id"]
|
|
||||||
|
|
||||||
release_dispatch.set()
|
|
||||||
loop.stop()
|
|
||||||
await asyncio.wait_for(run_task, timeout=2)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
|
async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
|
||||||
"""Messages left in the pending queue after _dispatch are re-published to the bus.
|
"""Messages left in the pending queue after _dispatch are re-published to the bus.
|
||||||
|
|||||||
@@ -161,25 +161,3 @@ async def test_dispatch_cancellation_restores_checkpoint():
|
|||||||
"Checkpoint metadata should be cleared after restore"
|
"Checkpoint metadata should be cleared after restore"
|
||||||
assert loop.sessions.save.called, \
|
assert loop.sessions.save.called, \
|
||||||
"Session should be persisted so the restored state survives process restart"
|
"Session should be persisted so the restored state survives process restart"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_cancellation_keeps_checkpoint_for_gateway_shutdown(tmp_path: Path) -> None:
|
|
||||||
"""Gateway shutdown preserves the checkpoint; an explicit stop restores it."""
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
loop.preserve_inflight_turns_on_shutdown()
|
|
||||||
loop._restore_runtime_checkpoint = MagicMock() # type: ignore[method-assign]
|
|
||||||
|
|
||||||
async def _cancel(*_args: object, **_kwargs: object) -> None:
|
|
||||||
raise asyncio.CancelledError()
|
|
||||||
|
|
||||||
loop._process_message = _cancel # type: ignore[method-assign]
|
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await loop._dispatch(
|
|
||||||
InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="work")
|
|
||||||
)
|
|
||||||
|
|
||||||
loop._restore_runtime_checkpoint.assert_not_called()
|
|
||||||
|
|||||||
@@ -3721,27 +3721,6 @@ async def test_notify_restart_done_waits_until_channel_starts():
|
|||||||
assert sent_msg.content.startswith("Restart completed")
|
assert sent_msg.content.startswith("Restart completed")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_websocket_restart_notice_does_not_overwrite_recovery_state():
|
|
||||||
"""WebSocket attach/recovery events already own reconnect state."""
|
|
||||||
fake_config = SimpleNamespace(
|
|
||||||
channels=ChannelsConfig(),
|
|
||||||
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
|
||||||
)
|
|
||||||
mgr = ChannelManager.__new__(ChannelManager)
|
|
||||||
mgr.config = fake_config
|
|
||||||
mgr.bus = MessageBus()
|
|
||||||
channel = _StartableChannel(fake_config, mgr.bus)
|
|
||||||
channel._running = True
|
|
||||||
mgr.channels = {"websocket": channel}
|
|
||||||
mgr._send_with_retry = AsyncMock()
|
|
||||||
|
|
||||||
notice = RestartNotice(channel="websocket", chat_id="chat", started_at_raw="100.0")
|
|
||||||
await mgr._send_restart_notice_when_started(notice)
|
|
||||||
|
|
||||||
mgr._send_with_retry.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_restart_notice_retries_until_running_channel_accepts_delivery():
|
async def test_restart_notice_retries_until_running_channel_accepts_delivery():
|
||||||
"""A running flag must not make an early transport failure final."""
|
"""A running flag must not make an early transport failure final."""
|
||||||
|
|||||||
@@ -102,19 +102,6 @@ class _GatewayAgentContractStub:
|
|||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def preserve_inflight_turns_on_shutdown(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class _EmptyGatewaySessionManager:
|
|
||||||
"""Minimal session-manager contract for gateway assembly tests."""
|
|
||||||
|
|
||||||
def list_sessions(self) -> list[dict[str, object]]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def flush_all(self) -> int:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
|
def test_gateway_signal_handler_first_signal_stops_and_second_forces() -> None:
|
||||||
class _FakeLoop:
|
class _FakeLoop:
|
||||||
@@ -2769,7 +2756,7 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
monkeypatch.setattr("nanobot.api.server.create_app", _fake_create_app)
|
||||||
@@ -2836,7 +2823,7 @@ def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path:
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
session_manager=lambda _workspace: object(),
|
||||||
cron_service=_StopCron,
|
cron_service=_StopCron,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3342,7 +3329,7 @@ def test_gateway_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
session_manager=lambda _workspace: object(),
|
||||||
cron_service=_StopCron,
|
cron_service=_StopCron,
|
||||||
get_cron_dir=lambda: legacy_dir,
|
get_cron_dir=lambda: legacy_dir,
|
||||||
)
|
)
|
||||||
@@ -3381,7 +3368,7 @@ def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
session_manager=lambda _workspace: object(),
|
||||||
cron_service=_StopCron,
|
cron_service=_StopCron,
|
||||||
get_cron_dir=lambda: legacy_dir,
|
get_cron_dir=lambda: legacy_dir,
|
||||||
)
|
)
|
||||||
@@ -3582,7 +3569,7 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
@@ -3715,9 +3702,6 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
seen["agent_closed"] = True
|
seen["agent_closed"] = True
|
||||||
|
|
||||||
def preserve_inflight_turns_on_shutdown(self) -> None:
|
|
||||||
seen["inflight_turns_preserved"] = True
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
seen["agent_stopped"] = True
|
seen["agent_stopped"] = True
|
||||||
|
|
||||||
@@ -3787,7 +3771,7 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.MCPProvider", _FakeMCPProvider)
|
||||||
@@ -3799,7 +3783,6 @@ def test_gateway_agent_task_owns_initial_mcp_provider_close(
|
|||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert seen["agent_stopped"] is True
|
assert seen["agent_stopped"] is True
|
||||||
assert seen["inflight_turns_preserved"] is True
|
|
||||||
assert seen["agent_closed"] is True
|
assert seen["agent_closed"] is True
|
||||||
assert seen["agent_task_cleaned_up"] is True
|
assert seen["agent_task_cleaned_up"] is True
|
||||||
assert seen["channels_stopped"] is True
|
assert seen["channels_stopped"] is True
|
||||||
@@ -3911,7 +3894,7 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
config,
|
config,
|
||||||
message_bus=lambda: object(),
|
message_bus=lambda: object(),
|
||||||
session_manager=lambda _workspace: _EmptyGatewaySessionManager(),
|
session_manager=lambda _workspace: object(),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.gateway_runtime.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
from nanobot.cli.commands import app
|
|
||||||
from nanobot.cli.process_identity import named_executable, set_cli_process_identity
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("args", "expected"),
|
|
||||||
[
|
|
||||||
(["agent"], "nanobot-agent"),
|
|
||||||
(["gateway", "--background"], "nanobot-gateway"),
|
|
||||||
(["webui"], "nanobot-webui"),
|
|
||||||
(["status"], "nanobot"),
|
|
||||||
([], "nanobot"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_cli_process_identity_uses_product_and_role(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
args: list[str],
|
|
||||||
expected: str,
|
|
||||||
) -> None:
|
|
||||||
titles: list[str] = []
|
|
||||||
monkeypatch.setattr("nanobot.cli.process_identity.os.name", "posix")
|
|
||||||
monkeypatch.setattr("nanobot.cli.process_identity._set_process_title", titles.append)
|
|
||||||
|
|
||||||
set_cli_process_identity(args)
|
|
||||||
|
|
||||||
assert titles == [expected]
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_process_identity_keeps_windows_launcher_name(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
titles: list[str] = []
|
|
||||||
monkeypatch.setattr("nanobot.cli.process_identity.os.name", "nt")
|
|
||||||
monkeypatch.setattr("nanobot.cli.process_identity._set_process_title", titles.append)
|
|
||||||
|
|
||||||
set_cli_process_identity(["agent"])
|
|
||||||
|
|
||||||
assert titles == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_console_entrypoint_still_sets_subcommand_identity(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
commands: list[list[str]] = []
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands.set_cli_process_identity", commands.append)
|
|
||||||
|
|
||||||
result = CliRunner().invoke(app, ["webui", "--help"])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert commands == [["webui"]]
|
|
||||||
|
|
||||||
|
|
||||||
def test_named_executable_creates_stable_role_symlink(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
if os.name == "nt":
|
|
||||||
pytest.skip("POSIX symlink naming is not used on Windows")
|
|
||||||
executable = tmp_path / "bun"
|
|
||||||
executable.write_text("runtime", encoding="utf-8")
|
|
||||||
|
|
||||||
first = Path(
|
|
||||||
named_executable(executable.as_posix(), name="nanobot-tui", directory=tmp_path / "run")
|
|
||||||
)
|
|
||||||
second = Path(
|
|
||||||
named_executable(executable.as_posix(), name="nanobot-tui", directory=tmp_path / "run")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert first == second
|
|
||||||
assert first.name == "nanobot-tui"
|
|
||||||
assert first.is_symlink()
|
|
||||||
assert first.resolve() == executable
|
|
||||||
|
|
||||||
|
|
||||||
def test_named_executable_uses_original_on_windows(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.cli.process_identity.os.name", "nt")
|
|
||||||
|
|
||||||
assert (
|
|
||||||
named_executable("bun.exe", name="nanobot-tui", directory=tmp_path / "run")
|
|
||||||
== "bun.exe"
|
|
||||||
)
|
|
||||||
@@ -467,13 +467,9 @@ def test_source_checkout_refreshes_locked_tui_dependencies(
|
|||||||
return subprocess.CompletedProcess(command, 0, "", "")
|
return subprocess.CompletedProcess(command, 0, "", "")
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
|
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.cli.tui_launcher.named_executable",
|
|
||||||
lambda executable, **_kwargs: f"{executable}-named",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert _resolve_source_tui_command(source_dir, bun) == [
|
assert _resolve_source_tui_command(source_dir, bun) == [
|
||||||
f"{bun}-named",
|
bun,
|
||||||
str(source_dir / "src" / "index.ts"),
|
str(source_dir / "src" / "index.ts"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from nanobot.gateway import (
|
|||||||
GatewayRuntimePaths,
|
GatewayRuntimePaths,
|
||||||
GatewayStartOptions,
|
GatewayStartOptions,
|
||||||
GatewayStatus,
|
GatewayStatus,
|
||||||
RuntimeResult,
|
|
||||||
)
|
)
|
||||||
from nanobot.gateway.runtime import monitor_gateway_clients
|
from nanobot.gateway.runtime import monitor_gateway_clients
|
||||||
from nanobot.process_runtime import process_is_running
|
from nanobot.process_runtime import process_is_running
|
||||||
@@ -455,33 +454,6 @@ def test_restart_does_not_detach_a_foreground_gateway(tmp_path, monkeypatch):
|
|||||||
assert result.message == "gateway_foreground_restart_required"
|
assert result.message == "gateway_foreground_restart_required"
|
||||||
|
|
||||||
|
|
||||||
def test_restart_stops_then_starts_the_background_gateway(tmp_path, monkeypatch):
|
|
||||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
|
||||||
status = GatewayStatus(
|
|
||||||
running=True,
|
|
||||||
pid=12345,
|
|
||||||
state_path=runtime.paths.state_path,
|
|
||||||
log_path=runtime.paths.log_path,
|
|
||||||
launch_mode="background",
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(runtime, "status", lambda **_kwargs: status)
|
|
||||||
|
|
||||||
def stop(*, timeout_s: int):
|
|
||||||
assert timeout_s == 20
|
|
||||||
return SimpleNamespace(ok=True, message="gateway_stopped", status=status)
|
|
||||||
|
|
||||||
monkeypatch.setattr(runtime, "_stop", stop)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
runtime,
|
|
||||||
"_start_background",
|
|
||||||
lambda _options: RuntimeResult(True, "gateway_started_background", status),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = runtime.restart(GatewayStartOptions(port=18790))
|
|
||||||
|
|
||||||
assert result.ok is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
|
def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
|
||||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||||
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
monkeypatch.setattr(runtime, "_process_identity", lambda pid: pid)
|
||||||
|
|||||||
@@ -1,773 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.bus.outbound_events import RecoveryStateEvent, SessionUpdatedEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.session.manager import Session, SessionManager
|
|
||||||
from nanobot.session.recovery import (
|
|
||||||
PENDING_FOLLOWUPS_KEY,
|
|
||||||
PENDING_USER_TURN_KEY,
|
|
||||||
RECOVERY_METADATA_KEY,
|
|
||||||
RUNTIME_CHECKPOINT_KEY,
|
|
||||||
RecoveryActionError,
|
|
||||||
RecoveryCoordinator,
|
|
||||||
acknowledge_pending_followups,
|
|
||||||
pending_followups,
|
|
||||||
record_pending_followup,
|
|
||||||
)
|
|
||||||
from nanobot.webui import session_list_index, transcript
|
|
||||||
|
|
||||||
|
|
||||||
def _persist(manager: SessionManager, session: Session) -> None:
|
|
||||||
session.metadata["webui"] = True
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
|
|
||||||
def _coordinator(workspace: Path) -> tuple[RecoveryCoordinator, MessageBus, SessionManager]:
|
|
||||||
bus = MessageBus()
|
|
||||||
sessions = SessionManager(workspace)
|
|
||||||
return RecoveryCoordinator(sessions, bus), bus, sessions
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_restart_before_model_call_waits_for_confirmation(tmp_path: Path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "finish this"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
assert restored.metadata[PENDING_USER_TURN_KEY] is True
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "restart_requires_confirmation"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stale_incomplete_transcript_waits_for_confirmation(tmp_path: Path, monkeypatch) -> None:
|
|
||||||
"""A materialized shutdown must not reappear as an endless Working state."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
_persist(sessions, session)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.webui.transcript.has_unfinished_transcript_tail",
|
|
||||||
lambda _key: True,
|
|
||||||
)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
|
||||||
assert state["status"] == "awaiting_user"
|
|
||||||
assert state["reason"] == "interrupted_without_checkpoint"
|
|
||||||
event = bus.outbound.get_nowait().event
|
|
||||||
assert isinstance(event, RecoveryStateEvent)
|
|
||||||
assert event.status == "awaiting_user"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_materialized_interruption_can_continue_from_saved_context(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
"""Older shutdowns may have cleared the checkpoint after saving partial history."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.extend(
|
|
||||||
[
|
|
||||||
{"role": "user", "content": "research this"},
|
|
||||||
{"role": "assistant", "content": "I will check."},
|
|
||||||
{"role": "tool", "tool_call_id": "search-1", "content": "saved result"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
_persist(sessions, session)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.webui.transcript.has_unfinished_transcript_tail",
|
|
||||||
lambda _key: True,
|
|
||||||
)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
|
||||||
assert state["status"] == "awaiting_user"
|
|
||||||
assert state["reason"] == "interrupted_with_saved_context"
|
|
||||||
assert "can_continue" not in state
|
|
||||||
event = bus.outbound.get_nowait().event
|
|
||||||
assert isinstance(event, RecoveryStateEvent)
|
|
||||||
assert event.can_continue is None
|
|
||||||
|
|
||||||
await coordinator.handle_action(
|
|
||||||
"continue",
|
|
||||||
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
continuation = bus.inbound.get_nowait()
|
|
||||||
assert continuation.session_key_override == "websocket:chat"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_transcript_only_interruption_is_discovered_without_materializing_completed_history(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
webui_dir = tmp_path / "webui"
|
|
||||||
webui_dir.mkdir()
|
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
|
||||||
monkeypatch.setattr(transcript, "get_webui_dir", lambda: webui_dir)
|
|
||||||
unfinished_key = "websocket:unfinished"
|
|
||||||
completed_key = "websocket:completed"
|
|
||||||
(webui_dir / f"{SessionManager.safe_key(unfinished_key)}.jsonl").write_text(
|
|
||||||
'{"event":"user","chat_id":"unfinished","text":"keep going"}\n'
|
|
||||||
'{"event":"message","chat_id":"unfinished","kind":"progress","text":"Working"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
(webui_dir / f"{SessionManager.safe_key(completed_key)}.jsonl").write_text(
|
|
||||||
'{"event":"user","chat_id":"completed","text":"done"}\n'
|
|
||||||
'{"event":"message","chat_id":"completed","text":"finished"}\n'
|
|
||||||
'{"event":"turn_end","chat_id":"completed"}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
coordinator, bus, sessions = _coordinator(tmp_path / "workspace")
|
|
||||||
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
restored = sessions.get_or_create(unfinished_key)
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "interrupted_without_checkpoint"
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["can_continue"] is False
|
|
||||||
assert sessions.read_session_metadata(completed_key) is None
|
|
||||||
event = bus.outbound.get_nowait().event
|
|
||||||
assert isinstance(event, RecoveryStateEvent)
|
|
||||||
assert event.status == "awaiting_user"
|
|
||||||
assert event.can_continue is False
|
|
||||||
assert bus.outbound.get_nowait().event.scope == "thread"
|
|
||||||
assert bus.outbound.empty()
|
|
||||||
|
|
||||||
with pytest.raises(RecoveryActionError, match="context is unavailable"):
|
|
||||||
await coordinator.handle_action(
|
|
||||||
"continue",
|
|
||||||
{"chat_id": "unfinished", "recovery_id": event.recovery_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scan_loads_only_sessions_that_need_webui_recovery(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
for key in ("telegram:idle", "discord:pending", "websocket:idle"):
|
|
||||||
session = sessions.get_or_create(key)
|
|
||||||
if key == "discord:pending":
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
_persist(sessions, session)
|
|
||||||
pending = sessions.get_or_create("websocket:pending")
|
|
||||||
pending.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
_persist(sessions, pending)
|
|
||||||
|
|
||||||
coordinator, _, restarted = _coordinator(tmp_path)
|
|
||||||
loaded: list[str] = []
|
|
||||||
get_or_create = restarted.get_or_create
|
|
||||||
|
|
||||||
def tracked_get_or_create(key: str) -> Session:
|
|
||||||
loaded.append(key)
|
|
||||||
return get_or_create(key)
|
|
||||||
|
|
||||||
monkeypatch.setattr(restarted, "get_or_create", tracked_get_or_create)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.webui.transcript.has_unfinished_transcript_tail",
|
|
||||||
lambda _key: False,
|
|
||||||
)
|
|
||||||
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
assert loaded == ["websocket:pending"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_live_turn_followup_survives_restart_until_it_is_committed(tmp_path: Path) -> None:
|
|
||||||
"""A message injected mid-turn is not lost between checkpoints."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
_persist(sessions, session)
|
|
||||||
followup_id = record_pending_followup(
|
|
||||||
session,
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="chat",
|
|
||||||
content="also check the logs",
|
|
||||||
metadata={"webui": True},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert followup_id is not None
|
|
||||||
sessions.save(session)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
queued = bus.inbound.get_nowait()
|
|
||||||
assert queued.content == "also check the logs"
|
|
||||||
assert queued.metadata["_recovery_followup_id"] == followup_id
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
assert len(pending_followups(restored)) == 1
|
|
||||||
|
|
||||||
acknowledge_pending_followups(restored, [followup_id])
|
|
||||||
assert PENDING_FOLLOWUPS_KEY not in restored.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def test_followup_journal_keeps_every_uncommitted_message(tmp_path: Path) -> None:
|
|
||||||
"""A live queue limit must never truncate durable WebUI follow-ups."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
followup_ids = [
|
|
||||||
record_pending_followup(
|
|
||||||
session,
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="chat",
|
|
||||||
content=f"follow-up-{index}",
|
|
||||||
metadata={"webui": True},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for index in range(21)
|
|
||||||
]
|
|
||||||
|
|
||||||
assert all(followup_ids)
|
|
||||||
sessions.save(session)
|
|
||||||
restarted = SessionManager(tmp_path)
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
assert [message.content for message in pending_followups(restored)] == [
|
|
||||||
f"follow-up-{index}" for index in range(21)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_requeued_followup_preserves_its_journal_id(tmp_path: Path) -> None:
|
|
||||||
"""Routing a recovered follow-up into a live turn must remain idempotent."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
original_id = record_pending_followup(
|
|
||||||
session,
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="chat",
|
|
||||||
content="also check the logs",
|
|
||||||
metadata={"webui": True},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert original_id is not None
|
|
||||||
|
|
||||||
recovered = pending_followups(session)[0]
|
|
||||||
assert record_pending_followup(session, recovered) == original_id
|
|
||||||
assert [record["id"] for record in session.metadata[PENDING_FOLLOWUPS_KEY]] == [
|
|
||||||
original_id
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_completed_tools_wait_for_confirmation_after_restart(tmp_path: Path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "inspect"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
|
||||||
"phase": "tools_completed",
|
|
||||||
"assistant_message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [{"id": "call-1", "function": {"name": "read_file"}}],
|
|
||||||
},
|
|
||||||
"completed_tool_results": [
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call-1",
|
|
||||||
"name": "read_file",
|
|
||||||
"content": "saved result",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
assert restored.messages[-1]["content"] == "saved result"
|
|
||||||
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_uncertain_tool_is_never_replayed(tmp_path: Path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "send it"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
|
||||||
"phase": "awaiting_tools",
|
|
||||||
"assistant_message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [{"id": "call-1", "function": {"name": "send_email"}}],
|
|
||||||
},
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [
|
|
||||||
{"id": "call-1", "function": {"name": "send_email"}}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "tool_state_unknown"
|
|
||||||
assert restored.messages[-1]["_recovery_interrupted"] is True
|
|
||||||
event = bus.outbound.get_nowait().event
|
|
||||||
assert isinstance(event, RecoveryStateEvent)
|
|
||||||
assert event.status == "awaiting_user"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unknown_checkpoint_waits_for_confirmation(tmp_path: Path) -> None:
|
|
||||||
"""Malformed or newer checkpoint phases fail closed pending confirmation."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "deploy it"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {"phase": "future_phase"}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["status"] == "awaiting_user"
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "checkpoint_unknown"
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["can_continue"] is False
|
|
||||||
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
|
||||||
assert PENDING_USER_TURN_KEY not in restored.metadata
|
|
||||||
assert [message["role"] for message in restored.messages] == ["user", "assistant"]
|
|
||||||
assert restored.messages[-1]["_recovery_interrupted"] is True
|
|
||||||
|
|
||||||
with pytest.raises(RecoveryActionError, match="context is unavailable"):
|
|
||||||
await coordinator.handle_action(
|
|
||||||
"continue",
|
|
||||||
{
|
|
||||||
"chat_id": "chat",
|
|
||||||
"recovery_id": restored.metadata[RECOVERY_METADATA_KEY]["recovery_id"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
dismissed = await coordinator.handle_action(
|
|
||||||
"dismiss",
|
|
||||||
{
|
|
||||||
"chat_id": "chat",
|
|
||||||
"recovery_id": restored.metadata[RECOVERY_METADATA_KEY]["recovery_id"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert dismissed["status"] == "recovered"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_malformed_checkpoint_can_always_be_dismissed(tmp_path: Path) -> None:
|
|
||||||
"""Corrupt private state must not trap the user in a failed recovery notice."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "deploy it"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
|
||||||
"phase": "future_phase",
|
|
||||||
"assistant_message": "invalid",
|
|
||||||
"completed_tool_results": 3,
|
|
||||||
"pending_tool_calls": [{"id": "call-1", "function": "invalid"}],
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, _, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
state = restored.metadata[RECOVERY_METADATA_KEY]
|
|
||||||
|
|
||||||
result = await coordinator.handle_action(
|
|
||||||
"dismiss",
|
|
||||||
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["status"] == "recovered"
|
|
||||||
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_known_but_malformed_checkpoint_cannot_continue(tmp_path: Path) -> None:
|
|
||||||
"""A known phase does not make corrupt tool state safe to resume."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "send it"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
|
||||||
"phase": "tools_completed",
|
|
||||||
"assistant_message": {"role": "assistant", "content": "working"},
|
|
||||||
"completed_tool_results": "missing durable results",
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, _, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
state = restored.metadata[RECOVERY_METADATA_KEY]
|
|
||||||
|
|
||||||
assert state["status"] == "awaiting_user"
|
|
||||||
assert state["reason"] == "checkpoint_invalid"
|
|
||||||
assert state["can_continue"] is False
|
|
||||||
with pytest.raises(RecoveryActionError, match="context is unavailable"):
|
|
||||||
await coordinator.handle_action(
|
|
||||||
"continue",
|
|
||||||
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_malformed_final_response_is_not_reported_as_restored(tmp_path: Path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "answer"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
|
||||||
"phase": "final_response",
|
|
||||||
"assistant_message": "not an answer row",
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, _, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
|
||||||
assert state["status"] == "awaiting_user"
|
|
||||||
assert state["reason"] == "checkpoint_invalid"
|
|
||||||
assert state["can_continue"] is False
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
|
||||||
assert PENDING_USER_TURN_KEY not in restored.metadata
|
|
||||||
assert [message["role"] for message in restored.messages] == ["user", "assistant"]
|
|
||||||
assert restored.messages[-1]["_recovery_interrupted"] is True
|
|
||||||
assert all("tool_calls" not in message for message in restored.messages)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_checkpoint_with_missing_tool_result_cannot_continue(tmp_path: Path) -> None:
|
|
||||||
"""Never resume when persisted results do not cover every requested tool."""
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "send both"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
|
||||||
"phase": "tools_completed",
|
|
||||||
"assistant_message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{"id": "call-1", "function": {"name": "send_email"}},
|
|
||||||
{"id": "call-2", "function": {"name": "send_email"}},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"completed_tool_results": [
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call-1",
|
|
||||||
"name": "send_email",
|
|
||||||
"content": "sent",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, _, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
state = restored.metadata[RECOVERY_METADATA_KEY]
|
|
||||||
assert state["status"] == "awaiting_user"
|
|
||||||
assert state["reason"] == "checkpoint_invalid"
|
|
||||||
assert state["can_continue"] is False
|
|
||||||
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
|
||||||
assert PENDING_USER_TURN_KEY not in restored.metadata
|
|
||||||
assert [message["role"] for message in restored.messages] == ["user", "assistant"]
|
|
||||||
assert restored.messages[-1]["_recovery_interrupted"] is True
|
|
||||||
assert all("tool_calls" not in message for message in restored.messages)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_empty_final_response_is_not_reported_as_restored(tmp_path: Path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "answer"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
|
||||||
"phase": "final_response",
|
|
||||||
"assistant_message": {"role": "assistant", "content": ""},
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, _, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
|
||||||
assert state["status"] == "awaiting_user"
|
|
||||||
assert state["reason"] == "checkpoint_invalid"
|
|
||||||
assert state["can_continue"] is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_final_answer_is_restored_without_model_call(tmp_path: Path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "answer"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
|
||||||
"phase": "final_response",
|
|
||||||
"assistant_message": {"role": "assistant", "content": "already finished"},
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
restored = restarted.get_or_create("websocket:chat")
|
|
||||||
assert restored.messages[-1]["content"] == "already finished"
|
|
||||||
first = bus.outbound.get_nowait().event
|
|
||||||
second = bus.outbound.get_nowait().event
|
|
||||||
assert isinstance(first, RecoveryStateEvent) and first.status == "recovered"
|
|
||||||
assert isinstance(second, SessionUpdatedEvent)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_explicit_recovery_continue_queues_once(tmp_path: Path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "continue"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
|
|
||||||
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
|
||||||
result = await coordinator.handle_action(
|
|
||||||
"continue",
|
|
||||||
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
|
||||||
)
|
|
||||||
assert result["status"] == "resuming"
|
|
||||||
assert bus.inbound.get_nowait().metadata["_webui_recovery_id"] == state["recovery_id"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_new_user_message_supersedes_waiting_recovery(tmp_path: Path) -> None:
|
|
||||||
coordinator, bus, sessions = _coordinator(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.append({"role": "user", "content": "old request"})
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
_persist(sessions, session)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
newer = InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="chat",
|
|
||||||
content="new request",
|
|
||||||
)
|
|
||||||
assert await coordinator.admit(newer) is True
|
|
||||||
restored = sessions.get_or_create("websocket:chat")
|
|
||||||
assert restored.messages[-1]["_recovery_interrupted"] is True
|
|
||||||
assert sum(
|
|
||||||
message.get("_recovery_interrupted") is True
|
|
||||||
for message in restored.messages
|
|
||||||
) == 1
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "superseded"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_new_user_message_cancels_active_recovery_task(tmp_path: Path) -> None:
|
|
||||||
coordinator, bus, sessions = _coordinator(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.metadata[RECOVERY_METADATA_KEY] = {
|
|
||||||
"status": "resuming",
|
|
||||||
"recovery_id": "active",
|
|
||||||
"attempts": 1,
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
started = asyncio.Event()
|
|
||||||
|
|
||||||
async def _active_recovery() -> None:
|
|
||||||
started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
task = asyncio.create_task(_active_recovery())
|
|
||||||
await started.wait()
|
|
||||||
coordinator.register_recovery_task("websocket:chat", task)
|
|
||||||
|
|
||||||
newer = InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="chat",
|
|
||||||
content="new request",
|
|
||||||
)
|
|
||||||
assert await coordinator.admit(newer) is True
|
|
||||||
assert task.cancelled()
|
|
||||||
restored = sessions.get_or_create("websocket:chat")
|
|
||||||
assert restored.metadata[RECOVERY_METADATA_KEY]["reason"] == "superseded"
|
|
||||||
assert restored.messages[-1]["_recovery_interrupted"] is True
|
|
||||||
first = bus.outbound.get_nowait().event
|
|
||||||
second = bus.outbound.get_nowait().event
|
|
||||||
assert isinstance(first, RecoveryStateEvent)
|
|
||||||
assert isinstance(second, SessionUpdatedEvent)
|
|
||||||
assert bus.outbound.empty()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_recovery_action_rejects_stale_page_and_continues_current_state(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
coordinator, bus, sessions = _coordinator(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.metadata[RECOVERY_METADATA_KEY] = {
|
|
||||||
"status": "awaiting_user",
|
|
||||||
"recovery_id": "current",
|
|
||||||
"attempts": 0,
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
with pytest.raises(RecoveryActionError, match="stale"):
|
|
||||||
await coordinator.handle_action(
|
|
||||||
"continue",
|
|
||||||
{"chat_id": "chat", "recovery_id": "old"},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await coordinator.handle_action(
|
|
||||||
"continue",
|
|
||||||
{"chat_id": "chat", "recovery_id": "current"},
|
|
||||||
)
|
|
||||||
assert result["status"] == "resuming"
|
|
||||||
assert bus.inbound.get_nowait().metadata["_webui_recovery_id"] == "current"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persisted_completion_wins_over_stale_resuming_marker(tmp_path: Path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.messages.extend(
|
|
||||||
[
|
|
||||||
{"role": "user", "content": "work"},
|
|
||||||
{"role": "assistant", "content": "done"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
session.metadata[RECOVERY_METADATA_KEY] = {
|
|
||||||
"status": "resuming",
|
|
||||||
"recovery_id": "recovery",
|
|
||||||
"attempts": 1,
|
|
||||||
"resume_message_count": 1,
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
coordinator, bus, restarted = _coordinator(tmp_path)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
state = restarted.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
|
||||||
assert state["status"] == "recovered"
|
|
||||||
assert state["reason"] == "committed"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dismiss_does_not_queue_work(tmp_path: Path) -> None:
|
|
||||||
coordinator, bus, sessions = _coordinator(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.metadata[RECOVERY_METADATA_KEY] = {
|
|
||||||
"status": "awaiting_user",
|
|
||||||
"recovery_id": "current",
|
|
||||||
"attempts": 0,
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
result = await coordinator.handle_action(
|
|
||||||
"dismiss",
|
|
||||||
{"chat_id": "chat", "recovery_id": "current"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["status"] == "recovered"
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scan_failure_is_visible_instead_of_aborting_other_sessions(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
coordinator, bus, sessions = _coordinator(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
async def fail(*_args: object, **_kwargs: object) -> None:
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
monkeypatch.setattr(RecoveryCoordinator, "_recover_session", fail)
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
state = sessions.get_or_create("websocket:chat").metadata[RECOVERY_METADATA_KEY]
|
|
||||||
assert state["status"] == "failed"
|
|
||||||
assert state["can_continue"] is False
|
|
||||||
assert isinstance(bus.outbound.get_nowait().event, RecoveryStateEvent)
|
|
||||||
|
|
||||||
with pytest.raises(RecoveryActionError, match="context is unavailable"):
|
|
||||||
await coordinator.handle_action(
|
|
||||||
"continue",
|
|
||||||
{"chat_id": "chat", "recovery_id": state["recovery_id"]},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bus_remains_quiet_after_recovered_state(tmp_path: Path) -> None:
|
|
||||||
coordinator, bus, sessions = _coordinator(tmp_path)
|
|
||||||
session = sessions.get_or_create("websocket:chat")
|
|
||||||
session.metadata[RECOVERY_METADATA_KEY] = {
|
|
||||||
"status": "recovered",
|
|
||||||
"recovery_id": "done",
|
|
||||||
"attempts": 1,
|
|
||||||
}
|
|
||||||
_persist(sessions, session)
|
|
||||||
|
|
||||||
await coordinator.scan()
|
|
||||||
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
assert bus.inbound.empty()
|
|
||||||
assert bus.outbound.empty()
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import nanobot.session as session_api
|
import nanobot.session as session_api
|
||||||
from nanobot.providers.base import ProviderConversationState
|
|
||||||
from nanobot.session import Session, SessionManager
|
from nanobot.session import Session, SessionManager
|
||||||
from nanobot.session.manager import SessionStore
|
from nanobot.session.manager import SessionStore
|
||||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||||
@@ -124,93 +123,3 @@ def test_manager_preserves_full_session_before_store_save(tmp_path) -> None:
|
|||||||
assert session.messages[0]["content"] == "0"
|
assert session.messages[0]["content"] == "0"
|
||||||
assert session.messages[-1]["content"] == "2000"
|
assert session.messages[-1]["content"] == "2000"
|
||||||
store.save.assert_called_once_with(session, fsync=False)
|
store.save.assert_called_once_with(session, fsync=False)
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_checkpoint_does_not_rewrite_long_session(tmp_path) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
session = manager.get_or_create("websocket:long")
|
|
||||||
for index in range(256):
|
|
||||||
session.add_message("user", f"{index}:" + "x" * 4096)
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
main_path = manager._get_session_path(session.key)
|
|
||||||
main_before = main_path.read_bytes()
|
|
||||||
stat_before = main_path.stat()
|
|
||||||
session.metadata["runtime_checkpoint"] = {
|
|
||||||
"phase": "tools_completed",
|
|
||||||
"assistant_message": {"role": "assistant", "content": "working"},
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
session.provider_state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="test-model",
|
|
||||||
version=1,
|
|
||||||
payload={"response_id": "private-response"},
|
|
||||||
)
|
|
||||||
|
|
||||||
manager.save_runtime_checkpoint(session)
|
|
||||||
|
|
||||||
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
|
|
||||||
assert main_path.read_bytes() == main_before
|
|
||||||
assert main_path.stat().st_ino == stat_before.st_ino
|
|
||||||
assert main_path.stat().st_mtime_ns == stat_before.st_mtime_ns
|
|
||||||
assert checkpoint_path.stat().st_size < len(main_before) // 100
|
|
||||||
|
|
||||||
restored = SessionManager(tmp_path).get_or_create(session.key)
|
|
||||||
assert restored.metadata["runtime_checkpoint"]["phase"] == "tools_completed"
|
|
||||||
assert restored.provider_state is not None
|
|
||||||
assert restored.provider_state.payload == {"response_id": "private-response"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_completed_session_supersedes_stale_checkpoint(tmp_path) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
session = manager.get_or_create("websocket:completed")
|
|
||||||
session.add_message("user", "question")
|
|
||||||
manager.save(session)
|
|
||||||
session.metadata["runtime_checkpoint"] = {"phase": "awaiting_tools"}
|
|
||||||
manager.save_runtime_checkpoint(session)
|
|
||||||
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
|
|
||||||
stale_checkpoint = checkpoint_path.read_bytes()
|
|
||||||
|
|
||||||
session.metadata.pop("runtime_checkpoint")
|
|
||||||
session.add_message("assistant", "answer")
|
|
||||||
manager.save(session)
|
|
||||||
assert not checkpoint_path.exists()
|
|
||||||
|
|
||||||
# Emulate a process dying after the main record was committed but before an
|
|
||||||
# obsolete sidecar could be removed. The base fingerprint keeps it stale.
|
|
||||||
checkpoint_path.write_bytes(stale_checkpoint)
|
|
||||||
restored = SessionManager(tmp_path).get_or_create(session.key)
|
|
||||||
assert "runtime_checkpoint" not in restored.metadata
|
|
||||||
assert restored.messages[-1]["content"] == "answer"
|
|
||||||
assert not checkpoint_path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_session_removes_runtime_checkpoint(tmp_path) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
session = manager.get_or_create("websocket:delete")
|
|
||||||
session.add_message("user", "question")
|
|
||||||
manager.save(session)
|
|
||||||
session.metadata["runtime_checkpoint"] = {"phase": "awaiting_tools"}
|
|
||||||
manager.save_runtime_checkpoint(session)
|
|
||||||
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
|
|
||||||
assert checkpoint_path.exists()
|
|
||||||
|
|
||||||
assert manager.delete_session(session.key) is True
|
|
||||||
assert not checkpoint_path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_runtime_checkpoint_is_discarded(tmp_path) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
session = manager.get_or_create("websocket:invalid-checkpoint")
|
|
||||||
session.add_message("user", "question")
|
|
||||||
manager.save(session)
|
|
||||||
checkpoint_path = manager._get_runtime_checkpoint_path(session.key)
|
|
||||||
checkpoint_path.write_text("{truncated", encoding="utf-8")
|
|
||||||
|
|
||||||
restored = SessionManager(tmp_path).get_or_create(session.key)
|
|
||||||
|
|
||||||
assert "runtime_checkpoint" not in restored.metadata
|
|
||||||
assert not checkpoint_path.exists()
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import nanobot.webui.transcript as transcript_module
|
import nanobot.webui.transcript as transcript_module
|
||||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||||
from nanobot.webui.transcript import (
|
from nanobot.webui.transcript import (
|
||||||
@@ -556,22 +554,6 @@ def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatc
|
|||||||
assert out["completed_turn_ids"] == []
|
assert out["completed_turn_ids"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_recovery_tail_check_reads_only_the_active_transcript(tmp_path, monkeypatch) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
key = "websocket:recovery-tail"
|
|
||||||
active_path = transcript_module.webui_transcript_path(key)
|
|
||||||
reads: list[Path] = []
|
|
||||||
|
|
||||||
def read(path: Path) -> list[dict[str, object]]:
|
|
||||||
reads.append(path)
|
|
||||||
return [{"event": "message", "kind": "progress", "text": "running"}]
|
|
||||||
|
|
||||||
monkeypatch.setattr(transcript_module, "_read_transcript_file", read)
|
|
||||||
|
|
||||||
assert transcript_module.has_unfinished_transcript_tail(key) is True
|
|
||||||
assert reads == [active_path]
|
|
||||||
|
|
||||||
|
|
||||||
def test_thread_response_reports_active_registry_without_transcript(
|
def test_thread_response_reports_active_registry_without_transcript(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -1503,7 +1485,7 @@ def test_replay_keeps_every_file_from_one_apply_patch_call() -> None:
|
|||||||
assert [edit["path"] for edit in msgs[0]["fileEdits"]] == ["USER.md", "MEMORY.md"]
|
assert [edit["path"] for edit in msgs[0]["fileEdits"]] == ["USER.md", "MEMORY.md"]
|
||||||
|
|
||||||
|
|
||||||
def test_replay_keeps_interrupted_pre_tool_text_as_answer() -> None:
|
def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
|
||||||
msgs = replay_transcript_to_ui_messages([
|
msgs = replay_transcript_to_ui_messages([
|
||||||
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
|
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
|
||||||
{"event": "stream_end", "chat_id": "t-stream"},
|
{"event": "stream_end", "chat_id": "t-stream"},
|
||||||
@@ -1522,10 +1504,8 @@ def test_replay_keeps_interrupted_pre_tool_text_as_answer() -> None:
|
|||||||
|
|
||||||
assert len(msgs) == 3
|
assert len(msgs) == 3
|
||||||
assert msgs[0]["role"] == "assistant"
|
assert msgs[0]["role"] == "assistant"
|
||||||
assert msgs[0]["content"] == "I will inspect first."
|
assert msgs[0]["content"] == ""
|
||||||
assert msgs[0]["turnPhase"] == "answer"
|
assert msgs[0]["reasoning"] == "I will inspect first."
|
||||||
assert "reasoning" not in msgs[0]
|
|
||||||
assert "activitySegmentId" not in msgs[0]
|
|
||||||
assert "isStreaming" not in msgs[0]
|
assert "isStreaming" not in msgs[0]
|
||||||
assert msgs[1]["kind"] == "trace"
|
assert msgs[1]["kind"] == "trace"
|
||||||
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
|
assert msgs[1]["traces"] == ['exec({"cmd":"ls"})']
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
import websockets
|
import websockets
|
||||||
|
|
||||||
from nanobot.session.manager import SessionManager
|
|
||||||
from nanobot.session.recovery import PENDING_USER_TURN_KEY, RUNTIME_CHECKPOINT_KEY
|
|
||||||
|
|
||||||
_BOOTSTRAP_SECRET = "smoke-secret"
|
_BOOTSTRAP_SECRET = "smoke-secret"
|
||||||
|
|
||||||
|
|
||||||
@@ -209,84 +206,3 @@ async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Pa
|
|||||||
assert any("shell-ok" in text for text in contents)
|
assert any("shell-ok" in text for text in contents)
|
||||||
finally:
|
finally:
|
||||||
_stop_gateway(process)
|
_stop_gateway(process)
|
||||||
|
|
||||||
|
|
||||||
def test_gateway_restart_restores_a_completed_answer_without_replaying_model(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Exercise recovery through two real gateway processes and durable files."""
|
|
||||||
ws_port = _free_port()
|
|
||||||
gateway_port = _free_port()
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
config_path = tmp_path / "config.json"
|
|
||||||
first_log = tmp_path / "gateway-first.log"
|
|
||||||
second_log = tmp_path / "gateway-second.log"
|
|
||||||
_write_smoke_config(
|
|
||||||
config_path,
|
|
||||||
workspace=workspace,
|
|
||||||
ws_port=ws_port,
|
|
||||||
gateway_port=gateway_port,
|
|
||||||
)
|
|
||||||
base_url = f"http://127.0.0.1:{ws_port}"
|
|
||||||
|
|
||||||
first = _start_gateway(config_path, first_log)
|
|
||||||
try:
|
|
||||||
_wait_for_bootstrap(base_url, first, first_log)
|
|
||||||
finally:
|
|
||||||
_stop_gateway(first)
|
|
||||||
|
|
||||||
sessions_root = tmp_path / "sessions"
|
|
||||||
sessions = SessionManager(workspace, sessions_root=sessions_root)
|
|
||||||
session = sessions.get_or_create("websocket:recovery-smoke")
|
|
||||||
session.messages.append({"role": "user", "content": "recover this answer"})
|
|
||||||
session.metadata["webui"] = True
|
|
||||||
session.metadata[PENDING_USER_TURN_KEY] = True
|
|
||||||
session.metadata[RUNTIME_CHECKPOINT_KEY] = {
|
|
||||||
"phase": "final_response",
|
|
||||||
"assistant_message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "restored without another model request",
|
|
||||||
},
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
sessions.save(session, fsync=True)
|
|
||||||
|
|
||||||
second = _start_gateway(config_path, second_log)
|
|
||||||
try:
|
|
||||||
bootstrap = _wait_for_bootstrap(base_url, second, second_log)
|
|
||||||
deadline = time.monotonic() + 20
|
|
||||||
restored = None
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
restored = SessionManager(
|
|
||||||
workspace,
|
|
||||||
sessions_root=sessions_root,
|
|
||||||
).get_or_create("websocket:recovery-smoke")
|
|
||||||
if any(
|
|
||||||
message.get("content") == "restored without another model request"
|
|
||||||
for message in restored.messages
|
|
||||||
):
|
|
||||||
break
|
|
||||||
time.sleep(0.1)
|
|
||||||
else:
|
|
||||||
logs = second_log.read_text(encoding="utf-8", errors="replace")
|
|
||||||
raise AssertionError(f"answer was not recovered after restart\n{logs}")
|
|
||||||
|
|
||||||
assert restored is not None
|
|
||||||
assert PENDING_USER_TURN_KEY not in restored.metadata
|
|
||||||
assert RUNTIME_CHECKPOINT_KEY not in restored.metadata
|
|
||||||
assert restored.metadata["webui_recovery"]["reason"] == "answer_restored"
|
|
||||||
|
|
||||||
async def assert_attach_state() -> None:
|
|
||||||
ws_url = f'{bootstrap["ws_url"]}?token={bootstrap["token"]}&client_id=recovery-smoke'
|
|
||||||
async with websockets.connect(ws_url) as ws:
|
|
||||||
await _recv_until(ws, "ready")
|
|
||||||
await ws.send(json.dumps({"type": "attach", "chat_id": "recovery-smoke"}))
|
|
||||||
attached = await _recv_until(ws, "attached")
|
|
||||||
assert attached["recovery_state"]["status"] == "recovered"
|
|
||||||
assert attached["recovery_state"]["reason"] == "answer_restored"
|
|
||||||
|
|
||||||
asyncio.run(assert_attach_state())
|
|
||||||
finally:
|
|
||||||
_stop_gateway(second)
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
|||||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||||
from nanobot.session.recovery import RECOVERY_METADATA_KEY
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -67,30 +66,6 @@ def test_webui_session_list_refreshes_after_model_preset_rename(tmp_path: Path)
|
|||||||
assert list_webui_sessions(manager)[0]["model_preset"] == "Codex"
|
assert list_webui_sessions(manager)[0]["model_preset"] == "Codex"
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_surfaces_pending_recovery_state(tmp_path: Path) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
session = manager.get_or_create("websocket:needs-attention")
|
|
||||||
session.add_message("user", "the interrupted task")
|
|
||||||
session.metadata[RECOVERY_METADATA_KEY] = {
|
|
||||||
"status": "awaiting_user",
|
|
||||||
"recovery_id": "recovery-123",
|
|
||||||
"reason": "uncertain_tool_state",
|
|
||||||
"attempts": 1,
|
|
||||||
# Private checkpoint details must never leak into the sidebar index.
|
|
||||||
"checkpoint": {"tool_args": "secret"},
|
|
||||||
}
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
row = list_webui_sessions(manager)[0]
|
|
||||||
|
|
||||||
assert row["recovery_state"] == {
|
|
||||||
"status": "awaiting_user",
|
|
||||||
"recovery_id": "recovery-123",
|
|
||||||
"reason": "uncertain_tool_state",
|
|
||||||
"attempts": 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
|
def test_webui_session_index_uses_unique_temp_file(tmp_path: Path) -> None:
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
session = manager.get_or_create("websocket:unique-index-temp")
|
session = manager.get_or_create("websocket:unique-index-temp")
|
||||||
|
|||||||
+1
-225
@@ -7,12 +7,7 @@ import {
|
|||||||
} from "@opentui/core/testing"
|
} from "@opentui/core/testing"
|
||||||
|
|
||||||
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
|
import { NanobotTui, sessionExitMessage, type AppOptions } from "./app"
|
||||||
import type {
|
import type { MessageOptions, SlashCommand, WorkspaceScopePayload } from "./protocol"
|
||||||
MessageOptions,
|
|
||||||
RecoveryState,
|
|
||||||
SlashCommand,
|
|
||||||
WorkspaceScopePayload,
|
|
||||||
} from "./protocol"
|
|
||||||
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
|
import type { HostAgentState, HostMetadata, TuiHost } from "./host"
|
||||||
|
|
||||||
const options: AppOptions = {
|
const options: AppOptions = {
|
||||||
@@ -96,13 +91,6 @@ function client(
|
|||||||
setWorkspaceScope(scope: WorkspaceScopePayload) {
|
setWorkspaceScope(scope: WorkspaceScopePayload) {
|
||||||
scopes.push(scope)
|
scopes.push(scope)
|
||||||
},
|
},
|
||||||
updateRecovery(
|
|
||||||
_action: "continue" | "dismiss",
|
|
||||||
_chatId: string,
|
|
||||||
recoveryId: string,
|
|
||||||
): Promise<RecoveryState> {
|
|
||||||
return Promise.resolve({ status: "recovered" as const, recovery_id: recoveryId })
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,97 +555,6 @@ describe("NanobotTui layout", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switches away from a running session without losing its queued follow-ups", async () => {
|
|
||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
|
||||||
const original = globalThis.fetch
|
|
||||||
globalThis.fetch = ((input: string | URL | Request) => {
|
|
||||||
const url = String(input)
|
|
||||||
if (url.endsWith("/api/sessions")) {
|
|
||||||
return Promise.resolve(new Response(JSON.stringify({
|
|
||||||
sessions: [
|
|
||||||
{ key: "websocket:chat", title: "Running chat", run_started_at: 1_700_000_000 },
|
|
||||||
{ key: "websocket:other", title: "Other chat" },
|
|
||||||
],
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
if (url.endsWith("/api/webui/sidebar-state")) {
|
|
||||||
return Promise.resolve(new Response(JSON.stringify({})))
|
|
||||||
}
|
|
||||||
return Promise.resolve(new Response(JSON.stringify({
|
|
||||||
messages: [],
|
|
||||||
page: { has_more_before: false },
|
|
||||||
})))
|
|
||||||
}) as typeof fetch
|
|
||||||
const sent: string[] = []
|
|
||||||
const attached: string[] = []
|
|
||||||
let activeChatId = "chat"
|
|
||||||
const base = client(sent, attached)
|
|
||||||
const transport = {
|
|
||||||
...base,
|
|
||||||
get activeChatId() { return activeChatId },
|
|
||||||
attach(chatId: string) {
|
|
||||||
attached.push(chatId)
|
|
||||||
activeChatId = chatId
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const app = NanobotTui.mount(
|
|
||||||
setup.renderer,
|
|
||||||
{ ...options, apiUrl: "http://nanobot.test", apiToken: "secret" },
|
|
||||||
transport,
|
|
||||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
|
||||||
)
|
|
||||||
const ui = app as unknown as {
|
|
||||||
ready: boolean
|
|
||||||
activeTurn: boolean
|
|
||||||
composer: TextareaRenderable
|
|
||||||
sessionMenu: { visible: boolean }
|
|
||||||
queuePreview: { root: { visible: boolean } }
|
|
||||||
status: { plainText: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
app.accept({ event: "attached", chat_id: "chat" })
|
|
||||||
await waitUntil(() => ui.ready)
|
|
||||||
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
|
|
||||||
ui.composer.setText("follow up in chat")
|
|
||||||
setup.mockInput.pressTab()
|
|
||||||
await waitUntil(() => ui.composer.plainText === "")
|
|
||||||
expect(ui.queuePreview.root.visible).toBe(true)
|
|
||||||
|
|
||||||
ui.composer.setText("/sessions")
|
|
||||||
ui.composer.submit()
|
|
||||||
await waitUntil(() => ui.sessionMenu.visible)
|
|
||||||
await Bun.sleep(120)
|
|
||||||
expect(ui.status.plainText).toContain("2 sessions")
|
|
||||||
|
|
||||||
ui.composer.setText("other")
|
|
||||||
ui.composer.submit()
|
|
||||||
await waitUntil(() => attached.at(-1) === "other")
|
|
||||||
app.accept({ event: "attached", chat_id: "other" })
|
|
||||||
await waitUntil(() => ui.ready)
|
|
||||||
expect(ui.activeTurn).toBe(false)
|
|
||||||
expect(ui.queuePreview.root.visible).toBe(false)
|
|
||||||
|
|
||||||
ui.composer.setText("/sessions")
|
|
||||||
ui.composer.submit()
|
|
||||||
await waitUntil(() => ui.sessionMenu.visible)
|
|
||||||
ui.composer.setText("running")
|
|
||||||
ui.composer.submit()
|
|
||||||
await waitUntil(() => attached.at(-1) === "chat")
|
|
||||||
app.accept({ event: "attached", chat_id: "chat" })
|
|
||||||
app.accept({ event: "goal_status", chat_id: "chat", status: "running", turn_id: "turn" })
|
|
||||||
await waitUntil(() => ui.ready && ui.activeTurn)
|
|
||||||
expect(ui.queuePreview.root.visible).toBe(true)
|
|
||||||
expect(sent).toEqual([])
|
|
||||||
|
|
||||||
app.accept({ event: "turn_end", chat_id: "chat", turn_id: "turn" })
|
|
||||||
await waitUntil(() => sent.length === 1)
|
|
||||||
expect(sent).toEqual(["follow up in chat"])
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = original
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("refreshes expired API credentials before opening sessions", async () => {
|
test("refreshes expired API credentials before opening sessions", async () => {
|
||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
@@ -1006,127 +903,6 @@ describe("NanobotTui layout", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("offers clickable recovery actions without letting a late response revive stale state", async () => {
|
|
||||||
setup = await createRenderer({ width: 96, height: 24, screenMode: "alternate-screen" })
|
|
||||||
const calls: Array<{ action: string; chatId: string; recoveryId: string }> = []
|
|
||||||
let deferredResolve: ((state: RecoveryState) => void) | undefined
|
|
||||||
const recoveryClient = client()
|
|
||||||
recoveryClient.updateRecovery = (action, chatId, recoveryId) => {
|
|
||||||
calls.push({ action, chatId, recoveryId })
|
|
||||||
if (recoveryId === "recovery-1") {
|
|
||||||
return Promise.resolve({ status: "resuming", recovery_id: recoveryId })
|
|
||||||
}
|
|
||||||
if (action === "dismiss") {
|
|
||||||
return Promise.resolve({ status: "recovered", recovery_id: recoveryId })
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => { deferredResolve = resolve })
|
|
||||||
}
|
|
||||||
const app = NanobotTui.mount(
|
|
||||||
setup.renderer,
|
|
||||||
options,
|
|
||||||
recoveryClient,
|
|
||||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
|
||||||
)
|
|
||||||
app.accept({
|
|
||||||
event: "attached",
|
|
||||||
chat_id: "chat",
|
|
||||||
recovery_state: {
|
|
||||||
status: "awaiting_user",
|
|
||||||
recovery_id: "recovery-1",
|
|
||||||
reason: "tool execution interrupted",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const ui = app as unknown as {
|
|
||||||
activeTurn: boolean
|
|
||||||
composer: TextareaRenderable
|
|
||||||
recoveryNotice: {
|
|
||||||
visible: boolean
|
|
||||||
dismiss: TextRenderable
|
|
||||||
resume: TextRenderable
|
|
||||||
}
|
|
||||||
status: TextRenderable
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitUntil(() => (app as unknown as { ready: boolean }).ready)
|
|
||||||
await setup.renderOnce()
|
|
||||||
expect(setup.captureCharFrame()).toContain("⚠ Task interrupted")
|
|
||||||
expect(setup.captureCharFrame()).toContain("Tools will not replay automatically")
|
|
||||||
expect(ui.status.plainText).toContain("continue or dismiss")
|
|
||||||
expect(ui.activeTurn).toBe(false)
|
|
||||||
expect(ui.composer.focused).toBe(true)
|
|
||||||
|
|
||||||
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
|
|
||||||
await waitUntil(() => calls.length === 1 && ui.activeTurn)
|
|
||||||
expect(calls[0]).toEqual({
|
|
||||||
action: "continue",
|
|
||||||
chatId: "chat",
|
|
||||||
recoveryId: "recovery-1",
|
|
||||||
})
|
|
||||||
expect(ui.recoveryNotice.visible).toBe(false)
|
|
||||||
expect(ui.status.plainText).toContain("Continuing")
|
|
||||||
|
|
||||||
app.accept({
|
|
||||||
event: "recovery_state",
|
|
||||||
chat_id: "chat",
|
|
||||||
status: "awaiting_user",
|
|
||||||
recovery_id: "recovery-2",
|
|
||||||
})
|
|
||||||
await setup.renderOnce()
|
|
||||||
await setup.mockMouse.click(ui.recoveryNotice.resume.x + 1, ui.recoveryNotice.resume.y)
|
|
||||||
await waitUntil(() => calls.length === 2)
|
|
||||||
app.accept({
|
|
||||||
event: "recovery_state",
|
|
||||||
chat_id: "chat",
|
|
||||||
status: "recovered",
|
|
||||||
recovery_id: "recovery-2",
|
|
||||||
})
|
|
||||||
deferredResolve?.({ status: "resuming", recovery_id: "recovery-2" })
|
|
||||||
await Bun.sleep(1)
|
|
||||||
|
|
||||||
expect(ui.recoveryNotice.visible).toBe(false)
|
|
||||||
expect(ui.activeTurn).toBe(false)
|
|
||||||
expect(ui.composer.focused).toBe(true)
|
|
||||||
|
|
||||||
app.accept({
|
|
||||||
event: "recovery_state",
|
|
||||||
chat_id: "chat",
|
|
||||||
status: "awaiting_user",
|
|
||||||
recovery_id: "recovery-unavailable",
|
|
||||||
can_continue: false,
|
|
||||||
})
|
|
||||||
await setup.renderOnce()
|
|
||||||
const unavailableFrame = setup.captureCharFrame()
|
|
||||||
expect(unavailableFrame).toContain("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 () => {
|
test("preserves gateway slash lifecycle while local navigation stays in the same menu", async () => {
|
||||||
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
setup = await createRenderer({ width: 80, height: 24, screenMode: "alternate-screen" })
|
||||||
const sent: string[] = []
|
const sent: string[] = []
|
||||||
|
|||||||
+20
-220
@@ -34,7 +34,6 @@ import {
|
|||||||
type InboundEvent,
|
type InboundEvent,
|
||||||
type MentionCandidate,
|
type MentionCandidate,
|
||||||
type MessageOptions,
|
type MessageOptions,
|
||||||
type RecoveryState,
|
|
||||||
type SlashCommand,
|
type SlashCommand,
|
||||||
type SessionSummary,
|
type SessionSummary,
|
||||||
type TokenUsage,
|
type TokenUsage,
|
||||||
@@ -71,7 +70,6 @@ import {
|
|||||||
} from "./mention-menu"
|
} from "./mention-menu"
|
||||||
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
import { PromptQueue, type QueuedPrompt } from "./prompt-queue"
|
||||||
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
|
import { QueuePreview, type QueuePreviewTheme } from "./queue-preview"
|
||||||
import { RecoveryNotice, type RecoveryNoticeTheme } from "./recovery-notice"
|
|
||||||
import { RuntimeControls } from "./runtime-controls"
|
import { RuntimeControls } from "./runtime-controls"
|
||||||
import {
|
import {
|
||||||
contextualFooterHints,
|
contextualFooterHints,
|
||||||
@@ -109,11 +107,6 @@ interface ChatClient {
|
|||||||
newChat(scope?: WorkspaceScopePayload): void
|
newChat(scope?: WorkspaceScopePayload): void
|
||||||
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
|
forkChat?(sourceChatId: string, beforeUserIndex: number, title?: string): void
|
||||||
setWorkspaceScope(scope: WorkspaceScopePayload): void
|
setWorkspaceScope(scope: WorkspaceScopePayload): void
|
||||||
updateRecovery(
|
|
||||||
action: "continue" | "dismiss",
|
|
||||||
chatId: string,
|
|
||||||
recoveryId: string,
|
|
||||||
): Promise<RecoveryState>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Palette {
|
interface Palette {
|
||||||
@@ -125,7 +118,6 @@ interface Palette {
|
|||||||
accent: string
|
accent: string
|
||||||
link: string
|
link: string
|
||||||
success: string
|
success: string
|
||||||
warning: string
|
|
||||||
error: string
|
error: string
|
||||||
user: string
|
user: string
|
||||||
userBackground: string
|
userBackground: string
|
||||||
@@ -142,7 +134,6 @@ const DARK: Palette = {
|
|||||||
accent: "#EF8E30",
|
accent: "#EF8E30",
|
||||||
link: "#60A5FA",
|
link: "#60A5FA",
|
||||||
success: "#5CC489",
|
success: "#5CC489",
|
||||||
warning: "#F5C451",
|
|
||||||
error: "#F87171",
|
error: "#F87171",
|
||||||
user: "#EF8E30",
|
user: "#EF8E30",
|
||||||
// Codex-style turn anchor: 12% white over the reference dark background.
|
// Codex-style turn anchor: 12% white over the reference dark background.
|
||||||
@@ -160,7 +151,6 @@ const LIGHT: Palette = {
|
|||||||
accent: "#B94D0B",
|
accent: "#B94D0B",
|
||||||
link: "#1D4ED8",
|
link: "#1D4ED8",
|
||||||
success: "#166534",
|
success: "#166534",
|
||||||
warning: "#A16207",
|
|
||||||
error: "#B91C1C",
|
error: "#B91C1C",
|
||||||
user: "#B94D0B",
|
user: "#B94D0B",
|
||||||
// Codex-style turn anchor: 4% black over the reference light background.
|
// Codex-style turn anchor: 4% black over the reference light background.
|
||||||
@@ -174,7 +164,6 @@ const ACTIVE_COMPOSER_PLACEHOLDER = "Steer this turn…"
|
|||||||
const SHIMMER_PAUSE = 16
|
const SHIMMER_PAUSE = 16
|
||||||
const SHIMMER_BAND = 4
|
const SHIMMER_BAND = 4
|
||||||
const SHIMMER_INTERVAL_MS = 80
|
const SHIMMER_INTERVAL_MS = 80
|
||||||
const SESSION_REFRESH_INTERVAL_MS = 1_000
|
|
||||||
const LOCAL_COMMANDS: TuiCommand[] = [
|
const LOCAL_COMMANDS: TuiCommand[] = [
|
||||||
{
|
{
|
||||||
command: "/sessions",
|
command: "/sessions",
|
||||||
@@ -263,8 +252,6 @@ function commandMenuTheme(palette: Palette): CommandMenuTheme {
|
|||||||
text: palette.text,
|
text: palette.text,
|
||||||
muted: palette.muted,
|
muted: palette.muted,
|
||||||
border: palette.border,
|
border: palette.border,
|
||||||
accent: palette.accent,
|
|
||||||
warning: palette.warning,
|
|
||||||
selectedBackground: palette.userBackground,
|
selectedBackground: palette.userBackground,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,17 +295,6 @@ function queuePreviewTheme(palette: Palette): QueuePreviewTheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function recoveryNoticeTheme(palette: Palette): RecoveryNoticeTheme {
|
|
||||||
return {
|
|
||||||
text: palette.text,
|
|
||||||
muted: palette.muted,
|
|
||||||
border: palette.border,
|
|
||||||
accent: palette.accent,
|
|
||||||
warning: palette.warning,
|
|
||||||
error: palette.error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function footerHintTheme(palette: Palette): FooterHintTheme {
|
function footerHintTheme(palette: Palette): FooterHintTheme {
|
||||||
return {
|
return {
|
||||||
accent: palette.accent,
|
accent: palette.accent,
|
||||||
@@ -404,7 +380,6 @@ export class NanobotTui {
|
|||||||
private readonly contextPanel: ContextPanel
|
private readonly contextPanel: ContextPanel
|
||||||
private readonly diffViewer: DiffViewer
|
private readonly diffViewer: DiffViewer
|
||||||
private readonly queuePreview: QueuePreview
|
private readonly queuePreview: QueuePreview
|
||||||
private readonly recoveryNotice: RecoveryNotice
|
|
||||||
private readonly client: ChatClient
|
private readonly client: ChatClient
|
||||||
private readonly shell: BoxRenderable
|
private readonly shell: BoxRenderable
|
||||||
private readonly title: BoxRenderable
|
private readonly title: BoxRenderable
|
||||||
@@ -415,8 +390,7 @@ export class NanobotTui {
|
|||||||
private readonly meta: TextRenderable
|
private readonly meta: TextRenderable
|
||||||
private readonly host: TuiHost
|
private readonly host: TuiHost
|
||||||
private readonly draft = new ComposerDraft()
|
private readonly draft = new ComposerDraft()
|
||||||
private readonly promptQueues = new Map<string, PromptQueue>()
|
private readonly promptQueue = new PromptQueue()
|
||||||
private currentChatId = ""
|
|
||||||
private palette: Palette
|
private palette: Palette
|
||||||
private activeThemeMode: ThemeMode
|
private activeThemeMode: ThemeMode
|
||||||
private backgroundKnown: boolean
|
private backgroundKnown: boolean
|
||||||
@@ -462,8 +436,6 @@ export class NanobotTui {
|
|||||||
private quitting = false
|
private quitting = false
|
||||||
private sessionLoadId = 0
|
private sessionLoadId = 0
|
||||||
private sessionLoading = false
|
private sessionLoading = false
|
||||||
private sessionRefreshPending = false
|
|
||||||
private sessionRefreshTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
private readonly commandTurns = new Map<string, ResolvedSlashCommandLifecycle>()
|
||||||
private readonly modelCommandTurns = new Set<string>()
|
private readonly modelCommandTurns = new Set<string>()
|
||||||
private readonly silentCommandTurns = new Set<string>()
|
private readonly silentCommandTurns = new Set<string>()
|
||||||
@@ -472,8 +444,6 @@ export class NanobotTui {
|
|||||||
private currentTask = ""
|
private currentTask = ""
|
||||||
private currentAction = ""
|
private currentAction = ""
|
||||||
private hostBlocked = false
|
private hostBlocked = false
|
||||||
private recoveryState: RecoveryState | null = null
|
|
||||||
private recoveryPending = false
|
|
||||||
private hostWorkspace: string
|
private hostWorkspace: string
|
||||||
private hostBranch: string
|
private hostBranch: string
|
||||||
private readonly apiReauthenticator: ApiReauthenticator | undefined
|
private readonly apiReauthenticator: ApiReauthenticator | undefined
|
||||||
@@ -525,14 +495,6 @@ export class NanobotTui {
|
|||||||
treeSitterClient,
|
treeSitterClient,
|
||||||
)
|
)
|
||||||
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
|
this.queuePreview = new QueuePreview(renderer, queuePreviewTheme(this.palette))
|
||||||
this.recoveryNotice = new RecoveryNotice(
|
|
||||||
renderer,
|
|
||||||
recoveryNoticeTheme(this.palette),
|
|
||||||
{
|
|
||||||
onContinue: () => void this.updateRecovery("continue"),
|
|
||||||
onDismiss: () => void this.updateRecovery("dismiss"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
this.client = client || new NanobotClient({
|
this.client = client || new NanobotClient({
|
||||||
...(options.bootstrapUrl
|
...(options.bootstrapUrl
|
||||||
? {
|
? {
|
||||||
@@ -761,7 +723,6 @@ export class NanobotTui {
|
|||||||
this.shell.add(this.runtimeControls.menuRoot)
|
this.shell.add(this.runtimeControls.menuRoot)
|
||||||
if (!host.hosted) this.shell.add(this.title)
|
if (!host.hosted) this.shell.add(this.title)
|
||||||
this.shell.add(this.queuePreview.root)
|
this.shell.add(this.queuePreview.root)
|
||||||
this.shell.add(this.recoveryNotice.root)
|
|
||||||
this.shell.add(this.composerFrame)
|
this.shell.add(this.composerFrame)
|
||||||
this.shell.add(statusRow)
|
this.shell.add(statusRow)
|
||||||
this.shell.add(this.diffViewer.root)
|
this.shell.add(this.diffViewer.root)
|
||||||
@@ -870,12 +831,6 @@ export class NanobotTui {
|
|||||||
this.quit()
|
this.quit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (["/continue", "/dismiss"].includes(visibleContent.toLowerCase())) {
|
|
||||||
this.clearComposer()
|
|
||||||
this.commandMenu.hide()
|
|
||||||
void this.updateRecovery(visibleContent.toLowerCase() === "/continue" ? "continue" : "dismiss")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const completion = this.commandMenu.completion(visibleContent)
|
const completion = this.commandMenu.completion(visibleContent)
|
||||||
if (completion) {
|
if (completion) {
|
||||||
this.setComposer(completion)
|
this.setComposer(completion)
|
||||||
@@ -970,8 +925,6 @@ export class NanobotTui {
|
|||||||
|
|
||||||
accept(event: InboundEvent): void {
|
accept(event: InboundEvent): void {
|
||||||
if (event.event === "attached") {
|
if (event.event === "attached") {
|
||||||
const switchedSession = Boolean(this.currentChatId && this.currentChatId !== event.chat_id)
|
|
||||||
this.currentChatId = event.chat_id
|
|
||||||
this.host.reportSession(event.chat_id)
|
this.host.reportSession(event.chat_id)
|
||||||
if (event.usage) this.lastUsage = event.usage
|
if (event.usage) this.lastUsage = event.usage
|
||||||
if (event.model_preset !== undefined) {
|
if (event.model_preset !== undefined) {
|
||||||
@@ -993,11 +946,7 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
const hydrationId = ++this.hydrationId
|
const hydrationId = ++this.hydrationId
|
||||||
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
|
void this.prepareChat(event.chat_id, restoring, hydrationId).then(() => {
|
||||||
if (hydrationId !== this.hydrationId) return
|
if (hydrationId === this.hydrationId) this.flushPendingEvents()
|
||||||
this.applyRecoveryState(event.recovery_state ?? null)
|
|
||||||
this.flushPendingEvents()
|
|
||||||
this.syncQueuePreview()
|
|
||||||
if (switchedSession) this.sendNextFollowUp()
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1134,9 +1083,6 @@ export class NanobotTui {
|
|||||||
this.applyHostGoalState(event.goal_state)
|
this.applyHostGoalState(event.goal_state)
|
||||||
if (!this.activeTurn) this.reportHostResting()
|
if (!this.activeTurn) this.reportHostResting()
|
||||||
return
|
return
|
||||||
case "recovery_state":
|
|
||||||
this.applyRecoveryState(event)
|
|
||||||
return
|
|
||||||
case "turn_model_updated":
|
case "turn_model_updated":
|
||||||
if (typeof event.context_window_tokens === "number") {
|
if (typeof event.context_window_tokens === "number") {
|
||||||
this.contextWindowTokens = event.context_window_tokens
|
this.contextWindowTokens = event.context_window_tokens
|
||||||
@@ -1245,87 +1191,6 @@ export class NanobotTui {
|
|||||||
for (const event of events || []) this.accept(event)
|
for (const event of events || []) this.accept(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearRecoveryState(): void {
|
|
||||||
this.recoveryState = null
|
|
||||||
this.recoveryPending = false
|
|
||||||
this.recoveryNotice.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyRecoveryState(state: RecoveryState | null): void {
|
|
||||||
if (!state) {
|
|
||||||
this.clearRecoveryState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.recoveryState = state
|
|
||||||
this.recoveryPending = false
|
|
||||||
if (state.status === "resuming") {
|
|
||||||
this.recoveryNotice.hide()
|
|
||||||
this.hostBlocked = false
|
|
||||||
this.activeLabel = "Continuing"
|
|
||||||
this.setCurrentAction("Continuing interrupted task")
|
|
||||||
this.setActive(true)
|
|
||||||
this.reportHostWorking()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (state.status === "awaiting_user" || state.status === "failed") {
|
|
||||||
this.activeTurnId = null
|
|
||||||
this.setActive(false)
|
|
||||||
this.hostBlocked = true
|
|
||||||
this.recoveryNotice.show(state)
|
|
||||||
const detail = state.reason || (state.status === "failed"
|
|
||||||
? "Recovery failed"
|
|
||||||
: "Task interrupted")
|
|
||||||
this.setCurrentAction(detail)
|
|
||||||
this.status.content = state.can_continue === false
|
|
||||||
? "Interrupted · dismiss to start a new message"
|
|
||||||
: "Interrupted · continue or dismiss"
|
|
||||||
this.host.reportState("blocked", detail)
|
|
||||||
this.composer.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.clearRecoveryState()
|
|
||||||
this.activeTurnId = null
|
|
||||||
this.hostBlocked = false
|
|
||||||
this.setActive(false)
|
|
||||||
if (this.ready) this.status.content = this.readyStatus()
|
|
||||||
this.reportHostResting()
|
|
||||||
}
|
|
||||||
|
|
||||||
private async updateRecovery(action: "continue" | "dismiss"): Promise<void> {
|
|
||||||
const state = this.recoveryState
|
|
||||||
if (
|
|
||||||
!state
|
|
||||||
|| (state.status !== "awaiting_user" && state.status !== "failed")
|
|
||||||
|| (action === "continue" && state.can_continue === false)
|
|
||||||
) {
|
|
||||||
this.status.content = "No interrupted task"
|
|
||||||
this.composer.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.recoveryPending) return
|
|
||||||
this.recoveryPending = true
|
|
||||||
this.recoveryNotice.setBusy(true)
|
|
||||||
this.status.content = action === "continue" ? "Continuing…" : "Dismissing…"
|
|
||||||
try {
|
|
||||||
const next = await this.client.updateRecovery(
|
|
||||||
action,
|
|
||||||
this.client.activeChatId,
|
|
||||||
state.recovery_id,
|
|
||||||
)
|
|
||||||
if (this.recoveryState?.recovery_id === state.recovery_id) {
|
|
||||||
this.applyRecoveryState(next)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (this.recoveryState?.recovery_id !== state.recovery_id) return
|
|
||||||
this.recoveryPending = false
|
|
||||||
this.recoveryNotice.setBusy(false)
|
|
||||||
this.status.content = error instanceof Error ? error.message : String(error)
|
|
||||||
this.host.reportState("blocked", state.reason || "Task interrupted")
|
|
||||||
} finally {
|
|
||||||
this.composer.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateGatewayApiConnection(apiUrl: string, apiToken: string): void {
|
private updateGatewayApiConnection(apiUrl: string, apiToken: string): void {
|
||||||
this.options.apiUrl = apiUrl
|
this.options.apiUrl = apiUrl
|
||||||
this.options.apiToken = apiToken
|
this.options.apiToken = apiToken
|
||||||
@@ -1417,7 +1282,6 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderActiveStatus(): void {
|
private renderActiveStatus(): void {
|
||||||
if (this.sessionLoading || this.sessionMenu.visible) return
|
|
||||||
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
|
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
|
||||||
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
|
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
|
||||||
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
|
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
|
||||||
@@ -1447,16 +1311,6 @@ export class NanobotTui {
|
|||||||
this.sendPrompt(prompt)
|
this.sendPrompt(prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
private get promptQueue(): PromptQueue {
|
|
||||||
const chatId = this.currentChatId || this.client.activeChatId
|
|
||||||
let queue = this.promptQueues.get(chatId)
|
|
||||||
if (!queue) {
|
|
||||||
queue = new PromptQueue()
|
|
||||||
this.promptQueues.set(chatId, queue)
|
|
||||||
}
|
|
||||||
return queue
|
|
||||||
}
|
|
||||||
|
|
||||||
private restoreQueuedPrompts(): void {
|
private restoreQueuedPrompts(): void {
|
||||||
const queued = this.promptQueue.restore()
|
const queued = this.promptQueue.restore()
|
||||||
if (!queued.length) return
|
if (!queued.length) return
|
||||||
@@ -1722,7 +1576,6 @@ export class NanobotTui {
|
|||||||
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
this.contextPanel.setTheme(contextPanelTheme(this.palette))
|
||||||
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
this.diffViewer.setTheme(diffViewerTheme(this.palette, this.backgroundKnown))
|
||||||
this.queuePreview.setTheme(queuePreviewTheme(this.palette))
|
this.queuePreview.setTheme(queuePreviewTheme(this.palette))
|
||||||
this.recoveryNotice.setTheme(recoveryNoticeTheme(this.palette))
|
|
||||||
this.updateComposerAppearance()
|
this.updateComposerAppearance()
|
||||||
this.composer.textColor = this.palette.text
|
this.composer.textColor = this.palette.text
|
||||||
this.composer.focusedTextColor = this.palette.text
|
this.composer.focusedTextColor = this.palette.text
|
||||||
@@ -2005,7 +1858,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private closeTransientMenus(): void {
|
private closeTransientMenus(): void {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.hideSessionMenu()
|
this.sessionMenu.hide()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
@@ -2059,7 +1912,7 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.hideSessionMenu()
|
this.sessionMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.status.content = "Loading branch points…"
|
this.status.content = "Loading branch points…"
|
||||||
@@ -2121,6 +1974,10 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async openSessions(): Promise<void> {
|
private async openSessions(): Promise<void> {
|
||||||
|
if (this.activeTurn) {
|
||||||
|
this.status.content = "Wait for the current turn or press Ctrl+C"
|
||||||
|
return
|
||||||
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.dismissRuntimeControls()
|
this.dismissRuntimeControls()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
@@ -2144,17 +2001,10 @@ export class NanobotTui {
|
|||||||
this.sessionTitle = sessionLabel(current)
|
this.sessionTitle = sessionLabel(current)
|
||||||
this.applySessionModel(current)
|
this.applySessionModel(current)
|
||||||
this.applySessionScope(current)
|
this.applySessionScope(current)
|
||||||
this.applyRecoveryState(current.recoveryState ?? null)
|
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
}
|
}
|
||||||
const limit = this.renderer.height >= 20 ? 8 : 4
|
const limit = this.renderer.height >= 20 ? 8 : 4
|
||||||
this.sessionMenu.open(
|
this.sessionMenu.open(sessions, this.client.activeChatId, limit)
|
||||||
sessions,
|
|
||||||
this.client.activeChatId,
|
|
||||||
limit,
|
|
||||||
this.defaultModelPreset,
|
|
||||||
)
|
|
||||||
this.startSessionRefresh()
|
|
||||||
this.renderTitleColor()
|
this.renderTitleColor()
|
||||||
this.sessionMenu.update(this.composer.plainText, limit)
|
this.sessionMenu.update(this.composer.plainText, limit)
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
@@ -2169,14 +2019,17 @@ export class NanobotTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private switchSession(session: SessionSummary): void {
|
private switchSession(session: SessionSummary): void {
|
||||||
this.sessionMenu.markRead(session.chatId)
|
if (this.activeTurn) {
|
||||||
|
this.status.content = "Wait for the current turn or press Ctrl+C"
|
||||||
|
return
|
||||||
|
}
|
||||||
if (session.chatId === this.client.activeChatId) {
|
if (session.chatId === this.client.activeChatId) {
|
||||||
this.sessionTitle = sessionLabel(session)
|
this.sessionTitle = sessionLabel(session)
|
||||||
this.applySessionModel(session)
|
this.applySessionModel(session)
|
||||||
this.applySessionScope(session)
|
this.applySessionScope(session)
|
||||||
this.applyRecoveryState(session.recoveryState ?? null)
|
|
||||||
this.updateTitle()
|
this.updateTitle()
|
||||||
this.closeSessions()
|
this.closeSessions()
|
||||||
|
this.status.content = this.readyStatus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.ready) {
|
if (!this.ready) {
|
||||||
@@ -2186,10 +2039,7 @@ export class NanobotTui {
|
|||||||
this.closeSessions()
|
this.closeSessions()
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
this.activeTurnId = null
|
this.clearPromptQueue()
|
||||||
this.setActive(false)
|
|
||||||
this.clearRecoveryState()
|
|
||||||
this.queuePreview.update([])
|
|
||||||
this.sessionMetadataId += 1
|
this.sessionMetadataId += 1
|
||||||
this.clearHostContext()
|
this.clearHostContext()
|
||||||
this.sessionTitle = sessionLabel(session)
|
this.sessionTitle = sessionLabel(session)
|
||||||
@@ -2216,14 +2066,13 @@ export class NanobotTui {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.hideSessionMenu()
|
this.sessionMenu.hide()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
try {
|
try {
|
||||||
this.ready = false
|
this.ready = false
|
||||||
this.clearRecoveryState()
|
|
||||||
this.clearPromptQueue()
|
this.clearPromptQueue()
|
||||||
this.sessionMetadataId += 1
|
this.sessionMetadataId += 1
|
||||||
this.clearHostContext()
|
this.clearHostContext()
|
||||||
@@ -2324,65 +2173,18 @@ export class NanobotTui {
|
|||||||
private closeSessions(): void {
|
private closeSessions(): void {
|
||||||
this.sessionLoadId += 1
|
this.sessionLoadId += 1
|
||||||
this.sessionLoading = false
|
this.sessionLoading = false
|
||||||
this.hideSessionMenu()
|
this.sessionMenu.hide()
|
||||||
this.renderTitleColor()
|
this.renderTitleColor()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
this.syncComposerPlaceholder()
|
this.syncComposerPlaceholder()
|
||||||
this.composer.focus()
|
this.composer.focus()
|
||||||
if (this.activeTurn) this.renderActiveStatus()
|
if (!this.activeTurn && this.ready) this.status.content = this.readyStatus()
|
||||||
else if (this.ready) this.status.content = this.readyStatus()
|
|
||||||
this.updateMeta()
|
this.updateMeta()
|
||||||
}
|
}
|
||||||
|
|
||||||
private hideSessionMenu(): void {
|
|
||||||
this.stopSessionRefresh()
|
|
||||||
this.sessionMenu.hide()
|
|
||||||
}
|
|
||||||
|
|
||||||
private startSessionRefresh(): void {
|
|
||||||
if (this.sessionRefreshTimer) return
|
|
||||||
this.sessionRefreshTimer = setInterval(() => {
|
|
||||||
if (!this.sessionMenu.visible) {
|
|
||||||
this.stopSessionRefresh()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void this.refreshSessionMenu()
|
|
||||||
}, SESSION_REFRESH_INTERVAL_MS)
|
|
||||||
;(this.sessionRefreshTimer as unknown as { unref?: () => void }).unref?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopSessionRefresh(): void {
|
|
||||||
if (this.sessionRefreshTimer) clearInterval(this.sessionRefreshTimer)
|
|
||||||
this.sessionRefreshTimer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refreshSessionMenu(): Promise<void> {
|
|
||||||
if (this.sessionRefreshPending || !this.sessionMenu.visible || this.quitting) return
|
|
||||||
this.sessionRefreshPending = true
|
|
||||||
const loadId = this.sessionLoadId
|
|
||||||
try {
|
|
||||||
const sessions = await fetchSessions(
|
|
||||||
this.options.apiUrl,
|
|
||||||
this.options.apiToken,
|
|
||||||
this.apiReauthenticator,
|
|
||||||
)
|
|
||||||
if (this.quitting || loadId !== this.sessionLoadId || !this.sessionMenu.visible) return
|
|
||||||
this.sessionMenu.replace(
|
|
||||||
sessions,
|
|
||||||
this.client.activeChatId,
|
|
||||||
this.defaultModelPreset,
|
|
||||||
)
|
|
||||||
this.status.content = sessions.length ? `${sessions.length} sessions` : "No saved sessions"
|
|
||||||
} catch {
|
|
||||||
// Keep the existing picker usable during a transient refresh failure.
|
|
||||||
} finally {
|
|
||||||
this.sessionRefreshPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async openContext(): Promise<void> {
|
private async openContext(): Promise<void> {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.hideSessionMenu()
|
this.sessionMenu.hide()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.clearComposer()
|
this.clearComposer()
|
||||||
@@ -2454,7 +2256,7 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private openDiff(): void {
|
private openDiff(): void {
|
||||||
this.commandMenu.hide()
|
this.commandMenu.hide()
|
||||||
this.hideSessionMenu()
|
this.sessionMenu.hide()
|
||||||
this.mentionMenu.hide()
|
this.mentionMenu.hide()
|
||||||
this.branchMenu.hide()
|
this.branchMenu.hide()
|
||||||
this.contextPanel.hide()
|
this.contextPanel.hide()
|
||||||
@@ -2518,7 +2320,6 @@ export class NanobotTui {
|
|||||||
this.quitting = true
|
this.quitting = true
|
||||||
this.submitGeneration += 1
|
this.submitGeneration += 1
|
||||||
this.submitPending = false
|
this.submitPending = false
|
||||||
this.stopSessionRefresh()
|
|
||||||
this.host.release()
|
this.host.release()
|
||||||
this.client.close()
|
this.client.close()
|
||||||
this.renderer.destroy()
|
this.renderer.destroy()
|
||||||
@@ -2529,7 +2330,6 @@ export class NanobotTui {
|
|||||||
|
|
||||||
private handleDestroy = (): void => {
|
private handleDestroy = (): void => {
|
||||||
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
if (this.shimmerTimer) clearInterval(this.shimmerTimer)
|
||||||
this.stopSessionRefresh()
|
|
||||||
this.transcript.destroy()
|
this.transcript.destroy()
|
||||||
this.diffViewer.destroy()
|
this.diffViewer.destroy()
|
||||||
this.host.release()
|
this.host.release()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
|||||||
|
|
||||||
export type CommandMenuTheme = PickerMenuTheme
|
export type CommandMenuTheme = PickerMenuTheme
|
||||||
|
|
||||||
type TuiCommandAction =
|
export type TuiCommandAction =
|
||||||
| "sessions"
|
| "sessions"
|
||||||
| "new-chat"
|
| "new-chat"
|
||||||
| "context"
|
| "context"
|
||||||
|
|||||||
+3
-46
@@ -1,27 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
BoxRenderable,
|
BoxRenderable,
|
||||||
RGBA,
|
RGBA,
|
||||||
StyledText,
|
|
||||||
TextAttributes,
|
TextAttributes,
|
||||||
TextRenderable,
|
TextRenderable,
|
||||||
type CliRenderer,
|
type CliRenderer,
|
||||||
type TextChunk,
|
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
|
|
||||||
export interface PickerMenuTheme {
|
export interface PickerMenuTheme {
|
||||||
text: string
|
text: string
|
||||||
muted: string
|
muted: string
|
||||||
border: string
|
border: string
|
||||||
accent?: string
|
|
||||||
warning?: string
|
|
||||||
selectedBackground?: string
|
selectedBackground?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PickerMenuOptions<T> {
|
interface PickerMenuOptions<T> {
|
||||||
id: string
|
id: string
|
||||||
key?: (item: T) => string
|
|
||||||
searchText: (item: T) => string
|
searchText: (item: T) => string
|
||||||
render: (item: T, selected: boolean) => string | TextChunk[]
|
render: (item: T) => string
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
maxWidth?: number
|
maxWidth?: number
|
||||||
onSelect?: (item: T) => void
|
onSelect?: (item: T) => void
|
||||||
@@ -73,21 +68,9 @@ export class PickerMenu<T> {
|
|||||||
this.update(query, limit)
|
this.update(query, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
replace(items: T[]): void {
|
|
||||||
if (!this.visible) return
|
|
||||||
this.items = items
|
|
||||||
this.update(this.query, this.limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
redraw(): void {
|
|
||||||
if (this.visible) this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
update(query: string, limit = this.limit): void {
|
update(query: string, limit = this.limit): void {
|
||||||
if (!this.visible) return
|
if (!this.visible) return
|
||||||
const changed = query !== this.query
|
const changed = query !== this.query
|
||||||
const previous = this.matches[this.selected]
|
|
||||||
const previousKey = previous === undefined ? null : this.options.key?.(previous)
|
|
||||||
this.query = query
|
this.query = query
|
||||||
this.limit = Math.max(1, limit)
|
this.limit = Math.max(1, limit)
|
||||||
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
|
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
|
||||||
@@ -97,18 +80,7 @@ export class PickerMenu<T> {
|
|||||||
return words.every((word) => haystack.includes(word))
|
return words.every((word) => haystack.includes(word))
|
||||||
})
|
})
|
||||||
.slice(0, this.limit)
|
.slice(0, this.limit)
|
||||||
if (changed) {
|
this.selected = changed ? 0 : Math.min(this.selected, Math.max(0, this.matches.length - 1))
|
||||||
this.selected = 0
|
|
||||||
} else {
|
|
||||||
const preserved = previous === undefined
|
|
||||||
? -1
|
|
||||||
: previousKey === null || previousKey === undefined
|
|
||||||
? this.matches.indexOf(previous)
|
|
||||||
: this.matches.findIndex((item) => this.options.key?.(item) === previousKey)
|
|
||||||
this.selected = preserved >= 0
|
|
||||||
? preserved
|
|
||||||
: Math.min(this.selected, Math.max(0, this.matches.length - 1))
|
|
||||||
}
|
|
||||||
this.render()
|
this.render()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,16 +125,9 @@ export class PickerMenu<T> {
|
|||||||
}
|
}
|
||||||
for (const [index, item] of this.matches.entries()) {
|
for (const [index, item] of this.matches.entries()) {
|
||||||
const selected = index === this.selected
|
const selected = index === this.selected
|
||||||
const rendered = this.options.render(item, selected)
|
|
||||||
const content = typeof rendered === "string"
|
|
||||||
? `${selected ? "›" : " "} ${rendered}`
|
|
||||||
: new StyledText([
|
|
||||||
chunk(`${selected ? "›" : " "} `, selected ? this.theme.text : this.theme.muted),
|
|
||||||
...rendered,
|
|
||||||
])
|
|
||||||
this.root.add(new TextRenderable(this.renderer, {
|
this.root.add(new TextRenderable(this.renderer, {
|
||||||
id: `${this.options.id}-${index}`,
|
id: `${this.options.id}-${index}`,
|
||||||
content,
|
content: `${selected ? "›" : " "} ${this.options.render(item)}`,
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 1,
|
height: 1,
|
||||||
wrapMode: "none",
|
wrapMode: "none",
|
||||||
@@ -196,11 +161,3 @@ export class PickerMenu<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function chunk(text: string, color: string): TextChunk {
|
|
||||||
return {
|
|
||||||
__isChunk: true,
|
|
||||||
text,
|
|
||||||
fg: RGBA.fromHex(color),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -595,62 +595,6 @@ describe("gateway protocol", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sends stale-safe recovery mutations and validates their response", async () => {
|
|
||||||
const original = globalThis.WebSocket
|
|
||||||
let socket: FakeSocket | undefined
|
|
||||||
Object.defineProperty(globalThis, "WebSocket", {
|
|
||||||
configurable: true,
|
|
||||||
value: class extends FakeSocket {
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
socket = this
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statuses: string[] = []
|
|
||||||
const client = new NanobotClient({
|
|
||||||
url: "ws://nanobot.test/ws",
|
|
||||||
onEvent: () => undefined,
|
|
||||||
onStatus: (status, detail) => statuses.push(`${status}:${detail || ""}`),
|
|
||||||
})
|
|
||||||
client.connect()
|
|
||||||
if (!socket) throw new Error("socket was not created")
|
|
||||||
|
|
||||||
const result = client.updateRecovery("continue", "chat", "recovery-1")
|
|
||||||
const request = JSON.parse(socket.sent.at(-1) || "{}") as {
|
|
||||||
request_id: string
|
|
||||||
action: string
|
|
||||||
payload: Record<string, string>
|
|
||||||
}
|
|
||||||
expect(request).toMatchObject({
|
|
||||||
type: "webui_request",
|
|
||||||
action: "recovery.continue",
|
|
||||||
payload: { chat_id: "chat", recovery_id: "recovery-1" },
|
|
||||||
})
|
|
||||||
socket.emit("message", { data: JSON.stringify({
|
|
||||||
event: "webui_response",
|
|
||||||
request_id: request.request_id,
|
|
||||||
ok: true,
|
|
||||||
result: { status: "resuming", recovery_id: "recovery-1", attempts: 1 },
|
|
||||||
}) })
|
|
||||||
expect(await result).toEqual({
|
|
||||||
status: "resuming",
|
|
||||||
recovery_id: "recovery-1",
|
|
||||||
attempts: 1,
|
|
||||||
})
|
|
||||||
expect(statuses).not.toContain("error:gateway sent an invalid event")
|
|
||||||
|
|
||||||
const interrupted = client.updateRecovery("dismiss", "chat", "recovery-2")
|
|
||||||
socket.emit("close")
|
|
||||||
await expect(interrupted).rejects.toThrow("gateway connection closed")
|
|
||||||
client.close()
|
|
||||||
} finally {
|
|
||||||
Object.defineProperty(globalThis, "WebSocket", { configurable: true, value: original })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("reports when the bounded history snapshot omits earlier turns", async () => {
|
test("reports when the bounded history snapshot omits earlier turns", async () => {
|
||||||
const original = globalThis.fetch
|
const original = globalThis.fetch
|
||||||
let requested = ""
|
let requested = ""
|
||||||
|
|||||||
+4
-135
@@ -29,14 +29,14 @@ export interface FileEditEvent {
|
|||||||
diff?: FileDiff
|
diff?: FileDiff
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileDiff {
|
export interface FileDiff {
|
||||||
format: "unified" | string
|
format: "unified" | string
|
||||||
context?: number
|
context?: number
|
||||||
truncated?: boolean
|
truncated?: boolean
|
||||||
text?: string
|
text?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaAttachment {
|
export interface MediaAttachment {
|
||||||
kind: "image" | "video" | "file"
|
kind: "image" | "video" | "file"
|
||||||
url: string
|
url: string
|
||||||
name?: string
|
name?: string
|
||||||
@@ -54,16 +54,6 @@ export interface RuntimeControls {
|
|||||||
canUseFullAccess: boolean
|
canUseFullAccess: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RecoveryStatus = "resuming" | "awaiting_user" | "recovered" | "failed"
|
|
||||||
|
|
||||||
export interface RecoveryState {
|
|
||||||
status: RecoveryStatus
|
|
||||||
recovery_id: string
|
|
||||||
reason?: string
|
|
||||||
attempts?: number
|
|
||||||
can_continue?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InboundEvent =
|
export type InboundEvent =
|
||||||
| { event: "ready"; chat_id: string; client_id: string }
|
| { event: "ready"; chat_id: string; client_id: string }
|
||||||
| {
|
| {
|
||||||
@@ -71,7 +61,6 @@ export type InboundEvent =
|
|||||||
chat_id: string
|
chat_id: string
|
||||||
model_preset?: string | null
|
model_preset?: string | null
|
||||||
usage?: TokenUsage
|
usage?: TokenUsage
|
||||||
recovery_state?: RecoveryState
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
event: "message_accepted"
|
event: "message_accepted"
|
||||||
@@ -129,7 +118,6 @@ export type InboundEvent =
|
|||||||
turn_id?: string
|
turn_id?: string
|
||||||
}
|
}
|
||||||
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
|
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
|
||||||
| ({ event: "recovery_state"; chat_id: string } & RecoveryState)
|
|
||||||
| {
|
| {
|
||||||
event: "session_updated"
|
event: "session_updated"
|
||||||
chat_id: string
|
chat_id: string
|
||||||
@@ -151,12 +139,6 @@ type OutboundEvent =
|
|||||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||||
| { type: "attach"; chat_id: string }
|
| { type: "attach"; chat_id: string }
|
||||||
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
|
||||||
| {
|
|
||||||
type: "webui_request"
|
|
||||||
request_id: string
|
|
||||||
action: string
|
|
||||||
payload: Record<string, unknown>
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: "message"
|
type: "message"
|
||||||
chat_id: string
|
chat_id: string
|
||||||
@@ -243,7 +225,7 @@ export interface SessionContextSnapshot {
|
|||||||
lastUsage: TokenUsage | null
|
lastUsage: TokenUsage | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionMention {
|
export interface SessionMention {
|
||||||
name: string
|
name: string
|
||||||
session_key: string
|
session_key: string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -289,7 +271,6 @@ export interface SessionSummary {
|
|||||||
updatedAt: string | null
|
updatedAt: string | null
|
||||||
runStartedAt: number | null
|
runStartedAt: number | null
|
||||||
modelPreset: string | null
|
modelPreset: string | null
|
||||||
recoveryState?: RecoveryState | null
|
|
||||||
workspaceScope?: WorkspaceScopePayload | null
|
workspaceScope?: WorkspaceScopePayload | null
|
||||||
pinned: boolean
|
pinned: boolean
|
||||||
archived: boolean
|
archived: boolean
|
||||||
@@ -316,7 +297,6 @@ const CHAT_EVENTS = new Set([
|
|||||||
"turn_end",
|
"turn_end",
|
||||||
"goal_status",
|
"goal_status",
|
||||||
"goal_state",
|
"goal_state",
|
||||||
"recovery_state",
|
|
||||||
"session_updated",
|
"session_updated",
|
||||||
"turn_model_updated",
|
"turn_model_updated",
|
||||||
"error",
|
"error",
|
||||||
@@ -397,34 +377,6 @@ function isWorkspaceScope(value: unknown): value is WorkspaceScopePayload {
|
|||||||
&& optional(value.restrict_to_workspace, "boolean")
|
&& optional(value.restrict_to_workspace, "boolean")
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecoveryState(value: unknown): value is RecoveryState {
|
|
||||||
return isRecord(value)
|
|
||||||
&& ["resuming", "awaiting_user", "recovered", "failed"].includes(String(value.status))
|
|
||||||
&& typeof value.recovery_id === "string"
|
|
||||||
&& optional(value.reason, "string")
|
|
||||||
&& optional(value.attempts, "number")
|
|
||||||
&& optional(value.can_continue, "boolean")
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebUIResponseEvent {
|
|
||||||
event: "webui_response"
|
|
||||||
request_id: string
|
|
||||||
ok: boolean
|
|
||||||
result?: unknown
|
|
||||||
error?: { status: number; message: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeWebUIResponse(value: unknown): WebUIResponseEvent | null | undefined {
|
|
||||||
if (!isRecord(value) || value.event !== "webui_response") return undefined
|
|
||||||
if (typeof value.request_id !== "string" || typeof value.ok !== "boolean") return null
|
|
||||||
if (value.ok) return value as unknown as WebUIResponseEvent
|
|
||||||
return isRecord(value.error)
|
|
||||||
&& typeof value.error.status === "number"
|
|
||||||
&& typeof value.error.message === "string"
|
|
||||||
? value as unknown as WebUIResponseEvent
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
||||||
if (!isRecord(value)) return null
|
if (!isRecord(value)) return null
|
||||||
const record = value
|
const record = value
|
||||||
@@ -455,8 +407,7 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
|||||||
&& ((record.model_preset !== undefined
|
&& ((record.model_preset !== undefined
|
||||||
&& record.model_preset !== null
|
&& record.model_preset !== null
|
||||||
&& typeof record.model_preset !== "string")
|
&& typeof record.model_preset !== "string")
|
||||||
|| (record.usage !== undefined && !isTokenUsage(record.usage))
|
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|
||||||
|| (record.recovery_state !== undefined && !isRecoveryState(record.recovery_state)))
|
|
||||||
) return null
|
) return null
|
||||||
if (
|
if (
|
||||||
["user_message", "message", "delta", "reasoning_delta"].includes(name)
|
["user_message", "message", "delta", "reasoning_delta"].includes(name)
|
||||||
@@ -501,7 +452,6 @@ function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
|||||||
) return null
|
) return null
|
||||||
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
|
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
|
||||||
if (name === "goal_state" && !isRecord(record.goal_state)) return null
|
if (name === "goal_state" && !isRecord(record.goal_state)) return null
|
||||||
if (name === "recovery_state" && !isRecoveryState(record)) return null
|
|
||||||
if (
|
if (
|
||||||
name === "session_updated"
|
name === "session_updated"
|
||||||
&& (!optional(record.scope, "string")
|
&& (!optional(record.scope, "string")
|
||||||
@@ -750,9 +700,6 @@ export async function fetchSessions(
|
|||||||
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
|
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
|
||||||
? value.model_preset.trim()
|
? value.model_preset.trim()
|
||||||
: null,
|
: null,
|
||||||
...(isRecoveryState(value.recovery_state)
|
|
||||||
? { recoveryState: value.recovery_state }
|
|
||||||
: {}),
|
|
||||||
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
|
...(isWorkspaceScope(value.workspace_scope) ? { workspaceScope: value.workspace_scope } : {}),
|
||||||
pinned: pinned.has(value.key),
|
pinned: pinned.has(value.key),
|
||||||
archived: archived.has(value.key),
|
archived: archived.has(value.key),
|
||||||
@@ -908,11 +855,6 @@ export class NanobotClient {
|
|||||||
private closedByClient = false
|
private closedByClient = false
|
||||||
private opening = false
|
private opening = false
|
||||||
private connectedOnce = false
|
private connectedOnce = false
|
||||||
private readonly pendingMutations = new Map<string, {
|
|
||||||
resolve: (value: unknown) => void
|
|
||||||
reject: (error: Error) => void
|
|
||||||
timer: ReturnType<typeof setTimeout>
|
|
||||||
}>()
|
|
||||||
|
|
||||||
constructor(private readonly options: ClientOptions) {}
|
constructor(private readonly options: ClientOptions) {}
|
||||||
|
|
||||||
@@ -974,7 +916,6 @@ export class NanobotClient {
|
|||||||
socket.addEventListener("close", () => {
|
socket.addEventListener("close", () => {
|
||||||
if (this.socket !== socket) return
|
if (this.socket !== socket) return
|
||||||
this.socket = null
|
this.socket = null
|
||||||
this.rejectPendingMutations("gateway connection closed")
|
|
||||||
if (this.closedByClient) {
|
if (this.closedByClient) {
|
||||||
this.options.onStatus("closed")
|
this.options.onStatus("closed")
|
||||||
return
|
return
|
||||||
@@ -990,7 +931,6 @@ export class NanobotClient {
|
|||||||
const socket = this.socket
|
const socket = this.socket
|
||||||
this.socket = null
|
this.socket = null
|
||||||
socket?.close()
|
socket?.close()
|
||||||
this.rejectPendingMutations("gateway connection closed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
send(content: string, options: MessageOptions = {}): string {
|
send(content: string, options: MessageOptions = {}): string {
|
||||||
@@ -1035,63 +975,6 @@ export class NanobotClient {
|
|||||||
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope })
|
this.write({ type: "set_workspace_scope", chat_id: this.chatId, workspace_scope: scope })
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRecovery(
|
|
||||||
action: "continue" | "dismiss",
|
|
||||||
chatId: string,
|
|
||||||
recoveryId: string,
|
|
||||||
): Promise<RecoveryState> {
|
|
||||||
return this.requestMutation<unknown>(`recovery.${action}`, {
|
|
||||||
chat_id: chatId,
|
|
||||||
recovery_id: recoveryId,
|
|
||||||
}).then((result) => {
|
|
||||||
if (!isRecoveryState(result)) throw new Error("gateway returned an invalid recovery state")
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private requestMutation<T>(
|
|
||||||
action: string,
|
|
||||||
payload: Record<string, unknown> = {},
|
|
||||||
timeoutMs = 20_000,
|
|
||||||
): Promise<T> {
|
|
||||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
||||||
return Promise.reject(new Error("gateway connection is not open"))
|
|
||||||
}
|
|
||||||
const requestId = crypto.randomUUID()
|
|
||||||
const frame = JSON.stringify({
|
|
||||||
type: "webui_request",
|
|
||||||
request_id: requestId,
|
|
||||||
action,
|
|
||||||
payload,
|
|
||||||
} satisfies OutboundEvent)
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
this.pendingMutations.delete(requestId)
|
|
||||||
reject(new Error(`gateway request timed out after ${timeoutMs}ms`))
|
|
||||||
}, timeoutMs)
|
|
||||||
this.pendingMutations.set(requestId, {
|
|
||||||
resolve: (value) => resolve(value as T),
|
|
||||||
reject,
|
|
||||||
timer,
|
|
||||||
})
|
|
||||||
try {
|
|
||||||
this.socket?.send(frame)
|
|
||||||
} catch {
|
|
||||||
clearTimeout(timer)
|
|
||||||
this.pendingMutations.delete(requestId)
|
|
||||||
reject(new Error("could not send gateway request"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private rejectPendingMutations(message: string): void {
|
|
||||||
for (const pending of this.pendingMutations.values()) {
|
|
||||||
clearTimeout(pending.timer)
|
|
||||||
pending.reject(new Error(message))
|
|
||||||
}
|
|
||||||
this.pendingMutations.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleMessage(raw: string): void {
|
private handleMessage(raw: string): void {
|
||||||
let value: unknown
|
let value: unknown
|
||||||
try {
|
try {
|
||||||
@@ -1100,20 +983,6 @@ export class NanobotClient {
|
|||||||
this.options.onStatus("error", "gateway sent invalid JSON")
|
this.options.onStatus("error", "gateway sent invalid JSON")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const response = decodeWebUIResponse(value)
|
|
||||||
if (response === null) {
|
|
||||||
this.options.onStatus("error", "gateway sent an invalid event")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (response) {
|
|
||||||
const pending = this.pendingMutations.get(response.request_id)
|
|
||||||
if (!pending) return
|
|
||||||
clearTimeout(pending.timer)
|
|
||||||
this.pendingMutations.delete(response.request_id)
|
|
||||||
if (response.ok) pending.resolve(response.result)
|
|
||||||
else pending.reject(new Error(response.error?.message || "gateway request failed"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const event = decodeInboundEvent(value)
|
const event = decodeInboundEvent(value)
|
||||||
if (event === undefined) return
|
if (event === undefined) return
|
||||||
if (event === null) {
|
if (event === null) {
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
import {
|
|
||||||
BoxRenderable,
|
|
||||||
RGBA,
|
|
||||||
StyledText,
|
|
||||||
TextAttributes,
|
|
||||||
TextRenderable,
|
|
||||||
type CliRenderer,
|
|
||||||
type TextChunk,
|
|
||||||
} from "@opentui/core"
|
|
||||||
|
|
||||||
import type { RecoveryState } from "./protocol"
|
|
||||||
|
|
||||||
export interface RecoveryNoticeTheme {
|
|
||||||
text: string
|
|
||||||
muted: string
|
|
||||||
border: string
|
|
||||||
accent: string
|
|
||||||
warning: string
|
|
||||||
error: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecoveryNoticeOptions {
|
|
||||||
onContinue: () => void
|
|
||||||
onDismiss: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A quiet action surface for a gateway-owned interrupted turn. */
|
|
||||||
export class RecoveryNotice {
|
|
||||||
readonly root: BoxRenderable
|
|
||||||
private readonly title: TextRenderable
|
|
||||||
private readonly detail: TextRenderable
|
|
||||||
private readonly dismiss: TextRenderable
|
|
||||||
private readonly resume: TextRenderable
|
|
||||||
private state: RecoveryState | null = null
|
|
||||||
private busy = false
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
renderer: CliRenderer,
|
|
||||||
private theme: RecoveryNoticeTheme,
|
|
||||||
options: RecoveryNoticeOptions,
|
|
||||||
) {
|
|
||||||
this.root = new BoxRenderable(renderer, {
|
|
||||||
id: "nanobot-tui-recovery-notice",
|
|
||||||
width: "100%",
|
|
||||||
height: 4,
|
|
||||||
flexShrink: 0,
|
|
||||||
flexDirection: "column",
|
|
||||||
border: true,
|
|
||||||
borderStyle: "rounded",
|
|
||||||
borderColor: theme.border,
|
|
||||||
paddingLeft: 1,
|
|
||||||
paddingRight: 1,
|
|
||||||
visible: false,
|
|
||||||
backgroundColor: RGBA.defaultBackground(),
|
|
||||||
})
|
|
||||||
const header = new BoxRenderable(renderer, {
|
|
||||||
id: "nanobot-tui-recovery-header",
|
|
||||||
width: "100%",
|
|
||||||
height: 1,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 2,
|
|
||||||
})
|
|
||||||
this.title = new TextRenderable(renderer, {
|
|
||||||
id: "nanobot-tui-recovery-title",
|
|
||||||
width: "auto",
|
|
||||||
minWidth: 0,
|
|
||||||
flexGrow: 1,
|
|
||||||
height: 1,
|
|
||||||
truncate: true,
|
|
||||||
selectable: false,
|
|
||||||
})
|
|
||||||
this.detail = new TextRenderable(renderer, {
|
|
||||||
id: "nanobot-tui-recovery-detail",
|
|
||||||
width: "100%",
|
|
||||||
height: 1,
|
|
||||||
truncate: true,
|
|
||||||
selectable: false,
|
|
||||||
})
|
|
||||||
this.dismiss = this.action(renderer, "dismiss", "Dismiss", options.onDismiss)
|
|
||||||
this.resume = this.action(renderer, "continue", "Continue", options.onContinue, true)
|
|
||||||
header.add(this.title)
|
|
||||||
header.add(this.dismiss)
|
|
||||||
header.add(this.resume)
|
|
||||||
this.root.add(header)
|
|
||||||
this.root.add(this.detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
get visible(): boolean {
|
|
||||||
return this.root.visible
|
|
||||||
}
|
|
||||||
|
|
||||||
show(state: RecoveryState): void {
|
|
||||||
this.state = state
|
|
||||||
this.busy = false
|
|
||||||
this.root.visible = true
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
hide(): void {
|
|
||||||
this.state = null
|
|
||||||
this.busy = false
|
|
||||||
this.root.visible = false
|
|
||||||
}
|
|
||||||
|
|
||||||
setBusy(busy: boolean): void {
|
|
||||||
this.busy = busy
|
|
||||||
if (this.visible) this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
setTheme(theme: RecoveryNoticeTheme): void {
|
|
||||||
this.theme = theme
|
|
||||||
this.root.borderColor = theme.border
|
|
||||||
if (this.visible) this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
private action(
|
|
||||||
renderer: CliRenderer,
|
|
||||||
id: string,
|
|
||||||
label: string,
|
|
||||||
callback: () => void,
|
|
||||||
primary = false,
|
|
||||||
): TextRenderable {
|
|
||||||
return new TextRenderable(renderer, {
|
|
||||||
id: `nanobot-tui-recovery-${id}`,
|
|
||||||
content: label,
|
|
||||||
width: label.length,
|
|
||||||
height: 1,
|
|
||||||
flexShrink: 0,
|
|
||||||
selectable: false,
|
|
||||||
onMouseOver: () => {
|
|
||||||
if (this.busy) return
|
|
||||||
const target = primary ? this.resume : this.dismiss
|
|
||||||
target.attributes = TextAttributes.BOLD | TextAttributes.UNDERLINE
|
|
||||||
},
|
|
||||||
onMouseOut: () => this.render(),
|
|
||||||
onMouseDown: (event) => {
|
|
||||||
if (event.button !== 0 || this.busy) return
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
renderer.clearSelection()
|
|
||||||
callback()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private render(): void {
|
|
||||||
if (!this.state) return
|
|
||||||
const failed = this.state.status === "failed"
|
|
||||||
const contextUnavailable = this.state.can_continue === false
|
|
||||||
const title = failed ? "Recovery failed" : "Task interrupted"
|
|
||||||
const detail = failed
|
|
||||||
? "Review the saved task before continuing."
|
|
||||||
: contextUnavailable
|
|
||||||
? "This task 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,11 +24,6 @@ const sessions: SessionSummary[] = [
|
|||||||
updatedAt: "2026-08-12T10:00:00Z",
|
updatedAt: "2026-08-12T10:00:00Z",
|
||||||
runStartedAt: null,
|
runStartedAt: null,
|
||||||
modelPreset: null,
|
modelPreset: null,
|
||||||
recoveryState: {
|
|
||||||
status: "awaiting_user",
|
|
||||||
recovery_id: "recovery-two",
|
|
||||||
reason: "tool execution interrupted",
|
|
||||||
},
|
|
||||||
pinned: false,
|
pinned: false,
|
||||||
archived: false,
|
archived: false,
|
||||||
},
|
},
|
||||||
@@ -48,21 +43,17 @@ describe("SessionMenu", () => {
|
|||||||
text: "#FFFFFF",
|
text: "#FFFFFF",
|
||||||
muted: "#999999",
|
muted: "#999999",
|
||||||
border: "#555555",
|
border: "#555555",
|
||||||
accent: "#FF8A33",
|
|
||||||
warning: "#F5C451",
|
|
||||||
})
|
})
|
||||||
setup.renderer.root.add(menu.root)
|
setup.renderer.root.add(menu.root)
|
||||||
menu.open(sessions, "one", 6, "Codex")
|
menu.open(sessions, "one", 6)
|
||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
|
|
||||||
expect(setup.captureCharFrame()).toContain("› ● API migration")
|
expect(setup.captureCharFrame()).toContain("› ● API migration")
|
||||||
expect(setup.captureCharFrame()).not.toContain("Move authentication")
|
|
||||||
expect(setup.captureCharFrame()).not.toContain("Codex")
|
|
||||||
expect(menu.choose()?.chatId).toBe("one")
|
expect(menu.choose()?.chatId).toBe("one")
|
||||||
|
|
||||||
menu.update("release stable", 6)
|
menu.update("release stable", 6)
|
||||||
await setup.renderOnce()
|
await setup.renderOnce()
|
||||||
expect(setup.captureCharFrame()).toContain("⚠ Release checklist")
|
expect(setup.captureCharFrame()).toContain("Release checklist")
|
||||||
expect(menu.choose()?.chatId).toBe("two")
|
expect(menu.choose()?.chatId).toBe("two")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,80 +63,12 @@ describe("SessionMenu", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("animates running sessions and marks completed background sessions unread", async () => {
|
|
||||||
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
|
||||||
const menu = new SessionMenu(setup.renderer, {
|
|
||||||
text: "#FFFFFF",
|
|
||||||
muted: "#999999",
|
|
||||||
border: "#555555",
|
|
||||||
accent: "#FF8A33",
|
|
||||||
warning: "#F5C451",
|
|
||||||
})
|
|
||||||
setup.renderer.root.add(menu.root)
|
|
||||||
const running = {
|
|
||||||
...sessions[0]!,
|
|
||||||
chatId: "running",
|
|
||||||
title: "Background task",
|
|
||||||
runStartedAt: Date.now(),
|
|
||||||
pinned: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
menu.open([sessions[0]!, running], "one", 6)
|
|
||||||
await setup.renderOnce()
|
|
||||||
expect(setup.captureCharFrame()).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Background task/u)
|
|
||||||
|
|
||||||
menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "one")
|
|
||||||
await setup.renderOnce()
|
|
||||||
expect(setup.captureCharFrame()).toContain("• Background task")
|
|
||||||
|
|
||||||
menu.markRead("running")
|
|
||||||
menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "running")
|
|
||||||
await setup.renderOnce()
|
|
||||||
expect(setup.captureCharFrame()).toContain("● Background task")
|
|
||||||
menu.hide()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("prioritizes actionable sessions without moving keyboard selection on refresh", async () => {
|
|
||||||
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
|
||||||
const menu = new SessionMenu(setup.renderer, {
|
|
||||||
text: "#FFFFFF",
|
|
||||||
muted: "#999999",
|
|
||||||
border: "#555555",
|
|
||||||
accent: "#FF8A33",
|
|
||||||
warning: "#F5C451",
|
|
||||||
})
|
|
||||||
setup.renderer.root.add(menu.root)
|
|
||||||
const running = {
|
|
||||||
...sessions[0]!,
|
|
||||||
chatId: "running",
|
|
||||||
title: "Background task",
|
|
||||||
runStartedAt: Date.now(),
|
|
||||||
pinned: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const idle = { ...sessions[1]!, recoveryState: null }
|
|
||||||
menu.open([sessions[0]!, running, idle], "one", 6)
|
|
||||||
expect(menu.choose()?.chatId).toBe("one")
|
|
||||||
expect(menu.move(1)).toBe(true)
|
|
||||||
expect(menu.choose()?.chatId).toBe("running")
|
|
||||||
|
|
||||||
menu.replace([sessions[0]!, running, sessions[1]!], "one")
|
|
||||||
await setup.renderOnce()
|
|
||||||
|
|
||||||
expect(menu.choose()?.chatId).toBe("running")
|
|
||||||
const frame = setup.captureCharFrame()
|
|
||||||
expect(frame.indexOf("Release checklist")).toBeLessThan(frame.indexOf("Background task"))
|
|
||||||
menu.hide()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("keeps keyboard selection when the pointer stays over the previous row", async () => {
|
test("keeps keyboard selection when the pointer stays over the previous row", async () => {
|
||||||
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
||||||
const menu = new SessionMenu(setup.renderer, {
|
const menu = new SessionMenu(setup.renderer, {
|
||||||
text: "#FFFFFF",
|
text: "#FFFFFF",
|
||||||
muted: "#999999",
|
muted: "#999999",
|
||||||
border: "#555555",
|
border: "#555555",
|
||||||
accent: "#FF8A33",
|
|
||||||
warning: "#F5C451",
|
|
||||||
})
|
})
|
||||||
setup.renderer.root.add(menu.root)
|
setup.renderer.root.add(menu.root)
|
||||||
menu.open(sessions, "one", 6)
|
menu.open(sessions, "one", 6)
|
||||||
@@ -174,8 +97,6 @@ describe("SessionMenu", () => {
|
|||||||
text: "#FFFFFF",
|
text: "#FFFFFF",
|
||||||
muted: "#999999",
|
muted: "#999999",
|
||||||
border: "#555555",
|
border: "#555555",
|
||||||
accent: "#FF8A33",
|
|
||||||
warning: "#F5C451",
|
|
||||||
})
|
})
|
||||||
setup.renderer.root.add(menu.root)
|
setup.renderer.root.add(menu.root)
|
||||||
const scoped = sessions.map((session) => ({
|
const scoped = sessions.map((session) => ({
|
||||||
@@ -233,31 +154,4 @@ describe("SessionMenu", () => {
|
|||||||
expect(duplicates).toContain("frontend/nanobot")
|
expect(duplicates).toContain("frontend/nanobot")
|
||||||
expect(duplicates).toContain("backend/nanobot")
|
expect(duplicates).toContain("backend/nanobot")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows only model overrides and keeps previews searchable", async () => {
|
|
||||||
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
|
|
||||||
const menu = new SessionMenu(setup.renderer, {
|
|
||||||
text: "#FFFFFF",
|
|
||||||
muted: "#999999",
|
|
||||||
border: "#555555",
|
|
||||||
accent: "#FF8A33",
|
|
||||||
warning: "#F5C451",
|
|
||||||
})
|
|
||||||
setup.renderer.root.add(menu.root)
|
|
||||||
|
|
||||||
menu.open(sessions, "one", 6, "Codex")
|
|
||||||
await setup.renderOnce()
|
|
||||||
const frame = setup.captureCharFrame()
|
|
||||||
expect(frame).not.toContain("Codex")
|
|
||||||
expect(frame).not.toContain("Prepare the stable release")
|
|
||||||
|
|
||||||
menu.update("prepare stable", 6)
|
|
||||||
await setup.renderOnce()
|
|
||||||
expect(menu.choose()?.chatId).toBe("two")
|
|
||||||
|
|
||||||
menu.replace([{ ...sessions[1]!, modelPreset: "Deep Research" }], "one", "Codex")
|
|
||||||
await setup.renderOnce()
|
|
||||||
expect(setup.captureCharFrame()).toContain("Deep Research")
|
|
||||||
menu.hide()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+14
-165
@@ -1,11 +1,9 @@
|
|||||||
import { RGBA, type BoxRenderable, type CliRenderer, type TextChunk } from "@opentui/core"
|
import { type BoxRenderable, type CliRenderer } from "@opentui/core"
|
||||||
|
|
||||||
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
import { PickerMenu, type PickerMenuTheme } from "./picker-menu"
|
||||||
import type { SessionSummary } from "./protocol"
|
import type { SessionSummary } from "./protocol"
|
||||||
|
|
||||||
type SessionMenuRow = SessionSummary & { active: boolean; unread: boolean }
|
type SessionMenuRow = SessionSummary & { active: boolean }
|
||||||
|
|
||||||
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
||||||
|
|
||||||
export function sessionLabel(session: SessionSummary): string {
|
export function sessionLabel(session: SessionSummary): string {
|
||||||
const label = session.title.trim() || session.preview.trim() || "Untitled chat"
|
const label = session.title.trim() || session.preview.trim() || "Untitled chat"
|
||||||
@@ -29,24 +27,14 @@ export class SessionMenu {
|
|||||||
private readonly picker: PickerMenu<SessionMenuRow>
|
private readonly picker: PickerMenu<SessionMenuRow>
|
||||||
private readonly workspaceLabels = new Map<string, string>()
|
private readonly workspaceLabels = new Map<string, string>()
|
||||||
private showWorkspaces = false
|
private showWorkspaces = false
|
||||||
private spinnerFrame = 0
|
|
||||||
private spinnerTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
private rows: SessionMenuRow[] = []
|
|
||||||
private defaultModelPreset = ""
|
|
||||||
private readonly snapshots = new Map<string, {
|
|
||||||
preview: string
|
|
||||||
runStartedAt: number | null
|
|
||||||
}>()
|
|
||||||
private readonly unreadChatIds = new Set<string>()
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
renderer: CliRenderer,
|
renderer: CliRenderer,
|
||||||
private theme: PickerMenuTheme,
|
theme: PickerMenuTheme,
|
||||||
onSelect?: (session: SessionSummary) => void,
|
onSelect?: (session: SessionSummary) => void,
|
||||||
) {
|
) {
|
||||||
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
this.picker = new PickerMenu<SessionMenuRow>(renderer, theme, {
|
||||||
id: "nanobot-tui-session-menu",
|
id: "nanobot-tui-session-menu",
|
||||||
key: (session) => session.chatId,
|
|
||||||
searchText: (session) => [
|
searchText: (session) => [
|
||||||
sessionLabel(session),
|
sessionLabel(session),
|
||||||
session.modelPreset || "",
|
session.modelPreset || "",
|
||||||
@@ -54,27 +42,20 @@ export class SessionMenu {
|
|||||||
session.chatId,
|
session.chatId,
|
||||||
session.workspaceScope?.project_name || "",
|
session.workspaceScope?.project_name || "",
|
||||||
session.workspaceScope?.project_path || "",
|
session.workspaceScope?.project_path || "",
|
||||||
session.recoveryState?.status || "",
|
|
||||||
session.recoveryState?.reason || "",
|
|
||||||
].join(" "),
|
].join(" "),
|
||||||
render: (session, selected) => {
|
render: (session) => {
|
||||||
const age = updatedLabel(session.updatedAt)
|
const age = updatedLabel(session.updatedAt)
|
||||||
|
const preview = session.preview.trim()
|
||||||
const detail = [
|
const detail = [
|
||||||
this.showWorkspaces ? this.workspaceLabel(session) : "",
|
this.showWorkspaces ? this.workspaceLabel(session) : "",
|
||||||
this.modelOverride(session),
|
session.modelPreset,
|
||||||
|
age,
|
||||||
|
preview && preview !== sessionLabel(session) ? preview : "",
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ")
|
.join(" · ")
|
||||||
const marker = this.marker(session)
|
const marker = session.active ? "● " : session.pinned ? "◆ " : session.archived ? "◇ " : ""
|
||||||
const foreground = this.interrupted(session)
|
return `${marker}${sessionLabel(session)}${detail ? ` ${detail}` : ""}`
|
||||||
? this.theme.warning || this.theme.accent || this.theme.text
|
|
||||||
: selected ? this.theme.text : this.theme.muted
|
|
||||||
return [
|
|
||||||
...(marker ? [chunk(`${marker.text} `, marker.color)] : []),
|
|
||||||
chunk(sessionLabel(session), foreground),
|
|
||||||
...(detail ? [chunk(` ${detail}`, this.theme.muted)] : []),
|
|
||||||
...(age ? [chunk(` ${age}`, this.theme.muted)] : []),
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
emptyText: "No matching sessions",
|
emptyText: "No matching sessions",
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -86,51 +67,16 @@ export class SessionMenu {
|
|||||||
return this.picker.visible
|
return this.picker.visible
|
||||||
}
|
}
|
||||||
|
|
||||||
open(
|
open(sessions: SessionSummary[], currentChatId: string, limit: number): void {
|
||||||
sessions: SessionSummary[],
|
|
||||||
currentChatId: string,
|
|
||||||
limit: number,
|
|
||||||
defaultModelPreset = "",
|
|
||||||
): void {
|
|
||||||
this.defaultModelPreset = defaultModelPreset
|
|
||||||
this.observe(sessions, currentChatId)
|
|
||||||
this.rows = this.prepareRows(sessions, currentChatId)
|
|
||||||
this.picker.show(this.rows, "", limit)
|
|
||||||
this.syncSpinner()
|
|
||||||
}
|
|
||||||
|
|
||||||
replace(
|
|
||||||
sessions: SessionSummary[],
|
|
||||||
currentChatId: string,
|
|
||||||
defaultModelPreset = this.defaultModelPreset,
|
|
||||||
): void {
|
|
||||||
this.defaultModelPreset = defaultModelPreset
|
|
||||||
this.observe(sessions, currentChatId)
|
|
||||||
this.rows = this.prepareRows(sessions, currentChatId)
|
|
||||||
this.picker.replace(this.rows)
|
|
||||||
this.syncSpinner()
|
|
||||||
}
|
|
||||||
|
|
||||||
markRead(chatId: string): void {
|
|
||||||
this.unreadChatIds.delete(chatId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private prepareRows(sessions: SessionSummary[], currentChatId: string): SessionMenuRow[] {
|
|
||||||
this.prepareWorkspaceLabels(sessions)
|
this.prepareWorkspaceLabels(sessions)
|
||||||
return sessions
|
const rows = sessions
|
||||||
.map((session) => ({
|
.map((session) => ({ ...session, active: session.chatId === currentChatId }))
|
||||||
...session,
|
|
||||||
active: session.chatId === currentChatId,
|
|
||||||
unread: this.unreadChatIds.has(session.chatId),
|
|
||||||
}))
|
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
return Number(right.active) - Number(left.active)
|
return Number(right.active) - Number(left.active)
|
||||||
|| sessionPriority(right) - sessionPriority(left)
|
|
||||||
|| Number(right.pinned) - Number(left.pinned)
|
|| Number(right.pinned) - Number(left.pinned)
|
||||||
|| Number(left.archived) - Number(right.archived)
|
|| Number(left.archived) - Number(right.archived)
|
||||||
|| timestamp(right.updatedAt) - timestamp(left.updatedAt)
|
|
||||||
|| sessionLabel(left).localeCompare(sessionLabel(right))
|
|
||||||
})
|
})
|
||||||
|
this.picker.show(rows, "", limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
update(query: string, limit: number): void {
|
update(query: string, limit: number): void {
|
||||||
@@ -146,84 +92,13 @@ export class SessionMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hide(): void {
|
hide(): void {
|
||||||
this.stopSpinner()
|
|
||||||
this.rows = []
|
|
||||||
this.picker.hide()
|
this.picker.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme: PickerMenuTheme): void {
|
setTheme(theme: PickerMenuTheme): void {
|
||||||
this.theme = theme
|
|
||||||
this.picker.setTheme(theme)
|
this.picker.setTheme(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
private marker(session: SessionMenuRow): { text: string; color: string } | null {
|
|
||||||
if (this.interrupted(session)) {
|
|
||||||
return {
|
|
||||||
text: "⚠",
|
|
||||||
color: this.theme.warning || this.theme.accent || this.theme.text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (session.runStartedAt !== null) {
|
|
||||||
return {
|
|
||||||
text: SPINNER_FRAMES[this.spinnerFrame % SPINNER_FRAMES.length] || SPINNER_FRAMES[0]!,
|
|
||||||
color: this.theme.accent || this.theme.text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (session.active) return { text: "●", color: this.theme.text }
|
|
||||||
if (session.unread) return { text: "•", color: this.theme.accent || this.theme.text }
|
|
||||||
if (session.pinned) return { text: "◆", color: this.theme.muted }
|
|
||||||
if (session.archived) return { text: "◇", color: this.theme.muted }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private interrupted(session: SessionMenuRow): boolean {
|
|
||||||
return session.recoveryState?.status === "awaiting_user"
|
|
||||||
|| session.recoveryState?.status === "failed"
|
|
||||||
}
|
|
||||||
|
|
||||||
private observe(sessions: SessionSummary[], currentChatId: string): void {
|
|
||||||
const present = new Set<string>()
|
|
||||||
for (const session of sessions) {
|
|
||||||
present.add(session.chatId)
|
|
||||||
const previous = this.snapshots.get(session.chatId)
|
|
||||||
const active = session.chatId === currentChatId
|
|
||||||
const completed = previous !== undefined
|
|
||||||
&& previous.runStartedAt !== null
|
|
||||||
&& session.runStartedAt === null
|
|
||||||
const receivedContent = previous !== undefined && previous.preview !== session.preview
|
|
||||||
if (active) this.unreadChatIds.delete(session.chatId)
|
|
||||||
else if (completed || receivedContent) this.unreadChatIds.add(session.chatId)
|
|
||||||
this.snapshots.set(session.chatId, {
|
|
||||||
preview: session.preview,
|
|
||||||
runStartedAt: session.runStartedAt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const chatId of this.snapshots.keys()) {
|
|
||||||
if (present.has(chatId)) continue
|
|
||||||
this.snapshots.delete(chatId)
|
|
||||||
this.unreadChatIds.delete(chatId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private syncSpinner(): void {
|
|
||||||
if (!this.visible || !this.rows.some((session) => session.runStartedAt !== null)) {
|
|
||||||
this.stopSpinner()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.spinnerTimer) return
|
|
||||||
this.spinnerTimer = setInterval(() => {
|
|
||||||
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length
|
|
||||||
this.picker.redraw()
|
|
||||||
}, 90)
|
|
||||||
;(this.spinnerTimer as unknown as { unref?: () => void }).unref?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopSpinner(): void {
|
|
||||||
if (this.spinnerTimer) clearInterval(this.spinnerTimer)
|
|
||||||
this.spinnerTimer = null
|
|
||||||
this.spinnerFrame = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private prepareWorkspaceLabels(sessions: SessionSummary[]): void {
|
private prepareWorkspaceLabels(sessions: SessionSummary[]): void {
|
||||||
this.workspaceLabels.clear()
|
this.workspaceLabels.clear()
|
||||||
const scopes = sessions.flatMap((session) => {
|
const scopes = sessions.flatMap((session) => {
|
||||||
@@ -247,19 +122,6 @@ export class SessionMenu {
|
|||||||
private workspaceLabel(session: SessionSummary): string {
|
private workspaceLabel(session: SessionSummary): string {
|
||||||
return this.workspaceLabels.get(normalizeWorkspacePath(session.workspaceScope?.project_path)) || ""
|
return this.workspaceLabels.get(normalizeWorkspacePath(session.workspaceScope?.project_path)) || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
private modelOverride(session: SessionSummary): string {
|
|
||||||
const preset = session.modelPreset?.trim() || ""
|
|
||||||
return preset && preset !== this.defaultModelPreset ? preset : ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunk(text: string, color: string): TextChunk {
|
|
||||||
return {
|
|
||||||
__isChunk: true,
|
|
||||||
text,
|
|
||||||
fg: RGBA.fromHex(color),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeWorkspacePath(value: string | undefined): string {
|
function normalizeWorkspacePath(value: string | undefined): string {
|
||||||
@@ -274,16 +136,3 @@ function shortPath(path: string): string {
|
|||||||
const parts = path.split("/").filter(Boolean)
|
const parts = path.split("/").filter(Boolean)
|
||||||
return parts.slice(-2).join("/") || path
|
return parts.slice(-2).join("/") || path
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionPriority(session: SessionMenuRow): number {
|
|
||||||
if (session.recoveryState?.status === "awaiting_user"
|
|
||||||
|| session.recoveryState?.status === "failed") return 3
|
|
||||||
if (session.runStartedAt !== null) return 2
|
|
||||||
return session.unread ? 1 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function timestamp(value: string | null): number {
|
|
||||||
if (!value) return 0
|
|
||||||
const parsed = Date.parse(value)
|
|
||||||
return Number.isNaN(parsed) ? 0 : parsed
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1274,15 +1274,6 @@ function Shell({
|
|||||||
}, [activeKey, activeTabKey, activeTabState]);
|
}, [activeKey, activeTabKey, activeTabState]);
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||||
const recoveryChatIdList = useMemo(
|
|
||||||
() => sessions
|
|
||||||
.filter((session) => (
|
|
||||||
session.recoveryState?.status === "awaiting_user"
|
|
||||||
|| session.recoveryState?.status === "failed"
|
|
||||||
))
|
|
||||||
.map((session) => session.chatId),
|
|
||||||
[sessions],
|
|
||||||
);
|
|
||||||
const activeChatId = activePaneSession?.chatId ?? null;
|
const activeChatId = activePaneSession?.chatId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatIdRef.current = activeChatId;
|
activeChatIdRef.current = activeChatId;
|
||||||
@@ -2561,7 +2552,6 @@ function Shell({
|
|||||||
collapsedGroups: sidebarState.collapsed_groups,
|
collapsedGroups: sidebarState.collapsed_groups,
|
||||||
runningChatIds: runningChatIdList,
|
runningChatIds: runningChatIdList,
|
||||||
updatedChatIds: updatedChatIdList,
|
updatedChatIds: updatedChatIdList,
|
||||||
recoveryChatIds: recoveryChatIdList,
|
|
||||||
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
|
viewState: { ...sidebarState.view, sort: automaticSidebarSort },
|
||||||
showArchived: sidebarState.view.show_archived,
|
showArchived: sidebarState.view.show_archived,
|
||||||
archivedCount: sidebarArchivedTabKeys.length,
|
archivedCount: sidebarArchivedTabKeys.length,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
type ChannelFieldMessages = {
|
export type ChannelFieldMessages = {
|
||||||
label: string;
|
label: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
help?: string;
|
help?: string;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactElement } from
|
|||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
ArchiveRestore,
|
ArchiveRestore,
|
||||||
AlertTriangle,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Folder,
|
Folder,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
@@ -261,7 +260,6 @@ interface ChatListProps {
|
|||||||
collapsedGroups?: Record<string, boolean>;
|
collapsedGroups?: Record<string, boolean>;
|
||||||
runningChatIds?: string[];
|
runningChatIds?: string[];
|
||||||
updatedChatIds?: string[];
|
updatedChatIds?: string[];
|
||||||
recoveryChatIds?: string[];
|
|
||||||
density?: SidebarDensity;
|
density?: SidebarDensity;
|
||||||
showPreviews?: boolean;
|
showPreviews?: boolean;
|
||||||
showTimestamps?: boolean;
|
showTimestamps?: boolean;
|
||||||
@@ -304,7 +302,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
collapsedGroups = {},
|
collapsedGroups = {},
|
||||||
runningChatIds = [],
|
runningChatIds = [],
|
||||||
updatedChatIds = [],
|
updatedChatIds = [],
|
||||||
recoveryChatIds = [],
|
|
||||||
density = "comfortable",
|
density = "comfortable",
|
||||||
showPreviews = false,
|
showPreviews = false,
|
||||||
showTimestamps = false,
|
showTimestamps = false,
|
||||||
@@ -561,7 +558,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
|
|
||||||
const running = new Set(runningChatIds);
|
const running = new Set(runningChatIds);
|
||||||
const updated = new Set(updatedChatIds);
|
const updated = new Set(updatedChatIds);
|
||||||
const recovery = new Set(recoveryChatIds);
|
|
||||||
const compact = density === "compact";
|
const compact = density === "compact";
|
||||||
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
const firstProjectGroupIndex = limitedGroups.findIndex((group) => group.kind === "project");
|
||||||
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
|
const selectableDeleteKeys = Array.from(new Set(limitedGroups.flatMap((group) => (
|
||||||
@@ -885,7 +881,6 @@ export const ChatList = memo(function ChatList({
|
|||||||
compact={compact}
|
compact={compact}
|
||||||
running={running}
|
running={running}
|
||||||
updated={updated}
|
updated={updated}
|
||||||
recovery={recovery}
|
|
||||||
onSelectPane={onSelectPane}
|
onSelectPane={onSelectPane}
|
||||||
onRequestDelete={onRequestDelete}
|
onRequestDelete={onRequestDelete}
|
||||||
onRequestRename={onRequestRename}
|
onRequestRename={onRequestRename}
|
||||||
@@ -920,11 +915,9 @@ export const ChatList = memo(function ChatList({
|
|||||||
: "";
|
: "";
|
||||||
const activityState = running.has(s.chatId)
|
const activityState = running.has(s.chatId)
|
||||||
? "running"
|
? "running"
|
||||||
: recovery.has(s.chatId)
|
: updated.has(s.chatId) && !topicActive
|
||||||
? "recovery"
|
? "updated"
|
||||||
: updated.has(s.chatId) && !topicActive
|
: null;
|
||||||
? "updated"
|
|
||||||
: null;
|
|
||||||
const hasPaneMoveTarget = Boolean(onAttachPane)
|
const hasPaneMoveTarget = Boolean(onAttachPane)
|
||||||
&& paneGroupTargets.some((target) => (
|
&& paneGroupTargets.some((target) => (
|
||||||
target.key !== paneGroup?.tabKey && !target.atCapacity
|
target.key !== paneGroup?.tabKey && !target.atCapacity
|
||||||
@@ -1337,7 +1330,6 @@ function ActivePaneRows({
|
|||||||
compact,
|
compact,
|
||||||
running,
|
running,
|
||||||
updated,
|
updated,
|
||||||
recovery,
|
|
||||||
onSelectPane,
|
onSelectPane,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onRequestRename,
|
onRequestRename,
|
||||||
@@ -1362,7 +1354,6 @@ function ActivePaneRows({
|
|||||||
compact: boolean;
|
compact: boolean;
|
||||||
running: ReadonlySet<string>;
|
running: ReadonlySet<string>;
|
||||||
updated: ReadonlySet<string>;
|
updated: ReadonlySet<string>;
|
||||||
recovery: ReadonlySet<string>;
|
|
||||||
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
onSelectPane?: (tabKey: string, paneKey: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
onRequestRename: (key: string, label: string) => void;
|
onRequestRename: (key: string, label: string) => void;
|
||||||
@@ -1399,11 +1390,9 @@ function ActivePaneRows({
|
|||||||
const active = tabActive && pane.key === group.activePaneKey;
|
const active = tabActive && pane.key === group.activePaneKey;
|
||||||
const activityState = running.has(pane.chatId)
|
const activityState = running.has(pane.chatId)
|
||||||
? "running"
|
? "running"
|
||||||
: recovery.has(pane.chatId)
|
: updated.has(pane.chatId) && !active
|
||||||
? "recovery"
|
? "updated"
|
||||||
: updated.has(pane.chatId) && !active
|
: null;
|
||||||
? "updated"
|
|
||||||
: null;
|
|
||||||
const paneActionsLabel = t("workbench.paneActions", { title: pane.title });
|
const paneActionsLabel = t("workbench.paneActions", { title: pane.title });
|
||||||
const selected = selectedDeleteKeys.has(pane.key);
|
const selected = selectedDeleteKeys.has(pane.key);
|
||||||
const isPinned = pinned.has(pane.key);
|
const isPinned = pinned.has(pane.key);
|
||||||
@@ -1863,27 +1852,10 @@ function ChatsFoldFooter({
|
|||||||
function SessionActivityIndicator({
|
function SessionActivityIndicator({
|
||||||
state,
|
state,
|
||||||
}: {
|
}: {
|
||||||
state: "running" | "updated" | "recovery" | null;
|
state: "running" | "updated" | null;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (state === "recovery") {
|
|
||||||
const label = t("chat.activity.recovery", {
|
|
||||||
defaultValue: "This conversation needs your attention",
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<SidebarItemTooltip label={label}>
|
|
||||||
<span
|
|
||||||
role="img"
|
|
||||||
aria-label={label}
|
|
||||||
className="grid h-4 w-4 shrink-0 place-items-center text-[#ff8a3d]"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={2} aria-hidden />
|
|
||||||
</span>
|
|
||||||
</SidebarItemTooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state === "running") {
|
if (state === "running") {
|
||||||
const label = t("chat.activity.running");
|
const label = t("chat.activity.running");
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function CapabilityMentionToken({
|
|||||||
return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />;
|
return <SessionMentionToken mention={segment.mention} label={segment.text} variant={variant} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionMentionToken({
|
export function SessionMentionToken({
|
||||||
mention,
|
mention,
|
||||||
label,
|
label,
|
||||||
variant,
|
variant,
|
||||||
@@ -172,7 +172,7 @@ function SessionMentionToken({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CliAppMentionToken({
|
export function CliAppMentionToken({
|
||||||
app,
|
app,
|
||||||
label,
|
label,
|
||||||
variant,
|
variant,
|
||||||
@@ -229,7 +229,7 @@ function CliAppMentionToken({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function McpPresetMentionToken({
|
export function McpPresetMentionToken({
|
||||||
preset,
|
preset,
|
||||||
label,
|
label,
|
||||||
variant,
|
variant,
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function splitFilePath(path: string): { directory: string; name: string }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileKindForPath(path: string): FileReferenceKind {
|
export function fileKindForPath(path: string): FileReferenceKind {
|
||||||
const normalized = path.toLowerCase();
|
const normalized = path.toLowerCase();
|
||||||
const name = normalized.split(/[\\/]/).pop() ?? normalized;
|
const name = normalized.split(/[\\/]/).pop() ?? normalized;
|
||||||
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||||
@@ -193,7 +193,7 @@ function fileKindForPath(path: string): FileReferenceKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
export function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
|
||||||
if (kind === "python") {
|
if (kind === "python") {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ interface MessageBubbleProps {
|
|||||||
temporary?: boolean;
|
temporary?: boolean;
|
||||||
/** When false, hide this message's copy button. Default true. */
|
/** When false, hide this message's copy button. Default true. */
|
||||||
showCopyAction?: boolean;
|
showCopyAction?: boolean;
|
||||||
|
/** Show opt-in per-turn token usage for completed assistant messages. */
|
||||||
|
showTurnUsage?: boolean;
|
||||||
cliApps?: CliAppInfo[];
|
cliApps?: CliAppInfo[];
|
||||||
mcpPresets?: McpPresetInfo[];
|
mcpPresets?: McpPresetInfo[];
|
||||||
slashCommands?: SlashCommand[];
|
slashCommands?: SlashCommand[];
|
||||||
@@ -189,31 +191,65 @@ function compactDuration(milliseconds: number): string {
|
|||||||
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TurnUsageMeta({
|
function TurnUsageMeta({ usage }: { usage: TurnUsage }) {
|
||||||
usage,
|
|
||||||
latencyMs,
|
|
||||||
}: {
|
|
||||||
usage: TurnUsage;
|
|
||||||
latencyMs?: number;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const context = usage.context_tokens;
|
||||||
const prompt = usage.prompt_tokens;
|
const prompt = usage.prompt_tokens;
|
||||||
const completion = usage.completion_tokens;
|
const completion = usage.completion_tokens;
|
||||||
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
|
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
|
||||||
|
const formatTokens = (tokens: number) => `${approximate}${formatCompactTokenCount(tokens)}`;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (typeof prompt === "number") parts.push(`${approximate}${formatCompactTokenCount(prompt)} in`);
|
const details: string[] = [];
|
||||||
if (typeof completion === "number") parts.push(`${approximate}${formatCompactTokenCount(completion)} out`);
|
|
||||||
|
if (typeof context === "number" && context >= 0) {
|
||||||
|
parts.push(t("message.usage.context", {
|
||||||
|
tokens: formatTokens(context),
|
||||||
|
defaultValue: "{{tokens}} context",
|
||||||
|
}));
|
||||||
|
if (typeof prompt === "number") {
|
||||||
|
details.push(t("message.usage.turnInput", {
|
||||||
|
tokens: formatTokens(prompt),
|
||||||
|
defaultValue: "{{tokens}} turn input",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else if (typeof prompt === "number") {
|
||||||
|
parts.push(t("message.usage.turnInput", {
|
||||||
|
tokens: formatTokens(prompt),
|
||||||
|
defaultValue: "{{tokens}} turn input",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof usage.request_count === "number" && usage.request_count > 0) {
|
||||||
|
const requestKey = usage.request_count === 1
|
||||||
|
? "message.usage.request"
|
||||||
|
: "message.usage.requests";
|
||||||
|
details.push(t(requestKey, {
|
||||||
|
count: usage.request_count,
|
||||||
|
defaultValue: usage.request_count === 1 ? "{{count}} request" : "{{count}} requests",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof completion === "number") {
|
||||||
|
const output = t("message.usage.output", {
|
||||||
|
tokens: formatTokens(completion),
|
||||||
|
defaultValue: "{{tokens}} out",
|
||||||
|
});
|
||||||
|
if (parts.length > 0) details.push(output);
|
||||||
|
else parts.push(output);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof usage.cached_tokens === "number"
|
typeof usage.cached_tokens === "number"
|
||||||
&& typeof prompt === "number"
|
&& typeof prompt === "number"
|
||||||
&& prompt > 0
|
&& prompt > 0
|
||||||
) {
|
) {
|
||||||
parts.push(`${Math.round(Math.min(1, usage.cached_tokens / prompt) * 100)}% cached`);
|
details.push(t("message.usage.cached", {
|
||||||
|
percent: Math.round(Math.min(1, usage.cached_tokens / prompt) * 100),
|
||||||
|
defaultValue: "{{percent}}% cached",
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs));
|
|
||||||
if (parts.length === 0) return null;
|
|
||||||
|
|
||||||
const details: string[] = [];
|
if (parts.length === 0) return null;
|
||||||
if (approximate) {
|
if (approximate) {
|
||||||
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
|
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
|
||||||
}
|
}
|
||||||
@@ -246,6 +282,17 @@ function TurnUsageMeta({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TurnLatencyMeta({ latencyMs }: { latencyMs: number }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-turn-latency
|
||||||
|
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||||
|
>
|
||||||
|
{compactDuration(latencyMs)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function deliveryErrorCopy(
|
function deliveryErrorCopy(
|
||||||
kind: MessageDeliveryErrorKind | undefined,
|
kind: MessageDeliveryErrorKind | undefined,
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
@@ -394,6 +441,7 @@ export function MessageBubble({
|
|||||||
isTurnStreaming = false,
|
isTurnStreaming = false,
|
||||||
temporary = false,
|
temporary = false,
|
||||||
showCopyAction = true,
|
showCopyAction = true,
|
||||||
|
showTurnUsage = false,
|
||||||
cliApps = [],
|
cliApps = [],
|
||||||
mcpPresets = [],
|
mcpPresets = [],
|
||||||
slashCommands = [],
|
slashCommands = [],
|
||||||
@@ -549,9 +597,21 @@ export function MessageBubble({
|
|||||||
&& (!empty || hasReasoning || media.length > 0);
|
&& (!empty || hasReasoning || media.length > 0);
|
||||||
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
|
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
|
||||||
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
|
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
|
||||||
const showUsage = message.role === "assistant" && !!message.usage && !message.isStreaming;
|
const showUsage = (
|
||||||
|
showTurnUsage
|
||||||
|
&& message.role === "assistant"
|
||||||
|
&& !!message.usage
|
||||||
|
&& !message.isStreaming
|
||||||
|
);
|
||||||
|
const showLatency = (
|
||||||
|
message.role === "assistant"
|
||||||
|
&& !!message.usage
|
||||||
|
&& !message.isStreaming
|
||||||
|
&& typeof message.latencyMs === "number"
|
||||||
|
&& message.latencyMs >= 0
|
||||||
|
);
|
||||||
const showAssistantFooterRow =
|
const showAssistantFooterRow =
|
||||||
showCopyButton || showForkButton || showAssistantTimestamp || showUsage;
|
showCopyButton || showForkButton || showAssistantTimestamp || showUsage || showLatency;
|
||||||
const showAssistantFooterSlot =
|
const showAssistantFooterSlot =
|
||||||
message.role === "assistant"
|
message.role === "assistant"
|
||||||
&& (!empty || hasReasoning || media.length > 0);
|
&& (!empty || hasReasoning || media.length > 0);
|
||||||
@@ -617,12 +677,8 @@ export function MessageBubble({
|
|||||||
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
{showUsage ? (
|
{showUsage ? <TurnUsageMeta usage={message.usage!} /> : null}
|
||||||
<TurnUsageMeta
|
{showLatency ? <TurnLatencyMeta latencyMs={message.latencyMs!} /> : null}
|
||||||
usage={message.usage!}
|
|
||||||
latencyMs={message.latencyMs}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{showAssistantTimestamp ? (
|
{showAssistantTimestamp ? (
|
||||||
<MessageTimestamp
|
<MessageTimestamp
|
||||||
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
|
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
|
||||||
@@ -967,7 +1023,7 @@ interface ReasoningBubbleProps {
|
|||||||
hasBodyBelow: boolean;
|
hasBodyBelow: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReasoningBubble({
|
export function ReasoningBubble({
|
||||||
text,
|
text,
|
||||||
streaming,
|
streaming,
|
||||||
hasBodyBelow,
|
hasBodyBelow,
|
||||||
@@ -993,7 +1049,7 @@ interface TraceGroupProps {
|
|||||||
* collapsed because tool traces are supporting evidence, not the answer.
|
* collapsed because tool traces are supporting evidence, not the answer.
|
||||||
* A single click expands the exact calls when the user wants details.
|
* A single click expands the exact calls when the user wants details.
|
||||||
*/
|
*/
|
||||||
function TraceGroup({ message }: TraceGroupProps) {
|
export function TraceGroup({ message }: TraceGroupProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lines = message.traces ?? [message.content];
|
const lines = message.traces ?? [message.content];
|
||||||
const count = lines.length;
|
const count = lines.length;
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ interface SidebarProps {
|
|||||||
collapsedGroups?: Record<string, boolean>;
|
collapsedGroups?: Record<string, boolean>;
|
||||||
runningChatIds?: string[];
|
runningChatIds?: string[];
|
||||||
updatedChatIds?: string[];
|
updatedChatIds?: string[];
|
||||||
recoveryChatIds?: string[];
|
|
||||||
viewState?: SidebarViewState;
|
viewState?: SidebarViewState;
|
||||||
showArchived?: boolean;
|
showArchived?: boolean;
|
||||||
archivedCount?: number;
|
archivedCount?: number;
|
||||||
@@ -271,7 +270,6 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
collapsedGroups={props.collapsedGroups}
|
collapsedGroups={props.collapsedGroups}
|
||||||
runningChatIds={props.runningChatIds}
|
runningChatIds={props.runningChatIds}
|
||||||
updatedChatIds={props.updatedChatIds}
|
updatedChatIds={props.updatedChatIds}
|
||||||
recoveryChatIds={props.recoveryChatIds}
|
|
||||||
density={props.viewState?.density}
|
density={props.viewState?.density}
|
||||||
showPreviews={props.viewState?.show_previews}
|
showPreviews={props.viewState?.show_previews}
|
||||||
showTimestamps={props.viewState?.show_timestamps}
|
showTimestamps={props.viewState?.show_timestamps}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export function ChannelLogo({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelDisplayName(feature: NanobotFeatureInfo): string {
|
export function channelDisplayName(feature: NanobotFeatureInfo): string {
|
||||||
return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name;
|
return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function ChannelSetupLinks({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelOfficialLink({
|
export function ChannelOfficialLink({
|
||||||
feature,
|
feature,
|
||||||
setup,
|
setup,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
|
import type { ChannelConfigField } from "@/components/settings/channels/catalog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
|
export function channelFieldValue(field: ChannelConfigField, values: Record<string, string>): string {
|
||||||
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
|
return values[field.key] ?? field.defaultValue ?? field.options?.[0]?.value ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type ChannelSetupPresentation = {
|
|||||||
presets?: ChannelProviderPreset[];
|
presets?: ChannelProviderPreset[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChannelCatalogSetupPresentation = {
|
export type ChannelCatalogSetupPresentation = {
|
||||||
mode?: "webui" | "credentials" | "connect";
|
mode?: "webui" | "credentials" | "connect";
|
||||||
command?: string;
|
command?: string;
|
||||||
docsUrl?: string;
|
docsUrl?: string;
|
||||||
@@ -38,15 +38,15 @@ type ChannelCatalogSetupPresentation = {
|
|||||||
presets?: ChannelProviderPresetDefinition[];
|
presets?: ChannelProviderPresetDefinition[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChannelFieldPresentation = {
|
export type ChannelFieldPresentation = {
|
||||||
key: string;
|
key: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChannelSetupActionDefinition = Omit<ChannelSetupAction, "label">;
|
export type ChannelSetupActionDefinition = Omit<ChannelSetupAction, "label">;
|
||||||
|
|
||||||
export type ChannelProviderPresetDefinition = Omit<ChannelProviderPreset, "label">;
|
export type ChannelProviderPresetDefinition = Omit<ChannelProviderPreset, "label">;
|
||||||
|
|
||||||
type ChannelSetupAction = {
|
export type ChannelSetupAction = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@@ -72,7 +72,7 @@ export type ChannelConfigField = {
|
|||||||
options?: ChannelConfigOption[];
|
options?: ChannelConfigOption[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChannelConfigOption = {
|
export type ChannelConfigOption = {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type SettingsSectionKey =
|
|||||||
| "runtime"
|
| "runtime"
|
||||||
| "advanced";
|
| "advanced";
|
||||||
|
|
||||||
type PendingRestartSection = "runtime" | "browser" | "image";
|
export type PendingRestartSection = "runtime" | "browser" | "image";
|
||||||
export type PendingRestartSections = Record<PendingRestartSection, boolean>;
|
export type PendingRestartSections = Record<PendingRestartSection, boolean>;
|
||||||
|
|
||||||
export type RestartAwarePayload = {
|
export type RestartAwarePayload = {
|
||||||
|
|||||||
@@ -407,6 +407,22 @@ export function AppearanceSettings({
|
|||||||
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
<SettingsRow
|
||||||
|
title={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
|
||||||
|
description={tx(
|
||||||
|
"settings.help.showTurnUsage",
|
||||||
|
"Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ToggleButton
|
||||||
|
checked={localPrefs.showTurnUsage}
|
||||||
|
onChange={(showTurnUsage) => (
|
||||||
|
onChangeLocalPrefs((prev) => ({ ...prev, showTurnUsage }))
|
||||||
|
)}
|
||||||
|
ariaLabel={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
|
||||||
|
label={localPrefs.showTurnUsage ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={tx("settings.rows.brandLogos", "Brand logos")}
|
title={tx("settings.rows.brandLogos", "Brand logos")}
|
||||||
description={tx(
|
description={tx(
|
||||||
@@ -421,37 +437,6 @@ export function AppearanceSettings({
|
|||||||
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow
|
|
||||||
title={tx("settings.rows.browserNotifications", "Task notifications")}
|
|
||||||
description={tx(
|
|
||||||
"settings.help.browserNotifications",
|
|
||||||
"Notify only when this page is in the background. Off by default.",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ToggleButton
|
|
||||||
checked={localPrefs.browserNotifications}
|
|
||||||
onChange={(enabled) => {
|
|
||||||
if (!enabled) {
|
|
||||||
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: false }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof Notification === "undefined") return;
|
|
||||||
if (Notification.permission === "granted") {
|
|
||||||
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void Notification.requestPermission().then((permission) => {
|
|
||||||
if (permission === "granted") {
|
|
||||||
onChangeLocalPrefs((prev) => ({ ...prev, browserNotifications: true }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
ariaLabel={tx("settings.rows.browserNotifications", "Task notifications")}
|
|
||||||
label={localPrefs.browserNotifications
|
|
||||||
? tx("settings.values.on", "On")
|
|
||||||
: tx("settings.values.off", "Off")}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import type { CliAppInfo, McpPresetInfo, ToolProgressEvent, UIFileEdit, UIMessag
|
|||||||
|
|
||||||
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||||
|
|
||||||
export { isAgentActivityMember };
|
export { isAgentActivityMember, isReasoningOnlyAssistant };
|
||||||
|
|
||||||
interface ActivityCounts {
|
interface ActivityCounts {
|
||||||
reasoningSteps: number;
|
reasoningSteps: number;
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { AlertTriangle, LoaderCircle, RotateCcw, X } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import type { RecoveryState } from "@/lib/types";
|
|
||||||
|
|
||||||
interface RecoveryNoticeProps {
|
|
||||||
state: RecoveryState;
|
|
||||||
onContinue: () => Promise<void>;
|
|
||||||
onDismiss: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecoveryNotice({ state, onContinue, onDismiss }: RecoveryNoticeProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [pending, setPending] = useState<"continue" | "dismiss" | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [hiddenRecoveryId, setHiddenRecoveryId] = useState<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
// A continuation can be interrupted again with the same recovery ID.
|
|
||||||
// Reveal the decision surface when the server returns to a waiting state.
|
|
||||||
if (state.status === "awaiting_user" || state.status === "failed") {
|
|
||||||
setHiddenRecoveryId(null);
|
|
||||||
}
|
|
||||||
}, [state.recovery_id, state.status]);
|
|
||||||
if (state.status === "recovered" || hiddenRecoveryId === state.recovery_id) return null;
|
|
||||||
|
|
||||||
const waiting = state.status === "awaiting_user" || state.status === "failed";
|
|
||||||
const contextUnavailable = state.can_continue === false;
|
|
||||||
const title = state.status === "failed"
|
|
||||||
? t("recovery.failed", { defaultValue: "Task recovery failed" })
|
|
||||||
: waiting
|
|
||||||
? t("recovery.interrupted", { defaultValue: "Task interrupted" })
|
|
||||||
: t("recovery.resuming", { defaultValue: "Restoring interrupted task…" });
|
|
||||||
const detail = state.status === "failed" || contextUnavailable
|
|
||||||
? t("recovery.failedHelp", {
|
|
||||||
defaultValue: "The saved task could not be restored safely. Review it before continuing.",
|
|
||||||
})
|
|
||||||
: waiting
|
|
||||||
? t("recovery.review", { defaultValue: "Review the task before continuing. Tools will not be replayed automatically." })
|
|
||||||
: t("recovery.safeResume", { defaultValue: "Continuing from saved conversation context." });
|
|
||||||
const run = (action: "continue" | "dismiss") => {
|
|
||||||
setPending(action);
|
|
||||||
setError(null);
|
|
||||||
// ``resuming`` is an internal transition, not another task for the user
|
|
||||||
// to monitor. Hide the notice optimistically and only bring it back if
|
|
||||||
// the explicit action is rejected.
|
|
||||||
if (action === "continue") setHiddenRecoveryId(state.recovery_id);
|
|
||||||
const operation = action === "continue" ? onContinue() : onDismiss();
|
|
||||||
void operation.catch(() => {
|
|
||||||
if (action === "continue") setHiddenRecoveryId(null);
|
|
||||||
setError(t("recovery.actionFailed", { defaultValue: "Recovery action failed. Try again." }));
|
|
||||||
}).finally(() => setPending(null));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role={waiting ? "alert" : "status"}
|
|
||||||
aria-live={waiting ? "assertive" : "polite"}
|
|
||||||
aria-busy={state.status === "resuming"}
|
|
||||||
data-recovery-status={state.status}
|
|
||||||
className="mx-auto mb-2 flex w-full max-w-[49.5rem] items-center gap-3 rounded-control border border-border/70 bg-muted/35 px-3 py-2 text-sm transition-[background-color,border-color,opacity,transform] duration-200 ease-out motion-reduce:transition-none animate-in fade-in-0 slide-in-from-bottom-1 duration-200 motion-reduce:animate-none"
|
|
||||||
>
|
|
||||||
{waiting ? (
|
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-primary motion-reduce:animate-none" aria-hidden />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium">
|
|
||||||
{title}
|
|
||||||
</p>
|
|
||||||
<p className={cn(
|
|
||||||
"mt-0.5 text-xs",
|
|
||||||
error ? "text-destructive" : "text-muted-foreground",
|
|
||||||
)}>
|
|
||||||
{error ?? detail}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{waiting ? (
|
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={pending !== null}
|
|
||||||
onClick={() => run("dismiss")}
|
|
||||||
>
|
|
||||||
<X className="mr-1 h-3.5 w-3.5" aria-hidden />
|
|
||||||
{t("recovery.dismiss", { defaultValue: "Dismiss" })}
|
|
||||||
</Button>
|
|
||||||
{!contextUnavailable ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
disabled={pending !== null}
|
|
||||||
onClick={() => run("continue")}
|
|
||||||
>
|
|
||||||
<RotateCcw className="mr-1 h-3.5 w-3.5" aria-hidden />
|
|
||||||
{t("recovery.continue", { defaultValue: "Continue" })}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { MessageBubble } from "@/components/MessageBubble";
|
import { MessageBubble } from "@/components/MessageBubble";
|
||||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||||
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
||||||
|
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
|
||||||
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export function ThreadMessages({
|
|||||||
onQuoteSelection,
|
onQuoteSelection,
|
||||||
}: ThreadMessagesProps) {
|
}: ThreadMessagesProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const showTurnUsage = useShowTurnUsage();
|
||||||
const messageListRef = useRef<HTMLDivElement>(null);
|
const messageListRef = useRef<HTMLDivElement>(null);
|
||||||
const units = useMemo(
|
const units = useMemo(
|
||||||
() => buildDisplayUnits(messages, isStreaming, activeTurnId),
|
() => buildDisplayUnits(messages, isStreaming, activeTurnId),
|
||||||
@@ -158,6 +160,7 @@ export function ThreadMessages({
|
|||||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||||
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
||||||
temporary={temporary}
|
temporary={temporary}
|
||||||
|
showTurnUsage={showTurnUsage}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
@@ -239,6 +242,7 @@ interface ThreadDisplayUnitProps {
|
|||||||
showForkBoundary: boolean;
|
showForkBoundary: boolean;
|
||||||
forkBoundaryLabel: string;
|
forkBoundaryLabel: string;
|
||||||
temporary: boolean;
|
temporary: boolean;
|
||||||
|
showTurnUsage: boolean;
|
||||||
cliApps: CliAppInfo[];
|
cliApps: CliAppInfo[];
|
||||||
mcpPresets: McpPresetInfo[];
|
mcpPresets: McpPresetInfo[];
|
||||||
slashCommands: SlashCommand[];
|
slashCommands: SlashCommand[];
|
||||||
@@ -257,6 +261,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
|||||||
showForkBoundary,
|
showForkBoundary,
|
||||||
forkBoundaryLabel,
|
forkBoundaryLabel,
|
||||||
temporary,
|
temporary,
|
||||||
|
showTurnUsage,
|
||||||
cliApps,
|
cliApps,
|
||||||
mcpPresets,
|
mcpPresets,
|
||||||
slashCommands,
|
slashCommands,
|
||||||
@@ -295,6 +300,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
|||||||
message={unit.message}
|
message={unit.message}
|
||||||
isTurnStreaming={isTurnStreaming}
|
isTurnStreaming={isTurnStreaming}
|
||||||
temporary={temporary}
|
temporary={temporary}
|
||||||
|
showTurnUsage={showTurnUsage}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
@@ -323,6 +329,7 @@ function threadDisplayUnitPropsEqual(
|
|||||||
&& previous.showForkBoundary === next.showForkBoundary
|
&& previous.showForkBoundary === next.showForkBoundary
|
||||||
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
|
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
|
||||||
&& previous.temporary === next.temporary
|
&& previous.temporary === next.temporary
|
||||||
|
&& previous.showTurnUsage === next.showTurnUsage
|
||||||
&& previous.cliApps === next.cliApps
|
&& previous.cliApps === next.cliApps
|
||||||
&& previous.mcpPresets === next.mcpPresets
|
&& previous.mcpPresets === next.mcpPresets
|
||||||
&& previous.slashCommands === next.slashCommands
|
&& previous.slashCommands === next.slashCommands
|
||||||
@@ -355,15 +362,11 @@ function activeTurnStartIndex(units: DisplayUnit[], activeTurnId: string | null)
|
|||||||
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
|
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
|
||||||
if (previous.type !== next.type) return false;
|
if (previous.type !== next.type) return false;
|
||||||
if (previous.type === "message" && next.type === "message") {
|
if (previous.type === "message" && next.type === "message") {
|
||||||
return (
|
return shallowMessageEqual(previous.message, next.message);
|
||||||
previous.sourceMessageCount === next.sourceMessageCount
|
|
||||||
&& shallowMessageEqual(previous.message, next.message)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (previous.type !== "activity" || next.type !== "activity") return false;
|
if (previous.type !== "activity" || next.type !== "activity") return false;
|
||||||
return (
|
return (
|
||||||
previous.sourceMessageCount === next.sourceMessageCount
|
previous.turnLatencyMs === next.turnLatencyMs
|
||||||
&& previous.turnLatencyMs === next.turnLatencyMs
|
|
||||||
&& previous.startedAtMs === next.startedAtMs
|
&& previous.startedAtMs === next.startedAtMs
|
||||||
&& previous.messages.length === next.messages.length
|
&& previous.messages.length === next.messages.length
|
||||||
&& previous.messages.every((message, index) =>
|
&& previous.messages.every((message, index) =>
|
||||||
@@ -387,7 +390,7 @@ function unitIndexAfterMessageCount(
|
|||||||
let seen = 0;
|
let seen = 0;
|
||||||
for (let i = 0; i < units.length; i += 1) {
|
for (let i = 0; i < units.length; i += 1) {
|
||||||
const unit = units[i];
|
const unit = units[i];
|
||||||
seen += unit.sourceMessageCount;
|
seen += unit.type === "activity" ? unit.messages.length : 1;
|
||||||
if (seen >= messageCount) return i;
|
if (seen >= messageCount) return i;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailab
|
|||||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||||
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
|
import { SessionHandleLabel } from "@/components/SessionHandleLabel";
|
||||||
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
import { PromptNavigator } from "@/components/thread/PromptNavigator";
|
||||||
import { RecoveryNotice } from "@/components/thread/RecoveryNotice";
|
|
||||||
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
|
||||||
import {
|
import {
|
||||||
ThreadComposer,
|
ThreadComposer,
|
||||||
@@ -764,9 +763,6 @@ export function ThreadShell({
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
runStartedAt,
|
runStartedAt,
|
||||||
goalState,
|
goalState,
|
||||||
recoveryState,
|
|
||||||
continueRecovery,
|
|
||||||
dismissRecovery,
|
|
||||||
send,
|
send,
|
||||||
transcribeAudio,
|
transcribeAudio,
|
||||||
stop,
|
stop,
|
||||||
@@ -839,15 +835,8 @@ export function ThreadShell({
|
|||||||
[displayMessages],
|
[displayMessages],
|
||||||
);
|
);
|
||||||
const currentGoalState = messagesReady ? goalState : undefined;
|
const currentGoalState = messagesReady ? goalState : undefined;
|
||||||
// Decision states freeze the interrupted turn and hand the next action to
|
const currentRunStartedAt = messagesReady ? runStartedAt : null;
|
||||||
// the recovery notice. ``resuming`` remains active; ``recovered`` is only
|
const turnActive = messagesReady && (isStreaming || currentRunStartedAt !== null);
|
||||||
// historical metadata and must not suppress a later normal turn.
|
|
||||||
const recoveryNeedsDecision = recoveryState?.status === "awaiting_user"
|
|
||||||
|| recoveryState?.status === "failed";
|
|
||||||
const currentRunStartedAt = messagesReady && !recoveryNeedsDecision ? runStartedAt : null;
|
|
||||||
const turnActive = messagesReady
|
|
||||||
&& !recoveryNeedsDecision
|
|
||||||
&& (isStreaming || currentRunStartedAt !== null);
|
|
||||||
const restoredViewportTurnId = useMemo(
|
const restoredViewportTurnId = useMemo(
|
||||||
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
() => turnActive ? latestActiveTurnId(displayMessages, currentRunStartedAt) : null,
|
||||||
[currentRunStartedAt, displayMessages, turnActive],
|
[currentRunStartedAt, displayMessages, turnActive],
|
||||||
@@ -1483,13 +1472,6 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const composer = (
|
const composer = (
|
||||||
<>
|
<>
|
||||||
{recoveryState ? (
|
|
||||||
<RecoveryNotice
|
|
||||||
state={recoveryState}
|
|
||||||
onContinue={continueRecovery}
|
|
||||||
onDismiss={dismissRecovery}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{streamError && !hasInlineDeliveryError(messages, streamError) ? (
|
{streamError && !hasInlineDeliveryError(messages, streamError) ? (
|
||||||
<StreamErrorNotice
|
<StreamErrorNotice
|
||||||
error={streamError}
|
error={streamError}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function FileEditRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
|
export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
|
||||||
return edit.added > 0 || edit.deleted > 0;
|
return edit.added > 0 || edit.deleted > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { compactActivityPath, redactActivityText } from "./activity-text";
|
|||||||
export type GenericToolStatus = "running" | "done" | "error";
|
export type GenericToolStatus = "running" | "done" | "error";
|
||||||
export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic";
|
export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic";
|
||||||
|
|
||||||
interface ToolField {
|
export interface ToolField {
|
||||||
key:
|
key:
|
||||||
| "query"
|
| "query"
|
||||||
| "pattern"
|
| "pattern"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { displayWebHost, formatCompactWebUrl, parseSafeActivityHttpUrl } from ".
|
|||||||
export type WebSearchStatus = "running" | "done" | "error";
|
export type WebSearchStatus = "running" | "done" | "error";
|
||||||
export type WebSearchTarget = "web" | "x";
|
export type WebSearchTarget = "web" | "x";
|
||||||
|
|
||||||
interface WebSearchSource {
|
export interface WebSearchSource {
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
host: string;
|
host: string;
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptLabel(content: string, index: number): string {
|
export function promptLabel(content: string, index: number): string {
|
||||||
const text = content.replace(/\s+/g, " ").trim();
|
const text = content.replace(/\s+/g, " ").trim();
|
||||||
if (!text) return `Prompt ${index + 1}`;
|
if (!text) return `Prompt ${index + 1}`;
|
||||||
return truncatePreview(text, 80);
|
return truncatePreview(text, 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
function promptPreview(content: string, index: number): string {
|
export function promptPreview(content: string, index: number): string {
|
||||||
const text = compactPreview(content);
|
const text = compactPreview(content);
|
||||||
if (!text) return `Prompt ${index + 1}`;
|
if (!text) return `Prompt ${index + 1}`;
|
||||||
return truncatePreview(text, 320);
|
return truncatePreview(text, 320);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
interface ButtonProps
|
export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
|||||||
import { formControlFocusClassName } from "@/components/ui/form-control";
|
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
|||||||
import { formControlFocusClassName } from "@/components/ui/form-control";
|
import { formControlFocusClassName } from "@/components/ui/form-control";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
({ className, ...props }, ref) => {
|
({ className, ...props }, ref) => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type {
|
|||||||
|
|
||||||
export const MAX_WORKBENCH_PANES = 4;
|
export const MAX_WORKBENCH_PANES = 4;
|
||||||
|
|
||||||
const WORKBENCH_LAYOUTS = [
|
export const WORKBENCH_LAYOUTS = [
|
||||||
"columns",
|
"columns",
|
||||||
"rows",
|
"rows",
|
||||||
"grid",
|
"grid",
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import type { WebUIIngressLimits } from "@/lib/types";
|
|||||||
* - ``ready`` — ``dataUrl`` available; safe to submit
|
* - ``ready`` — ``dataUrl`` available; safe to submit
|
||||||
* - ``error`` — validation / decode failure; chip shows inline error
|
* - ``error`` — validation / decode failure; chip shows inline error
|
||||||
*/
|
*/
|
||||||
type AttachmentStatus = "encoding" | "ready" | "error";
|
export type AttachmentStatus = "encoding" | "ready" | "error";
|
||||||
export type AttachmentKind = "image" | "file";
|
export type AttachmentKind = "image" | "file";
|
||||||
|
|
||||||
interface AttachedAttachment {
|
export interface AttachedAttachment {
|
||||||
id: string;
|
id: string;
|
||||||
kind: AttachmentKind;
|
kind: AttachmentKind;
|
||||||
file: File;
|
file: File;
|
||||||
@@ -32,7 +32,7 @@ interface AttachedAttachment {
|
|||||||
|
|
||||||
export type AttachedImage = AttachedAttachment;
|
export type AttachedImage = AttachedAttachment;
|
||||||
|
|
||||||
interface RestoredReadyAttachment {
|
export interface RestoredReadyAttachment {
|
||||||
dataUrl: string;
|
dataUrl: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
kind?: AttachmentKind;
|
kind?: AttachmentKind;
|
||||||
@@ -55,8 +55,8 @@ export type AttachmentError =
|
|||||||
| "io"; // file read failed at the browser layer
|
| "io"; // file read failed at the browser layer
|
||||||
|
|
||||||
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
|
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
|
||||||
const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
|
export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
|
||||||
const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
|
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
|
||||||
|
|
||||||
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
|
/** MIME whitelist — mirrors the server's and the ``<input accept>`` attr. */
|
||||||
const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
|
const ACCEPTED_IMAGE_MIMES: ReadonlySet<string> = new Set([
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { acceptedAttachmentKind } from "@/hooks/useAttachedImages";
|
|||||||
* - Plain text pasted alongside attachments is *not* consumed by this helper,
|
* - Plain text pasted alongside attachments is *not* consumed by this helper,
|
||||||
* so the caller can still let the textarea receive it naturally.
|
* so the caller can still let the textarea receive it naturally.
|
||||||
*/
|
*/
|
||||||
function extractImageFilesFromPaste(
|
export function extractImageFilesFromPaste(
|
||||||
event: ClipboardEvent | React.ClipboardEvent,
|
event: ClipboardEvent | React.ClipboardEvent,
|
||||||
): File[] {
|
): File[] {
|
||||||
const clipboard = (event as ClipboardEvent).clipboardData
|
const clipboard = (event as ClipboardEvent).clipboardData
|
||||||
@@ -27,7 +27,7 @@ function extractImageFilesFromPaste(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
|
/** Extract dropped attachment files, mirroring ``extractImageFilesFromPaste``. */
|
||||||
function extractImageFilesFromDrop(
|
export function extractImageFilesFromDrop(
|
||||||
event: DragEvent | React.DragEvent,
|
event: DragEvent | React.DragEvent,
|
||||||
): File[] {
|
): File[] {
|
||||||
const dt = (event as DragEvent).dataTransfer
|
const dt = (event as DragEvent).dataTransfer
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
import { toMediaAttachment } from "@/lib/media";
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
@@ -29,7 +28,6 @@ import {
|
|||||||
} from "@/lib/thread-event-projection";
|
} from "@/lib/thread-event-projection";
|
||||||
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
|
import type { UIMessageTurnFields } from "@/lib/thread-event-projection";
|
||||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||||
import { readLocalPreferences } from "@/lib/local-preferences";
|
|
||||||
import type {
|
import type {
|
||||||
InboundEvent,
|
InboundEvent,
|
||||||
OutboundCliAppMention,
|
OutboundCliAppMention,
|
||||||
@@ -38,7 +36,6 @@ import type {
|
|||||||
SessionMention,
|
SessionMention,
|
||||||
GoalStateWsPayload,
|
GoalStateWsPayload,
|
||||||
MessageDeliveryStatus,
|
MessageDeliveryStatus,
|
||||||
RecoveryState,
|
|
||||||
UIMediaAttachment,
|
UIMediaAttachment,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
WorkspaceScopePayload,
|
WorkspaceScopePayload,
|
||||||
@@ -247,9 +244,6 @@ export function useNanobotStream(
|
|||||||
runStartedAt: number | null;
|
runStartedAt: number | null;
|
||||||
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
|
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
|
||||||
goalState: GoalStateWsPayload | undefined;
|
goalState: GoalStateWsPayload | undefined;
|
||||||
recoveryState: RecoveryState | null;
|
|
||||||
continueRecovery: () => Promise<void>;
|
|
||||||
dismissRecovery: () => Promise<void>;
|
|
||||||
send: (
|
send: (
|
||||||
content: string,
|
content: string,
|
||||||
images?: SendAttachment[],
|
images?: SendAttachment[],
|
||||||
@@ -268,7 +262,6 @@ export function useNanobotStream(
|
|||||||
dismissStreamError: () => void;
|
dismissStreamError: () => void;
|
||||||
} {
|
} {
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
const { t } = useTranslation();
|
|
||||||
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
|
const initialRunStartedAt = chatId ? client.getRunStartedAt(chatId) : null;
|
||||||
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
||||||
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
|
const [messageOwnerChatId, setMessageOwnerChatId] = useState(chatId);
|
||||||
@@ -280,7 +273,6 @@ export function useNanobotStream(
|
|||||||
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
||||||
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
|
const [runStartedAt, setRunStartedAt] = useState<number | null>(initialRunStartedAt);
|
||||||
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||||
const [recoveryState, setRecoveryState] = useState<RecoveryState | null>(null);
|
|
||||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||||
const buffer = useRef<StreamBuffer | null>(null);
|
const buffer = useRef<StreamBuffer | null>(null);
|
||||||
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
|
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
|
||||||
@@ -296,16 +288,6 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||||
|
|
||||||
const notifyInBackground = useCallback((body: string) => {
|
|
||||||
if (
|
|
||||||
typeof Notification === "undefined"
|
|
||||||
|| Notification.permission !== "granted"
|
|
||||||
|| document.visibilityState === "visible"
|
|
||||||
|| !readLocalPreferences().browserNotifications
|
|
||||||
) return;
|
|
||||||
new Notification("nanobot", { body });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearPendingStreamWork = useCallback(() => {
|
const clearPendingStreamWork = useCallback(() => {
|
||||||
if (streamFrameRef.current !== null) {
|
if (streamFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(streamFrameRef.current);
|
window.cancelAnimationFrame(streamFrameRef.current);
|
||||||
@@ -657,7 +639,6 @@ export function useNanobotStream(
|
|||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
setRunStartedAt(restoredRunStartedAt);
|
setRunStartedAt(restoredRunStartedAt);
|
||||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||||
setRecoveryState(null);
|
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
activeAssistantRef.current = null;
|
activeAssistantRef.current = null;
|
||||||
closedAssistantStreamIdsRef.current.clear();
|
closedAssistantStreamIdsRef.current.clear();
|
||||||
@@ -865,71 +846,10 @@ export function useNanobotStream(
|
|||||||
return finalized;
|
return finalized;
|
||||||
});
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
notifyInBackground(t("recovery.completed", { defaultValue: "Task completed" }));
|
|
||||||
onTurnEnd?.();
|
onTurnEnd?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ev.event === "recovery_state") {
|
|
||||||
const next: RecoveryState = {
|
|
||||||
status: ev.status,
|
|
||||||
recovery_id: ev.recovery_id,
|
|
||||||
...(ev.reason ? { reason: ev.reason } : {}),
|
|
||||||
...(typeof ev.attempts === "number" ? { attempts: ev.attempts } : {}),
|
|
||||||
...(typeof ev.can_continue === "boolean"
|
|
||||||
? { can_continue: ev.can_continue }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
setRecoveryState(next);
|
|
||||||
if (ev.status === "resuming") {
|
|
||||||
setRunStartedAt((current) => current ?? Date.now() / 1000);
|
|
||||||
setIsStreaming(true);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
ev.status === "awaiting_user"
|
|
||||||
|| ev.status === "recovered"
|
|
||||||
|| ev.status === "failed"
|
|
||||||
) {
|
|
||||||
// Recovery is an explicit boundary. The interrupted turn is no
|
|
||||||
// longer running, so do not let the stale start time keep the
|
|
||||||
// activity clock (or composer stop state) alive underneath the
|
|
||||||
// recovery notice.
|
|
||||||
setRunStartedAt(null);
|
|
||||||
setIsStreaming(false);
|
|
||||||
client.finishRunLocally(chatId);
|
|
||||||
clearPendingStreamWork();
|
|
||||||
closeActiveAssistantStream();
|
|
||||||
clearActivitySegment();
|
|
||||||
if (ev.status !== "recovered") {
|
|
||||||
notifyInBackground(
|
|
||||||
ev.status === "failed"
|
|
||||||
? t("recovery.failed", { defaultValue: "Task recovery failed" })
|
|
||||||
: t("recovery.interrupted", { defaultValue: "Task interrupted" }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ev.event === "attached") {
|
|
||||||
setRecoveryState(ev.recovery_state ?? null);
|
|
||||||
if (ev.recovery_state?.status === "resuming") {
|
|
||||||
setRunStartedAt((current) => current ?? Date.now() / 1000);
|
|
||||||
setIsStreaming(true);
|
|
||||||
} else if (
|
|
||||||
ev.recovery_state?.status === "awaiting_user"
|
|
||||||
|| ev.recovery_state?.status === "failed"
|
|
||||||
) {
|
|
||||||
setRunStartedAt(null);
|
|
||||||
setIsStreaming(false);
|
|
||||||
client.finishRunLocally(chatId);
|
|
||||||
clearPendingStreamWork();
|
|
||||||
closeActiveAssistantStream();
|
|
||||||
clearActivitySegment();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ev.event === "message") {
|
if (ev.event === "message") {
|
||||||
if (
|
if (
|
||||||
suppressStreamUntilTurnEndRef.current &&
|
suppressStreamUntilTurnEndRef.current &&
|
||||||
@@ -1142,10 +1062,8 @@ export function useNanobotStream(
|
|||||||
ensureActivitySegmentId,
|
ensureActivitySegmentId,
|
||||||
flushPendingStreamEvents,
|
flushPendingStreamEvents,
|
||||||
isSideChannelEvent,
|
isSideChannelEvent,
|
||||||
notifyInBackground,
|
|
||||||
onTurnEnd,
|
onTurnEnd,
|
||||||
schedulePendingStreamFlush,
|
schedulePendingStreamFlush,
|
||||||
t,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
@@ -1255,32 +1173,12 @@ export function useNanobotStream(
|
|||||||
[client],
|
[client],
|
||||||
);
|
);
|
||||||
|
|
||||||
const recoveryAction = useCallback(async (action: "continue" | "dismiss") => {
|
|
||||||
if (!chatId || !recoveryState) return;
|
|
||||||
await client.requestMutation(`recovery.${action}`, {
|
|
||||||
chat_id: chatId,
|
|
||||||
recovery_id: recoveryState.recovery_id,
|
|
||||||
});
|
|
||||||
}, [chatId, client, recoveryState]);
|
|
||||||
|
|
||||||
const continueRecovery = useCallback(
|
|
||||||
() => recoveryAction("continue"),
|
|
||||||
[recoveryAction],
|
|
||||||
);
|
|
||||||
const dismissRecovery = useCallback(
|
|
||||||
() => recoveryAction("dismiss"),
|
|
||||||
[recoveryAction],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
messagesReady: messageOwnerChatId === chatId,
|
messagesReady: messageOwnerChatId === chatId,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
runStartedAt,
|
runStartedAt,
|
||||||
goalState,
|
goalState,
|
||||||
recoveryState,
|
|
||||||
continueRecovery,
|
|
||||||
dismissRecovery,
|
|
||||||
send,
|
send,
|
||||||
transcribeAudio,
|
transcribeAudio,
|
||||||
stop,
|
stop,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
LOCAL_PREFS_CHANGED_EVENT,
|
||||||
|
readLocalPreferences,
|
||||||
|
type LocalPreferences,
|
||||||
|
} from "@/lib/local-preferences";
|
||||||
|
|
||||||
|
export function useShowTurnUsage(): boolean {
|
||||||
|
const [showTurnUsage, setShowTurnUsage] = useState(
|
||||||
|
() => readLocalPreferences().showTurnUsage,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => setShowTurnUsage(readLocalPreferences().showTurnUsage);
|
||||||
|
const refreshFromLocalPreferenceEvent = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<Partial<LocalPreferences> | undefined>).detail;
|
||||||
|
setShowTurnUsage(
|
||||||
|
detail ? detail.showTurnUsage === true : readLocalPreferences().showTurnUsage,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", refresh);
|
||||||
|
window.addEventListener("focus", refresh);
|
||||||
|
window.addEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", refresh);
|
||||||
|
window.removeEventListener("focus", refresh);
|
||||||
|
window.removeEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return showTurnUsage;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { normalizeWorkbenchState } from "@/components/workbench/workbench-model"
|
|||||||
import { fetchSidebarState } from "@/lib/api";
|
import { fetchSidebarState } from "@/lib/api";
|
||||||
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
import type { ChatSummary, SidebarStatePayload } from "@/lib/types";
|
||||||
|
|
||||||
const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
pinned_keys: [],
|
pinned_keys: [],
|
||||||
archived_keys: [],
|
archived_keys: [],
|
||||||
@@ -74,7 +74,7 @@ function boolMap(value: unknown): Record<string, boolean> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSidebarState(raw: unknown): SidebarStatePayload {
|
export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } };
|
return { ...DEFAULT_SIDEBAR_STATE, view: { ...DEFAULT_SIDEBAR_STATE.view } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function normalizeLocale(
|
|||||||
return baseMatch?.code ?? defaultLocale;
|
return baseMatch?.code ?? defaultLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStoredLocale(): SupportedLocale | null {
|
export function readStoredLocale(): SupportedLocale | null {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
const raw = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
applyDocumentLocale,
|
applyDocumentLocale,
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
fallbackLocale,
|
fallbackLocale,
|
||||||
|
LOCALE_STORAGE_KEY,
|
||||||
normalizeLocale,
|
normalizeLocale,
|
||||||
persistLocale,
|
persistLocale,
|
||||||
resolveInitialLocale,
|
resolveInitialLocale,
|
||||||
@@ -46,7 +47,7 @@ export function currentLocale(): SupportedLocale {
|
|||||||
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
|
return normalizeLocale(i18n.resolvedLanguage ?? i18n.language ?? defaultLocale);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLocaleResources(
|
export async function loadLocaleResources(
|
||||||
locale: SupportedLocale,
|
locale: SupportedLocale,
|
||||||
): Promise<LocaleResource> {
|
): Promise<LocaleResource> {
|
||||||
const existing = resourcePromises.get(locale);
|
const existing = resourcePromises.get(locale);
|
||||||
@@ -130,4 +131,5 @@ function syncLocaleSideEffects(language: string) {
|
|||||||
persistLocale(locale);
|
persistLocale(locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { LOCALE_STORAGE_KEY };
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -196,9 +196,9 @@
|
|||||||
"density": "Density",
|
"density": "Density",
|
||||||
"activityMode": "Activity detail",
|
"activityMode": "Activity detail",
|
||||||
"fileEditDisplay": "File edit display",
|
"fileEditDisplay": "File edit display",
|
||||||
|
"showTurnUsage": "Show per-turn token usage",
|
||||||
"codeWrap": "Code wrapping",
|
"codeWrap": "Code wrapping",
|
||||||
"brandLogos": "Brand logos",
|
"brandLogos": "Brand logos",
|
||||||
"browserNotifications": "Task notifications",
|
|
||||||
"maxResults": "Max results",
|
"maxResults": "Max results",
|
||||||
"timeout": "Timeout",
|
"timeout": "Timeout",
|
||||||
"jinaReader": "Jina reader",
|
"jinaReader": "Jina reader",
|
||||||
@@ -242,9 +242,9 @@
|
|||||||
"density": "Stored only in this browser.",
|
"density": "Stored only in this browser.",
|
||||||
"activityMode": "Choose how much agent activity chrome to show by default.",
|
"activityMode": "Choose how much agent activity chrome to show by default.",
|
||||||
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
|
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
|
||||||
|
"showTurnUsage": "Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
|
||||||
"codeWrap": "Keep long code lines readable on smaller screens.",
|
"codeWrap": "Keep long code lines readable on smaller screens.",
|
||||||
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
||||||
"browserNotifications": "Notify only when this page is in the background. Off by default.",
|
|
||||||
"maxResults": "Results returned by each web_search call.",
|
"maxResults": "Results returned by each web_search call.",
|
||||||
"timeout": "Seconds before a search provider request times out.",
|
"timeout": "Seconds before a search provider request times out.",
|
||||||
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
"jinaReader": "Use Jina Reader for web_fetch when available.",
|
||||||
@@ -1015,8 +1015,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent running",
|
"running": "Agent running",
|
||||||
"complete": "Agent finished",
|
"complete": "Agent finished",
|
||||||
"updated": "New activity",
|
"updated": "New activity"
|
||||||
"recovery": "This conversation needs your attention"
|
|
||||||
},
|
},
|
||||||
"pin": "Pin",
|
"pin": "Pin",
|
||||||
"unpin": "Unpin",
|
"unpin": "Unpin",
|
||||||
@@ -1434,23 +1433,15 @@
|
|||||||
"fileEditOpenFile": "Open file",
|
"fileEditOpenFile": "Open file",
|
||||||
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
|
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "Context now: {{tokens}}{{capacity}}",
|
"context": "{{tokens}} context",
|
||||||
"requests": "{{count}} calls this turn",
|
"turnInput": "{{tokens}} turn input",
|
||||||
|
"request": "{{count}} request",
|
||||||
|
"requests": "{{count}} requests",
|
||||||
|
"output": "{{tokens}} out",
|
||||||
|
"cached": "{{percent}}% cached",
|
||||||
"estimated": "Includes estimated usage"
|
"estimated": "Includes estimated usage"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "Recovery action failed. Try again.",
|
|
||||||
"interrupted": "Task interrupted",
|
|
||||||
"completed": "Task completed",
|
|
||||||
"failed": "Task recovery failed",
|
|
||||||
"failedHelp": "The saved task could not be restored safely. Review it before continuing.",
|
|
||||||
"resuming": "Restoring interrupted task…",
|
|
||||||
"review": "Review the task before continuing. Tools will not be replayed automatically.",
|
|
||||||
"safeResume": "Continuing from saved conversation context.",
|
|
||||||
"dismiss": "Dismiss",
|
|
||||||
"continue": "Continue"
|
|
||||||
},
|
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Image preview",
|
"title": "Image preview",
|
||||||
"open": "View image",
|
"open": "View image",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
"density": "Densidad",
|
"density": "Densidad",
|
||||||
"activityMode": "Detalle de actividad",
|
"activityMode": "Detalle de actividad",
|
||||||
"fileEditDisplay": "Vista de edición de archivos",
|
"fileEditDisplay": "Vista de edición de archivos",
|
||||||
|
"showTurnUsage": "Mostrar uso de tokens por turno",
|
||||||
"codeWrap": "Ajuste de código",
|
"codeWrap": "Ajuste de código",
|
||||||
"maxResults": "Resultados máximos",
|
"maxResults": "Resultados máximos",
|
||||||
"timeout": "Tiempo de espera",
|
"timeout": "Tiempo de espera",
|
||||||
@@ -161,7 +162,6 @@
|
|||||||
"webuiDefaultAccess": "Acceso predeterminado",
|
"webuiDefaultAccess": "Acceso predeterminado",
|
||||||
"currentModel": "Configuración actual",
|
"currentModel": "Configuración actual",
|
||||||
"brandLogos": "Logos de marca",
|
"brandLogos": "Logos de marca",
|
||||||
"browserNotifications": "Notificaciones de tareas",
|
|
||||||
"cliAppsCatalog": "Catálogo",
|
"cliAppsCatalog": "Catálogo",
|
||||||
"cliAppsFilter": "Filtro",
|
"cliAppsFilter": "Filtro",
|
||||||
"engine": "Motor",
|
"engine": "Motor",
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
"density": "Solo se guarda en este navegador.",
|
"density": "Solo se guarda en este navegador.",
|
||||||
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
|
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
|
||||||
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.",
|
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.",
|
||||||
|
"showTurnUsage": "Muestra el contexto, la entrada acumulada, la salida, la tasa de caché y las llamadas al modelo debajo de los mensajes del asistente. Solo se guarda en este navegador.",
|
||||||
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
|
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
|
||||||
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
||||||
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
||||||
@@ -205,7 +206,6 @@
|
|||||||
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
"selectedModelProvider": "Definido por el modelo seleccionado.",
|
||||||
"selectedModelValue": "Definido por el modelo seleccionado.",
|
"selectedModelValue": "Definido por el modelo seleccionado.",
|
||||||
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
|
"brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.",
|
||||||
"browserNotifications": "Notifica solo cuando esta página está en segundo plano. Desactivado de forma predeterminada.",
|
|
||||||
"cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.",
|
"cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.",
|
||||||
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
|
"cliAppsFilter": "Busca por aplicación, categoría o capacidad.",
|
||||||
"logs": "Abre la carpeta de registros del motor nativo.",
|
"logs": "Abre la carpeta de registros del motor nativo.",
|
||||||
@@ -1002,8 +1002,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agente en ejecución",
|
"running": "Agente en ejecución",
|
||||||
"complete": "Agente terminado",
|
"complete": "Agente terminado",
|
||||||
"updated": "Nueva actividad",
|
"updated": "Nueva actividad"
|
||||||
"recovery": "Esta conversación requiere tu atención"
|
|
||||||
},
|
},
|
||||||
"pin": "Fijar",
|
"pin": "Fijar",
|
||||||
"unpin": "Desfijar",
|
"unpin": "Desfijar",
|
||||||
@@ -1404,8 +1403,12 @@
|
|||||||
"fileEditOpenFile": "Abrir archivo",
|
"fileEditOpenFile": "Abrir archivo",
|
||||||
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
|
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "Contexto más reciente: {{tokens}}{{capacity}} tokens",
|
"context": "{{tokens}} de contexto",
|
||||||
"requests": "{{count}} solicitudes al modelo",
|
"turnInput": "{{tokens}} de entrada en el turno",
|
||||||
|
"request": "{{count}} solicitud",
|
||||||
|
"requests": "{{count}} solicitudes",
|
||||||
|
"output": "{{tokens}} de salida",
|
||||||
|
"cached": "{{percent}}% en caché",
|
||||||
"estimated": "Incluye uso estimado"
|
"estimated": "Incluye uso estimado"
|
||||||
},
|
},
|
||||||
"activityThinkingFor": "Pensando durante {{duration}}",
|
"activityThinkingFor": "Pensando durante {{duration}}",
|
||||||
@@ -1426,18 +1429,6 @@
|
|||||||
"automationSourceFallback": "Automatización",
|
"automationSourceFallback": "Automatización",
|
||||||
"automationTriggered": "Activada automáticamente"
|
"automationTriggered": "Activada automáticamente"
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "La recuperación falló. Inténtalo de nuevo.",
|
|
||||||
"interrupted": "Tarea interrumpida",
|
|
||||||
"completed": "Tarea completada",
|
|
||||||
"failed": "La recuperación de la tarea falló",
|
|
||||||
"failedHelp": "La tarea guardada no se pudo restaurar de forma segura. Revísala antes de continuar.",
|
|
||||||
"resuming": "Restaurando la tarea interrumpida…",
|
|
||||||
"review": "Revisa la tarea antes de continuar. Las herramientas no se repetirán automáticamente.",
|
|
||||||
"safeResume": "Continuando desde el contexto guardado de la conversación.",
|
|
||||||
"dismiss": "Descartar",
|
|
||||||
"continue": "Continuar"
|
|
||||||
},
|
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Vista previa de imagen",
|
"title": "Vista previa de imagen",
|
||||||
"open": "Ver imagen",
|
"open": "Ver imagen",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
"density": "Densité",
|
"density": "Densité",
|
||||||
"activityMode": "Détail d’activité",
|
"activityMode": "Détail d’activité",
|
||||||
"fileEditDisplay": "Affichage des modifications de fichiers",
|
"fileEditDisplay": "Affichage des modifications de fichiers",
|
||||||
|
"showTurnUsage": "Afficher l’utilisation des tokens par tour",
|
||||||
"codeWrap": "Retour à la ligne du code",
|
"codeWrap": "Retour à la ligne du code",
|
||||||
"maxResults": "Résultats max.",
|
"maxResults": "Résultats max.",
|
||||||
"timeout": "Délai d’attente",
|
"timeout": "Délai d’attente",
|
||||||
@@ -161,7 +162,6 @@
|
|||||||
"webuiDefaultAccess": "Accès par défaut",
|
"webuiDefaultAccess": "Accès par défaut",
|
||||||
"currentModel": "Configuration actuelle",
|
"currentModel": "Configuration actuelle",
|
||||||
"brandLogos": "Logos de marque",
|
"brandLogos": "Logos de marque",
|
||||||
"browserNotifications": "Notifications de tâches",
|
|
||||||
"cliAppsCatalog": "Catalogue",
|
"cliAppsCatalog": "Catalogue",
|
||||||
"cliAppsFilter": "Filtre",
|
"cliAppsFilter": "Filtre",
|
||||||
"engine": "Moteur",
|
"engine": "Moteur",
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
"density": "Enregistré seulement dans ce navigateur.",
|
"density": "Enregistré seulement dans ce navigateur.",
|
||||||
"activityMode": "Choisissez le niveau de détail de l’activité de l’agent affiché par défaut.",
|
"activityMode": "Choisissez le niveau de détail de l’activité de l’agent affiché par défaut.",
|
||||||
"fileEditDisplay": "Choisissez si l’activité de modification affiche le nombre de lignes ou les différences.",
|
"fileEditDisplay": "Choisissez si l’activité de modification affiche le nombre de lignes ou les différences.",
|
||||||
|
"showTurnUsage": "Affiche le contexte, l’entrée cumulée, la sortie, le taux de cache et le nombre d’appels au modèle sous les messages de l’assistant. Enregistré uniquement dans ce navigateur.",
|
||||||
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
|
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
|
||||||
"maxResults": "Résultats renvoyés par chaque appel web_search.",
|
"maxResults": "Résultats renvoyés par chaque appel web_search.",
|
||||||
"timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.",
|
"timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.",
|
||||||
@@ -205,7 +206,6 @@
|
|||||||
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
"selectedModelProvider": "Défini par le modèle sélectionné.",
|
||||||
"selectedModelValue": "Défini par le modèle sélectionné.",
|
"selectedModelValue": "Défini par le modèle sélectionné.",
|
||||||
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
|
"brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.",
|
||||||
"browserNotifications": "Notifier uniquement lorsque cette page est en arrière-plan. Désactivé par défaut.",
|
|
||||||
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI d’applications que nanobot peut exécuter localement ; les applications natives restent inchangées.",
|
"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é.",
|
"cliAppsFilter": "Recherchez par application, catégorie ou capacité.",
|
||||||
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
"logs": "Ouvre le dossier des journaux du moteur natif.",
|
||||||
@@ -1001,8 +1001,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agent en cours",
|
"running": "Agent en cours",
|
||||||
"complete": "Agent terminé",
|
"complete": "Agent terminé",
|
||||||
"updated": "Nouvelle activité",
|
"updated": "Nouvelle activité"
|
||||||
"recovery": "Cette conversation nécessite votre attention"
|
|
||||||
},
|
},
|
||||||
"pin": "Épingler",
|
"pin": "Épingler",
|
||||||
"unpin": "Désépingler",
|
"unpin": "Désépingler",
|
||||||
@@ -1403,8 +1402,12 @@
|
|||||||
"fileEditOpenFile": "Ouvrir le fichier",
|
"fileEditOpenFile": "Ouvrir le fichier",
|
||||||
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
|
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "Dernier contexte : {{tokens}}{{capacity}} tokens",
|
"context": "{{tokens}} de contexte",
|
||||||
"requests": "{{count}} requêtes au modèle",
|
"turnInput": "{{tokens}} en entrée sur ce tour",
|
||||||
|
"request": "{{count}} requête",
|
||||||
|
"requests": "{{count}} requêtes",
|
||||||
|
"output": "{{tokens}} en sortie",
|
||||||
|
"cached": "{{percent}} % en cache",
|
||||||
"estimated": "Inclut une estimation de l’utilisation"
|
"estimated": "Inclut une estimation de l’utilisation"
|
||||||
},
|
},
|
||||||
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
||||||
@@ -1425,18 +1428,6 @@
|
|||||||
"automationSourceFallback": "Automatisation",
|
"automationSourceFallback": "Automatisation",
|
||||||
"automationTriggered": "Déclenché automatiquement"
|
"automationTriggered": "Déclenché automatiquement"
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "La récupération a échoué. Réessayez.",
|
|
||||||
"interrupted": "Tâche interrompue",
|
|
||||||
"completed": "Tâche terminée",
|
|
||||||
"failed": "Échec de la récupération de la tâche",
|
|
||||||
"failedHelp": "La tâche enregistrée 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": {
|
"lightbox": {
|
||||||
"title": "Aperçu de l’image",
|
"title": "Aperçu de l’image",
|
||||||
"open": "Voir l’image",
|
"open": "Voir l’image",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
"density": "Kerapatan",
|
"density": "Kerapatan",
|
||||||
"activityMode": "Detail aktivitas",
|
"activityMode": "Detail aktivitas",
|
||||||
"fileEditDisplay": "Tampilan perubahan file",
|
"fileEditDisplay": "Tampilan perubahan file",
|
||||||
|
"showTurnUsage": "Tampilkan penggunaan token per giliran",
|
||||||
"codeWrap": "Bungkus kode",
|
"codeWrap": "Bungkus kode",
|
||||||
"maxResults": "Hasil maksimum",
|
"maxResults": "Hasil maksimum",
|
||||||
"timeout": "Batas waktu",
|
"timeout": "Batas waktu",
|
||||||
@@ -161,7 +162,6 @@
|
|||||||
"webuiDefaultAccess": "Akses bawaan",
|
"webuiDefaultAccess": "Akses bawaan",
|
||||||
"currentModel": "Konfigurasi saat ini",
|
"currentModel": "Konfigurasi saat ini",
|
||||||
"brandLogos": "Logo merek",
|
"brandLogos": "Logo merek",
|
||||||
"browserNotifications": "Notifikasi tugas",
|
|
||||||
"cliAppsCatalog": "Katalog",
|
"cliAppsCatalog": "Katalog",
|
||||||
"cliAppsFilter": "Saring",
|
"cliAppsFilter": "Saring",
|
||||||
"engine": "Mesin",
|
"engine": "Mesin",
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
"density": "Hanya disimpan di browser ini.",
|
"density": "Hanya disimpan di browser ini.",
|
||||||
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
|
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
|
||||||
"fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.",
|
"fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.",
|
||||||
|
"showTurnUsage": "Tampilkan konteks, input kumulatif, output, rasio cache, dan jumlah panggilan model di bawah pesan asisten. Hanya disimpan di browser ini.",
|
||||||
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
|
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
|
||||||
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
|
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
|
||||||
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
|
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
|
||||||
@@ -205,7 +206,6 @@
|
|||||||
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
"selectedModelProvider": "Ditentukan oleh model yang dipilih.",
|
||||||
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
|
"selectedModelValue": "Ditentukan oleh model yang dipilih.",
|
||||||
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
|
"brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.",
|
||||||
"browserNotifications": "Beri tahu hanya saat halaman ini di latar belakang. Nonaktif secara bawaan.",
|
|
||||||
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
|
"cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.",
|
||||||
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
|
"cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.",
|
||||||
"logs": "Buka folder log mesin asli.",
|
"logs": "Buka folder log mesin asli.",
|
||||||
@@ -1001,8 +1001,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agen sedang berjalan",
|
"running": "Agen sedang berjalan",
|
||||||
"complete": "Agen selesai",
|
"complete": "Agen selesai",
|
||||||
"updated": "Aktivitas baru",
|
"updated": "Aktivitas baru"
|
||||||
"recovery": "Percakapan ini memerlukan perhatian Anda"
|
|
||||||
},
|
},
|
||||||
"pin": "Sematkan",
|
"pin": "Sematkan",
|
||||||
"unpin": "Lepas sematan",
|
"unpin": "Lepas sematan",
|
||||||
@@ -1403,8 +1402,12 @@
|
|||||||
"fileEditOpenFile": "Buka file",
|
"fileEditOpenFile": "Buka file",
|
||||||
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
|
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "Konteks terbaru: {{tokens}}{{capacity}} token",
|
"context": "Konteks {{tokens}}",
|
||||||
"requests": "{{count}} permintaan model",
|
"turnInput": "Input giliran {{tokens}}",
|
||||||
|
"request": "{{count}} permintaan",
|
||||||
|
"requests": "{{count}} permintaan",
|
||||||
|
"output": "Output {{tokens}}",
|
||||||
|
"cached": "{{percent}}% di-cache",
|
||||||
"estimated": "Termasuk penggunaan perkiraan"
|
"estimated": "Termasuk penggunaan perkiraan"
|
||||||
},
|
},
|
||||||
"activityThinkingFor": "Berpikir selama {{duration}}",
|
"activityThinkingFor": "Berpikir selama {{duration}}",
|
||||||
@@ -1425,18 +1428,6 @@
|
|||||||
"automationSourceFallback": "Otomatisasi",
|
"automationSourceFallback": "Otomatisasi",
|
||||||
"automationTriggered": "Dipicu otomatis"
|
"automationTriggered": "Dipicu otomatis"
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "Pemulihan gagal. Coba lagi.",
|
|
||||||
"interrupted": "Tugas terputus",
|
|
||||||
"completed": "Tugas selesai",
|
|
||||||
"failed": "Pemulihan tugas gagal",
|
|
||||||
"failedHelp": "Tugas tersimpan tidak dapat dipulihkan dengan aman. Tinjau sebelum melanjutkan.",
|
|
||||||
"resuming": "Memulihkan tugas yang terputus…",
|
|
||||||
"review": "Tinjau tugas sebelum melanjutkan. Alat tidak akan dijalankan ulang secara otomatis.",
|
|
||||||
"safeResume": "Melanjutkan dari konteks percakapan yang tersimpan.",
|
|
||||||
"dismiss": "Abaikan",
|
|
||||||
"continue": "Lanjutkan"
|
|
||||||
},
|
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Pratinjau gambar",
|
"title": "Pratinjau gambar",
|
||||||
"open": "Lihat gambar",
|
"open": "Lihat gambar",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
"density": "表示密度",
|
"density": "表示密度",
|
||||||
"activityMode": "アクティビティ詳細",
|
"activityMode": "アクティビティ詳細",
|
||||||
"fileEditDisplay": "ファイル編集表示",
|
"fileEditDisplay": "ファイル編集表示",
|
||||||
|
"showTurnUsage": "ターンごとのトークン使用量を表示",
|
||||||
"codeWrap": "コードの折り返し",
|
"codeWrap": "コードの折り返し",
|
||||||
"maxResults": "最大結果数",
|
"maxResults": "最大結果数",
|
||||||
"timeout": "タイムアウト",
|
"timeout": "タイムアウト",
|
||||||
@@ -161,7 +162,6 @@
|
|||||||
"webuiDefaultAccess": "既定の権限",
|
"webuiDefaultAccess": "既定の権限",
|
||||||
"currentModel": "現在の設定",
|
"currentModel": "現在の設定",
|
||||||
"brandLogos": "ブランドロゴ",
|
"brandLogos": "ブランドロゴ",
|
||||||
"browserNotifications": "タスク通知",
|
|
||||||
"cliAppsCatalog": "カタログ",
|
"cliAppsCatalog": "カタログ",
|
||||||
"cliAppsFilter": "フィルター",
|
"cliAppsFilter": "フィルター",
|
||||||
"engine": "エンジン",
|
"engine": "エンジン",
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
"density": "このブラウザーにのみ保存されます。",
|
"density": "このブラウザーにのみ保存されます。",
|
||||||
"activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。",
|
"activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。",
|
||||||
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
|
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
|
||||||
|
"showTurnUsage": "アシスタントのメッセージの下に、コンテキスト、累積入力、出力、キャッシュ率、モデル呼び出し回数を表示します。このブラウザーにのみ保存されます。",
|
||||||
"codeWrap": "小さな画面でも長いコード行を読みやすくします。",
|
"codeWrap": "小さな画面でも長いコード行を読みやすくします。",
|
||||||
"maxResults": "各 web_search 呼び出しで返す結果数です。",
|
"maxResults": "各 web_search 呼び出しで返す結果数です。",
|
||||||
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
|
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
|
||||||
@@ -205,7 +206,6 @@
|
|||||||
"selectedModelProvider": "選択したモデルによって設定されます。",
|
"selectedModelProvider": "選択したモデルによって設定されます。",
|
||||||
"selectedModelValue": "選択したモデルによって設定されます。",
|
"selectedModelValue": "選択したモデルによって設定されます。",
|
||||||
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
|
"brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。",
|
||||||
"browserNotifications": "このページがバックグラウンドにある場合のみ通知します。既定ではオフです。",
|
|
||||||
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
|
"cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。",
|
||||||
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
"cliAppsFilter": "アプリ、カテゴリ、機能で検索します。",
|
||||||
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
"logs": "ネイティブエンジンのログフォルダーを開きます。",
|
||||||
@@ -1001,8 +1001,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "エージェント実行中",
|
"running": "エージェント実行中",
|
||||||
"complete": "エージェント完了",
|
"complete": "エージェント完了",
|
||||||
"updated": "新しいアクティビティ",
|
"updated": "新しいアクティビティ"
|
||||||
"recovery": "この会話には対応が必要です"
|
|
||||||
},
|
},
|
||||||
"pin": "ピン留め",
|
"pin": "ピン留め",
|
||||||
"unpin": "ピン留めを解除",
|
"unpin": "ピン留めを解除",
|
||||||
@@ -1403,8 +1402,12 @@
|
|||||||
"fileEditOpenFile": "ファイルを開く",
|
"fileEditOpenFile": "ファイルを開く",
|
||||||
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
|
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン",
|
"context": "コンテキスト {{tokens}}",
|
||||||
"requests": "モデルリクエスト {{count}} 回",
|
"turnInput": "ターン入力 {{tokens}}",
|
||||||
|
"request": "リクエスト {{count}} 回",
|
||||||
|
"requests": "リクエスト {{count}} 回",
|
||||||
|
"output": "出力 {{tokens}}",
|
||||||
|
"cached": "キャッシュ {{percent}}%",
|
||||||
"estimated": "推定使用量を含みます"
|
"estimated": "推定使用量を含みます"
|
||||||
},
|
},
|
||||||
"activityThinkingFor": "{{duration}}考えています",
|
"activityThinkingFor": "{{duration}}考えています",
|
||||||
@@ -1425,18 +1428,6 @@
|
|||||||
"automationSourceFallback": "自動化",
|
"automationSourceFallback": "自動化",
|
||||||
"automationTriggered": "自動実行"
|
"automationTriggered": "自動実行"
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "復元操作に失敗しました。もう一度お試しください。",
|
|
||||||
"interrupted": "タスクが中断されました",
|
|
||||||
"completed": "タスクが完了しました",
|
|
||||||
"failed": "タスクの復元に失敗しました",
|
|
||||||
"failedHelp": "保存されたタスクを安全に復元できませんでした。続行前に確認してください。",
|
|
||||||
"resuming": "中断されたタスクを復元しています…",
|
|
||||||
"review": "続行する前にタスクを確認してください。ツールは自動的に再実行されません。",
|
|
||||||
"safeResume": "保存された会話コンテキストから続行しています。",
|
|
||||||
"dismiss": "閉じる",
|
|
||||||
"continue": "続行"
|
|
||||||
},
|
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "画像プレビュー",
|
"title": "画像プレビュー",
|
||||||
"open": "画像を表示",
|
"open": "画像を表示",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
"density": "밀도",
|
"density": "밀도",
|
||||||
"activityMode": "활동 상세",
|
"activityMode": "활동 상세",
|
||||||
"fileEditDisplay": "파일 편집 표시",
|
"fileEditDisplay": "파일 편집 표시",
|
||||||
|
"showTurnUsage": "턴별 토큰 사용량 표시",
|
||||||
"codeWrap": "코드 줄바꿈",
|
"codeWrap": "코드 줄바꿈",
|
||||||
"maxResults": "최대 결과 수",
|
"maxResults": "최대 결과 수",
|
||||||
"timeout": "타임아웃",
|
"timeout": "타임아웃",
|
||||||
@@ -161,7 +162,6 @@
|
|||||||
"webuiDefaultAccess": "기본 권한",
|
"webuiDefaultAccess": "기본 권한",
|
||||||
"currentModel": "현재 구성",
|
"currentModel": "현재 구성",
|
||||||
"brandLogos": "브랜드 로고",
|
"brandLogos": "브랜드 로고",
|
||||||
"browserNotifications": "작업 알림",
|
|
||||||
"cliAppsCatalog": "카탈로그",
|
"cliAppsCatalog": "카탈로그",
|
||||||
"cliAppsFilter": "필터",
|
"cliAppsFilter": "필터",
|
||||||
"engine": "엔진",
|
"engine": "엔진",
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
"density": "이 브라우저에만 저장됩니다.",
|
"density": "이 브라우저에만 저장됩니다.",
|
||||||
"activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.",
|
"activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.",
|
||||||
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.",
|
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.",
|
||||||
|
"showTurnUsage": "어시스턴트 메시지 아래에 컨텍스트, 누적 입력, 출력, 캐시 비율, 모델 호출 횟수를 표시합니다. 이 브라우저에만 저장됩니다.",
|
||||||
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
|
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
|
||||||
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
|
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
|
||||||
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
|
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
|
||||||
@@ -205,7 +206,6 @@
|
|||||||
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
"selectedModelProvider": "선택한 모델에 의해 설정됩니다.",
|
||||||
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
|
"selectedModelValue": "선택한 모델에 의해 설정됩니다.",
|
||||||
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
|
"brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.",
|
||||||
"browserNotifications": "이 페이지가 백그라운드에 있을 때만 알립니다. 기본값은 꺼짐입니다.",
|
|
||||||
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
|
"cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.",
|
||||||
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
"cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.",
|
||||||
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
"logs": "네이티브 엔진 로그 폴더를 엽니다.",
|
||||||
@@ -1001,8 +1001,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "에이전트 실행 중",
|
"running": "에이전트 실행 중",
|
||||||
"complete": "에이전트 완료",
|
"complete": "에이전트 완료",
|
||||||
"updated": "새 활동",
|
"updated": "새 활동"
|
||||||
"recovery": "이 대화에는 확인이 필요합니다"
|
|
||||||
},
|
},
|
||||||
"pin": "고정",
|
"pin": "고정",
|
||||||
"unpin": "고정 해제",
|
"unpin": "고정 해제",
|
||||||
@@ -1403,8 +1402,12 @@
|
|||||||
"fileEditOpenFile": "파일 열기",
|
"fileEditOpenFile": "파일 열기",
|
||||||
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
|
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰",
|
"context": "컨텍스트 {{tokens}}",
|
||||||
"requests": "모델 요청 {{count}}회",
|
"turnInput": "턴 입력 {{tokens}}",
|
||||||
|
"request": "요청 {{count}}회",
|
||||||
|
"requests": "요청 {{count}}회",
|
||||||
|
"output": "출력 {{tokens}}",
|
||||||
|
"cached": "캐시됨 {{percent}}%",
|
||||||
"estimated": "예상 사용량 포함"
|
"estimated": "예상 사용량 포함"
|
||||||
},
|
},
|
||||||
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
||||||
@@ -1425,18 +1428,6 @@
|
|||||||
"automationSourceFallback": "자동화",
|
"automationSourceFallback": "자동화",
|
||||||
"automationTriggered": "자동 실행됨"
|
"automationTriggered": "자동 실행됨"
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "복구 작업에 실패했습니다. 다시 시도하세요.",
|
|
||||||
"interrupted": "작업이 중단됨",
|
|
||||||
"completed": "작업 완료",
|
|
||||||
"failed": "작업 복구 실패",
|
|
||||||
"failedHelp": "저장된 작업을 안전하게 복구할 수 없습니다. 계속하기 전에 검토하세요.",
|
|
||||||
"resuming": "중단된 작업을 복구하는 중…",
|
|
||||||
"review": "계속하기 전에 작업을 검토하세요. 도구는 자동으로 다시 실행되지 않습니다.",
|
|
||||||
"safeResume": "저장된 대화 컨텍스트에서 계속합니다.",
|
|
||||||
"dismiss": "닫기",
|
|
||||||
"continue": "계속"
|
|
||||||
},
|
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "이미지 미리보기",
|
"title": "이미지 미리보기",
|
||||||
"open": "이미지 보기",
|
"open": "이미지 보기",
|
||||||
|
|||||||
@@ -196,9 +196,9 @@
|
|||||||
"density": "Densidade",
|
"density": "Densidade",
|
||||||
"activityMode": "Detalhe de atividade",
|
"activityMode": "Detalhe de atividade",
|
||||||
"fileEditDisplay": "Exibição de edição de arquivo",
|
"fileEditDisplay": "Exibição de edição de arquivo",
|
||||||
|
"showTurnUsage": "Mostrar uso de tokens por turno",
|
||||||
"codeWrap": "Quebra de linha no código",
|
"codeWrap": "Quebra de linha no código",
|
||||||
"brandLogos": "Logos de marca",
|
"brandLogos": "Logos de marca",
|
||||||
"browserNotifications": "Notificações de tarefas",
|
|
||||||
"maxResults": "Máx. de resultados",
|
"maxResults": "Máx. de resultados",
|
||||||
"timeout": "Tempo limite",
|
"timeout": "Tempo limite",
|
||||||
"jinaReader": "Leitor Jina",
|
"jinaReader": "Leitor Jina",
|
||||||
@@ -242,9 +242,9 @@
|
|||||||
"density": "Armazenado apenas neste navegador.",
|
"density": "Armazenado apenas neste navegador.",
|
||||||
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
|
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
|
||||||
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
|
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
|
||||||
|
"showTurnUsage": "Mostra contexto, entrada acumulada, saída, taxa de cache e chamadas do modelo abaixo das mensagens do assistente. Salvo somente neste navegador.",
|
||||||
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
|
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
|
||||||
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
|
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
|
||||||
"browserNotifications": "Notifica somente quando esta página está em segundo plano. Desativado por padrão.",
|
|
||||||
"maxResults": "Resultados retornados por cada chamada de web_search.",
|
"maxResults": "Resultados retornados por cada chamada de web_search.",
|
||||||
"timeout": "Segundos antes de uma requisição de busca expirar.",
|
"timeout": "Segundos antes de uma requisição de busca expirar.",
|
||||||
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
|
"jinaReader": "Usa o Jina Reader para web_fetch quando disponível.",
|
||||||
@@ -1015,8 +1015,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Agente em execução",
|
"running": "Agente em execução",
|
||||||
"complete": "Agente finalizado",
|
"complete": "Agente finalizado",
|
||||||
"updated": "Nova atividade",
|
"updated": "Nova atividade"
|
||||||
"recovery": "Esta conversa precisa da sua atenção"
|
|
||||||
},
|
},
|
||||||
"pin": "Fixar",
|
"pin": "Fixar",
|
||||||
"unpin": "Desafixar",
|
"unpin": "Desafixar",
|
||||||
@@ -1434,23 +1433,15 @@
|
|||||||
"fileEditOpenFile": "Abrir arquivo",
|
"fileEditOpenFile": "Abrir arquivo",
|
||||||
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.",
|
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "Contexto atual: {{tokens}}{{capacity}}",
|
"context": "{{tokens}} de contexto",
|
||||||
"requests": "{{count}} chamadas nesta rodada",
|
"turnInput": "{{tokens}} de entrada na rodada",
|
||||||
|
"request": "{{count}} chamada",
|
||||||
|
"requests": "{{count}} chamadas",
|
||||||
|
"output": "{{tokens}} de saída",
|
||||||
|
"cached": "{{percent}}% em cache",
|
||||||
"estimated": "Inclui uso estimado"
|
"estimated": "Inclui uso estimado"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "A recuperação falhou. Tente novamente.",
|
|
||||||
"interrupted": "Tarefa interrompida",
|
|
||||||
"completed": "Tarefa concluída",
|
|
||||||
"failed": "Falha ao recuperar a tarefa",
|
|
||||||
"failedHelp": "Não foi possível restaurar a tarefa salva com segurança. Revise-a antes de continuar.",
|
|
||||||
"resuming": "Restaurando a tarefa interrompida…",
|
|
||||||
"review": "Revise a tarefa antes de continuar. As ferramentas não serão executadas novamente de forma automática.",
|
|
||||||
"safeResume": "Continuando a partir do contexto de conversa salvo.",
|
|
||||||
"dismiss": "Descartar",
|
|
||||||
"continue": "Continuar"
|
|
||||||
},
|
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Pré-visualização de imagem",
|
"title": "Pré-visualização de imagem",
|
||||||
"open": "Ver imagem",
|
"open": "Ver imagem",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
"density": "Mật độ",
|
"density": "Mật độ",
|
||||||
"activityMode": "Chi tiết hoạt động",
|
"activityMode": "Chi tiết hoạt động",
|
||||||
"fileEditDisplay": "Hiển thị sửa tệp",
|
"fileEditDisplay": "Hiển thị sửa tệp",
|
||||||
|
"showTurnUsage": "Hiển thị mức dùng token theo lượt",
|
||||||
"codeWrap": "Xuống dòng mã",
|
"codeWrap": "Xuống dòng mã",
|
||||||
"maxResults": "Kết quả tối đa",
|
"maxResults": "Kết quả tối đa",
|
||||||
"timeout": "Thời gian chờ",
|
"timeout": "Thời gian chờ",
|
||||||
@@ -161,7 +162,6 @@
|
|||||||
"webuiDefaultAccess": "Quyền mặc định",
|
"webuiDefaultAccess": "Quyền mặc định",
|
||||||
"currentModel": "Cấu hình hiện tại",
|
"currentModel": "Cấu hình hiện tại",
|
||||||
"brandLogos": "Logo thương hiệu",
|
"brandLogos": "Logo thương hiệu",
|
||||||
"browserNotifications": "Thông báo tác vụ",
|
|
||||||
"cliAppsCatalog": "Danh mục",
|
"cliAppsCatalog": "Danh mục",
|
||||||
"cliAppsFilter": "Bộ lọc",
|
"cliAppsFilter": "Bộ lọc",
|
||||||
"engine": "Bộ máy",
|
"engine": "Bộ máy",
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
"density": "Chỉ lưu trong trình duyệt này.",
|
"density": "Chỉ lưu trong trình duyệt này.",
|
||||||
"activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.",
|
"activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.",
|
||||||
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.",
|
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.",
|
||||||
|
"showTurnUsage": "Hiển thị ngữ cảnh, đầu vào tích lũy, đầu ra, tỷ lệ bộ nhớ đệm và số lần gọi mô hình bên dưới tin nhắn của trợ lý. Chỉ được lưu trong trình duyệt này.",
|
||||||
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
|
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
|
||||||
"maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.",
|
"maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.",
|
||||||
"timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.",
|
"timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.",
|
||||||
@@ -205,7 +206,6 @@
|
|||||||
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
|
"selectedModelProvider": "Được đặt bởi mô hình đã chọn.",
|
||||||
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
|
"selectedModelValue": "Được đặt bởi mô hình đã chọn.",
|
||||||
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
|
"brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.",
|
||||||
"browserNotifications": "Chỉ thông báo khi trang này ở nền. Mặc định tắt.",
|
|
||||||
"cliAppsCatalog": "Chỉ cài đặt các bộ chuyển đổi CLI ứng dụng mà nanobot có thể chạy cục bộ; ứng dụng gốc không bị thay đổi.",
|
"cliAppsCatalog": "Chỉ cài đặt các bộ chuyển đổi CLI ứng dụng mà nanobot có thể chạy cục bộ; ứng dụng gốc không bị thay đổi.",
|
||||||
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
|
"cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.",
|
||||||
"logs": "Mở thư mục nhật ký của bộ máy gốc.",
|
"logs": "Mở thư mục nhật ký của bộ máy gốc.",
|
||||||
@@ -1001,8 +1001,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "Tác nhân đang chạy",
|
"running": "Tác nhân đang chạy",
|
||||||
"complete": "Tác nhân đã hoàn tất",
|
"complete": "Tác nhân đã hoàn tất",
|
||||||
"updated": "Hoạt động mới",
|
"updated": "Hoạt động mới"
|
||||||
"recovery": "Cuộc trò chuyện này cần bạn xử lý"
|
|
||||||
},
|
},
|
||||||
"pin": "Ghim",
|
"pin": "Ghim",
|
||||||
"unpin": "Bỏ ghim",
|
"unpin": "Bỏ ghim",
|
||||||
@@ -1403,8 +1402,12 @@
|
|||||||
"fileEditOpenFile": "Mở tệp",
|
"fileEditOpenFile": "Mở tệp",
|
||||||
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
|
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "Ngữ cảnh gần nhất: {{tokens}}{{capacity}} token",
|
"context": "Ngữ cảnh {{tokens}}",
|
||||||
"requests": "{{count}} yêu cầu mô hình",
|
"turnInput": "Đầu vào lượt {{tokens}}",
|
||||||
|
"request": "{{count}} yêu cầu",
|
||||||
|
"requests": "{{count}} yêu cầu",
|
||||||
|
"output": "Đầu ra {{tokens}}",
|
||||||
|
"cached": "{{percent}}% đã lưu đệm",
|
||||||
"estimated": "Bao gồm mức sử dụng ước tính"
|
"estimated": "Bao gồm mức sử dụng ước tính"
|
||||||
},
|
},
|
||||||
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
|
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
|
||||||
@@ -1425,18 +1428,6 @@
|
|||||||
"automationSourceFallback": "Tự động hóa",
|
"automationSourceFallback": "Tự động hóa",
|
||||||
"automationTriggered": "Tự động kích hoạt"
|
"automationTriggered": "Tự động kích hoạt"
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "Khôi phục thất bại. Hãy thử lại.",
|
|
||||||
"interrupted": "Tác vụ bị gián đoạn",
|
|
||||||
"completed": "Tác vụ đã hoàn tất",
|
|
||||||
"failed": "Khôi phục tác vụ thất bại",
|
|
||||||
"failedHelp": "Không thể khôi phục an toàn tác vụ đã lưu. Hãy xem lại trước khi tiếp tục.",
|
|
||||||
"resuming": "Đang khôi phục tác vụ bị gián đoạn…",
|
|
||||||
"review": "Hãy xem lại tác vụ trước khi tiếp tục. Công cụ sẽ không tự động chạy lại.",
|
|
||||||
"safeResume": "Đang tiếp tục từ ngữ cảnh hội thoại đã lưu.",
|
|
||||||
"dismiss": "Bỏ qua",
|
|
||||||
"continue": "Tiếp tục"
|
|
||||||
},
|
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Xem trước ảnh",
|
"title": "Xem trước ảnh",
|
||||||
"open": "Xem ảnh",
|
"open": "Xem ảnh",
|
||||||
|
|||||||
@@ -196,9 +196,9 @@
|
|||||||
"density": "密度",
|
"density": "密度",
|
||||||
"activityMode": "活动详情",
|
"activityMode": "活动详情",
|
||||||
"fileEditDisplay": "文件编辑显示",
|
"fileEditDisplay": "文件编辑显示",
|
||||||
|
"showTurnUsage": "显示每轮 Token 用量",
|
||||||
"codeWrap": "代码换行",
|
"codeWrap": "代码换行",
|
||||||
"brandLogos": "品牌 Logo",
|
"brandLogos": "品牌 Logo",
|
||||||
"browserNotifications": "任务通知",
|
|
||||||
"maxResults": "最大结果数",
|
"maxResults": "最大结果数",
|
||||||
"timeout": "超时",
|
"timeout": "超时",
|
||||||
"jinaReader": "Jina 阅读器",
|
"jinaReader": "Jina 阅读器",
|
||||||
@@ -242,9 +242,9 @@
|
|||||||
"density": "只保存在此浏览器中。",
|
"density": "只保存在此浏览器中。",
|
||||||
"activityMode": "选择默认显示多少智能体活动详情。",
|
"activityMode": "选择默认显示多少智能体活动详情。",
|
||||||
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
||||||
|
"showTurnUsage": "在助手消息下显示上下文、累计输入、输出、缓存率和模型调用次数;仅保存在此浏览器。",
|
||||||
"codeWrap": "让长代码行在小屏幕上也易读。",
|
"codeWrap": "让长代码行在小屏幕上也易读。",
|
||||||
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
|
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
|
||||||
"browserNotifications": "仅在页面位于后台时通知,默认关闭。",
|
|
||||||
"maxResults": "每次 web_search 调用返回的结果数。",
|
"maxResults": "每次 web_search 调用返回的结果数。",
|
||||||
"timeout": "搜索提供商请求超时前等待的秒数。",
|
"timeout": "搜索提供商请求超时前等待的秒数。",
|
||||||
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
|
||||||
@@ -1015,8 +1015,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "智能体正在运行",
|
"running": "智能体正在运行",
|
||||||
"complete": "智能体已完成",
|
"complete": "智能体已完成",
|
||||||
"updated": "有新内容",
|
"updated": "有新内容"
|
||||||
"recovery": "此对话需要你的处理"
|
|
||||||
},
|
},
|
||||||
"pin": "置顶",
|
"pin": "置顶",
|
||||||
"unpin": "取消置顶",
|
"unpin": "取消置顶",
|
||||||
@@ -1434,23 +1433,15 @@
|
|||||||
"fileEditOpenFile": "打开文件",
|
"fileEditOpenFile": "打开文件",
|
||||||
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
|
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "当前上下文:{{tokens}}{{capacity}}",
|
"context": "上下文 {{tokens}}",
|
||||||
"requests": "本轮 {{count}} 次模型调用",
|
"turnInput": "本轮输入 {{tokens}}",
|
||||||
|
"request": "{{count}} 次模型调用",
|
||||||
|
"requests": "{{count}} 次模型调用",
|
||||||
|
"output": "输出 {{tokens}}",
|
||||||
|
"cached": "缓存 {{percent}}%",
|
||||||
"estimated": "包含估算用量"
|
"estimated": "包含估算用量"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "恢复操作失败,请重试。",
|
|
||||||
"interrupted": "任务已中断",
|
|
||||||
"completed": "任务已完成",
|
|
||||||
"failed": "任务恢复失败",
|
|
||||||
"failedHelp": "无法安全恢复已保存的任务,继续前请先检查。",
|
|
||||||
"resuming": "正在恢复中断的任务…",
|
|
||||||
"review": "继续前请检查任务。工具不会被自动重放。",
|
|
||||||
"safeResume": "正在从已保存的对话上下文继续。",
|
|
||||||
"dismiss": "忽略",
|
|
||||||
"continue": "继续"
|
|
||||||
},
|
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "图片预览",
|
"title": "图片预览",
|
||||||
"open": "查看图片",
|
"open": "查看图片",
|
||||||
|
|||||||
@@ -142,6 +142,7 @@
|
|||||||
"density": "密度",
|
"density": "密度",
|
||||||
"activityMode": "活動細節",
|
"activityMode": "活動細節",
|
||||||
"fileEditDisplay": "檔案編輯顯示",
|
"fileEditDisplay": "檔案編輯顯示",
|
||||||
|
"showTurnUsage": "顯示每輪 Token 用量",
|
||||||
"codeWrap": "程式碼換行",
|
"codeWrap": "程式碼換行",
|
||||||
"maxResults": "最大結果數",
|
"maxResults": "最大結果數",
|
||||||
"timeout": "逾時",
|
"timeout": "逾時",
|
||||||
@@ -161,7 +162,6 @@
|
|||||||
"webuiDefaultAccess": "預設存取權",
|
"webuiDefaultAccess": "預設存取權",
|
||||||
"currentModel": "目前設定",
|
"currentModel": "目前設定",
|
||||||
"brandLogos": "品牌 Logo",
|
"brandLogos": "品牌 Logo",
|
||||||
"browserNotifications": "任務通知",
|
|
||||||
"cliAppsCatalog": "目錄",
|
"cliAppsCatalog": "目錄",
|
||||||
"cliAppsFilter": "篩選",
|
"cliAppsFilter": "篩選",
|
||||||
"engine": "引擎",
|
"engine": "引擎",
|
||||||
@@ -186,6 +186,7 @@
|
|||||||
"density": "只儲存在此瀏覽器中。",
|
"density": "只儲存在此瀏覽器中。",
|
||||||
"activityMode": "選擇預設顯示多少智能體活動細節。",
|
"activityMode": "選擇預設顯示多少智能體活動細節。",
|
||||||
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
|
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
|
||||||
|
"showTurnUsage": "在助理訊息下顯示上下文、累計輸入、輸出、快取率和模型呼叫次數;僅儲存在此瀏覽器。",
|
||||||
"codeWrap": "讓長程式碼行在小螢幕上也易讀。",
|
"codeWrap": "讓長程式碼行在小螢幕上也易讀。",
|
||||||
"maxResults": "每次呼叫 web_search 所回傳的結果數。",
|
"maxResults": "每次呼叫 web_search 所回傳的結果數。",
|
||||||
"timeout": "搜尋供應商請求逾時前的秒數。",
|
"timeout": "搜尋供應商請求逾時前的秒數。",
|
||||||
@@ -205,7 +206,6 @@
|
|||||||
"selectedModelProvider": "由選取的模型決定。",
|
"selectedModelProvider": "由選取的模型決定。",
|
||||||
"selectedModelValue": "由選取的模型決定。",
|
"selectedModelValue": "由選取的模型決定。",
|
||||||
"brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。",
|
"brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。",
|
||||||
"browserNotifications": "僅在頁面位於背景時通知,預設關閉。",
|
|
||||||
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。",
|
"cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。",
|
||||||
"cliAppsFilter": "依應用程式、類別或功能搜尋。",
|
"cliAppsFilter": "依應用程式、類別或功能搜尋。",
|
||||||
"logs": "開啟原生引擎日誌資料夾。",
|
"logs": "開啟原生引擎日誌資料夾。",
|
||||||
@@ -1001,8 +1001,7 @@
|
|||||||
"activity": {
|
"activity": {
|
||||||
"running": "智能體正在執行",
|
"running": "智能體正在執行",
|
||||||
"complete": "智能體已完成",
|
"complete": "智能體已完成",
|
||||||
"updated": "有新內容",
|
"updated": "有新內容"
|
||||||
"recovery": "此對話需要你的處理"
|
|
||||||
},
|
},
|
||||||
"pin": "置頂",
|
"pin": "置頂",
|
||||||
"unpin": "取消置頂",
|
"unpin": "取消置頂",
|
||||||
@@ -1402,8 +1401,12 @@
|
|||||||
"fileEditOpenFile": "開啟檔案",
|
"fileEditOpenFile": "開啟檔案",
|
||||||
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
|
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
|
||||||
"usage": {
|
"usage": {
|
||||||
"context": "最近一次上下文:{{tokens}}{{capacity}} tokens",
|
"context": "上下文 {{tokens}}",
|
||||||
|
"turnInput": "本輪輸入 {{tokens}}",
|
||||||
|
"request": "{{count}} 次模型請求",
|
||||||
"requests": "{{count}} 次模型請求",
|
"requests": "{{count}} 次模型請求",
|
||||||
|
"output": "輸出 {{tokens}}",
|
||||||
|
"cached": "快取 {{percent}}%",
|
||||||
"estimated": "包含估算用量"
|
"estimated": "包含估算用量"
|
||||||
},
|
},
|
||||||
"activityThinkingFor": "思考中,已 {{duration}}",
|
"activityThinkingFor": "思考中,已 {{duration}}",
|
||||||
@@ -1425,18 +1428,6 @@
|
|||||||
"automationTriggered": "已自動觸發",
|
"automationTriggered": "已自動觸發",
|
||||||
"askAboutSelection": "繼續提問"
|
"askAboutSelection": "繼續提問"
|
||||||
},
|
},
|
||||||
"recovery": {
|
|
||||||
"actionFailed": "復原操作失敗,請再試一次。",
|
|
||||||
"interrupted": "任務已中斷",
|
|
||||||
"completed": "任務已完成",
|
|
||||||
"failed": "任務復原失敗",
|
|
||||||
"failedHelp": "無法安全復原已儲存的任務,繼續前請先檢查。",
|
|
||||||
"resuming": "正在復原中斷的任務…",
|
|
||||||
"review": "繼續前請檢查任務。工具不會自動重播。",
|
|
||||||
"safeResume": "正在從已儲存的對話上下文繼續。",
|
|
||||||
"dismiss": "略過",
|
|
||||||
"continue": "繼續"
|
|
||||||
},
|
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "圖片預覽",
|
"title": "圖片預覽",
|
||||||
"open": "檢視圖片",
|
"open": "檢視圖片",
|
||||||
|
|||||||
@@ -1,32 +1,20 @@
|
|||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
/** A completed turn has two surfaces: one activity container and one final
|
/** A completed turn has two surfaces: one activity container and one final
|
||||||
* answer. Answer text is never inferred to be activity merely because a later
|
* answer. An active turn temporarily preserves arrival order so visible
|
||||||
* tool event arrived; explicit reasoning/activity fields own that distinction. */
|
* Markdown never moves when a later tool starts. */
|
||||||
export type TurnUnit =
|
export type TurnUnit =
|
||||||
| {
|
| {
|
||||||
type: "activity";
|
type: "activity";
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
/** Number of raw UI messages represented by this display unit. */
|
|
||||||
sourceMessageCount: number;
|
|
||||||
turnLatencyMs?: number;
|
turnLatencyMs?: number;
|
||||||
startedAtMs?: number;
|
startedAtMs?: number;
|
||||||
}
|
}
|
||||||
| {
|
| { type: "message"; message: UIMessage };
|
||||||
type: "message";
|
|
||||||
message: UIMessage;
|
|
||||||
/** Number of raw UI messages represented by this display unit. */
|
|
||||||
sourceMessageCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
|
export function isReasoningOnlyAssistant(message: UIMessage): boolean {
|
||||||
if (message.role !== "assistant" || message.kind === "trace") return false;
|
if (message.role !== "assistant" || message.kind === "trace") return false;
|
||||||
if (
|
if (message.activityKind === "model" || message.content.trim().length > 0) return false;
|
||||||
message.activityKind === "model"
|
|
||||||
|| message.content.trim().length > 0
|
|
||||||
|| !!message.media?.length
|
|
||||||
|| !!message.images?.length
|
|
||||||
) return false;
|
|
||||||
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
|
return !!(message.reasoning?.length || message.reasoningStreaming || message.isStreaming);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +42,10 @@ export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
|
|||||||
*
|
*
|
||||||
* user → [one live/completed activity surface] → [one final answer]
|
* user → [one live/completed activity surface] → [one final answer]
|
||||||
*
|
*
|
||||||
* A provider may emit multiple answer segments around tool activity. They are
|
* Assistant text that is followed by another activity is an intermediate model
|
||||||
* merged into the one final answer instead of being reclassified as reasoning;
|
* segment. It remains in the activity timeline, where the renderer keeps its
|
||||||
* only explicit reasoning, trace, and model-activity rows enter the fold.
|
* normal Markdown surface, but it never creates a second answer bubble. This
|
||||||
|
* is the same causal model used by Codex-style transcripts.
|
||||||
*/
|
*/
|
||||||
export function normalizeActivityTimeline(
|
export function normalizeActivityTimeline(
|
||||||
messages: UIMessage[],
|
messages: UIMessage[],
|
||||||
@@ -74,41 +63,50 @@ export function normalizeActivityTimeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ordered = orderMessagesByTurnSeq(turnMessages);
|
const ordered = orderMessagesByTurnSeq(turnMessages);
|
||||||
|
const lastActivityIndex = ordered.reduce(
|
||||||
|
(index, message, current) => isRawActivity(message) ? current : index,
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
const answerIndices = ordered
|
||||||
|
.map((message, index) => ({ message, index }))
|
||||||
|
.filter(({ message }) => isAssistantAnswer(message));
|
||||||
|
const finalAnswerIndex = answerIndices.at(-1)?.index;
|
||||||
|
// A replay can deliver a completed answer before a late trace row. Keep
|
||||||
|
// that answer visible, but place the late activity in the single activity
|
||||||
|
// surface before it. An answer followed by more activity is an
|
||||||
|
// intermediate model segment and stays in that surface in turn order.
|
||||||
|
const hasFinalAnswer = finalAnswerIndex !== undefined
|
||||||
|
&& (finalAnswerIndex > lastActivityIndex || ordered[finalAnswerIndex].isStreaming !== true);
|
||||||
|
|
||||||
const activity: UIMessage[] = [];
|
const activity: UIMessage[] = [];
|
||||||
const answers: UIMessage[] = [];
|
const answers: UIMessage[] = [];
|
||||||
let activitySourceMessageCount = 0;
|
ordered.forEach((message, index) => {
|
||||||
for (const message of ordered) {
|
|
||||||
if (isRawActivity(message)) {
|
if (isRawActivity(message)) {
|
||||||
activity.push(message);
|
activity.push(message);
|
||||||
activitySourceMessageCount += 1;
|
|
||||||
} else if (isAssistantAnswer(message)) {
|
} else if (isAssistantAnswer(message)) {
|
||||||
if (message.reasoning?.trim() || message.reasoningStreaming) {
|
if (message.reasoning?.trim() || message.reasoningStreaming) {
|
||||||
// The synthetic reasoning row and answer both come from one raw
|
|
||||||
// message, so account for that source on the answer unit only.
|
|
||||||
activity.push(reasoningOnlyMessageFromAnswer(message));
|
activity.push(reasoningOnlyMessageFromAnswer(message));
|
||||||
}
|
}
|
||||||
answers.push(stripInlineReasoning(message));
|
if (hasFinalAnswer && index === finalAnswerIndex) {
|
||||||
|
answers.push(stripInlineReasoning(message));
|
||||||
|
} else {
|
||||||
|
activity.push(modelActivitySnippet(message));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
activity.push(message);
|
activity.push(message);
|
||||||
activitySourceMessageCount += 1;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
if (activity.length) {
|
if (activity.length) {
|
||||||
units.push({
|
units.push({
|
||||||
type: "activity",
|
type: "activity",
|
||||||
messages: activity,
|
messages: activity,
|
||||||
sourceMessageCount: activitySourceMessageCount,
|
|
||||||
turnLatencyMs: activityTurnLatencyMs(activity, ordered),
|
turnLatencyMs: activityTurnLatencyMs(activity, ordered),
|
||||||
startedAtMs: activeTurnStartedAtMs,
|
startedAtMs: activeTurnStartedAtMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (answers.length) {
|
if (answers.length) {
|
||||||
units.push({
|
units.push({ type: "message", message: mergeAssistantAnswers(answers) });
|
||||||
type: "message",
|
|
||||||
message: mergeAssistantAnswers(answers),
|
|
||||||
sourceMessageCount: answers.length,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
turnMessages = [];
|
turnMessages = [];
|
||||||
@@ -119,7 +117,7 @@ export function normalizeActivityTimeline(
|
|||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
flushTurn();
|
flushTurn();
|
||||||
units.push({ type: "message", message, sourceMessageCount: 1 });
|
units.push({ type: "message", message });
|
||||||
activeTurnId = message.turnId;
|
activeTurnId = message.turnId;
|
||||||
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
|
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
|
||||||
continue;
|
continue;
|
||||||
@@ -134,16 +132,15 @@ export function normalizeActivityTimeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep an in-flight turn in arrival order. Answer, reasoning, and activity
|
* Keep an in-flight turn in arrival order. Until ``turn_end`` there is no
|
||||||
* semantics are explicit, but a later tool event can still arrive after
|
* reliable way to know whether an assistant text segment is the final answer
|
||||||
* already-visible answer Markdown. Reparenting on each event would make that
|
* or commentary before another tool call. Reclassifying it when that tool
|
||||||
* Markdown tree jump between containers.
|
* arrives makes an already-visible Markdown tree jump between containers.
|
||||||
*
|
*
|
||||||
* Completed turns still use ``normalizeActivityTimeline`` and collapse into
|
* Completed turns still use ``normalizeActivityTimeline`` and collapse into
|
||||||
* one audit surface plus the merged answer. While the turn is active, answer
|
* one audit surface plus the final answer. While the turn is active, text and
|
||||||
* text and contiguous activity phases stay in arrival order. A later tool
|
* contiguous activity phases stay in arrival order. A later tool therefore
|
||||||
* therefore appends a Working surface after existing Markdown instead of
|
* appends a Working surface after existing Markdown instead of reparenting it.
|
||||||
* reparenting it.
|
|
||||||
*/
|
*/
|
||||||
export function projectActivityTimeline(
|
export function projectActivityTimeline(
|
||||||
messages: UIMessage[],
|
messages: UIMessage[],
|
||||||
@@ -179,29 +176,23 @@ function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
|
|||||||
const prompt = messages[0];
|
const prompt = messages[0];
|
||||||
const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined;
|
const startedAtMs = prompt?.role === "user" ? validCreatedAtMs(prompt.createdAt) : undefined;
|
||||||
let activity: UIMessage[] = [];
|
let activity: UIMessage[] = [];
|
||||||
let activitySourceMessageCount = 0;
|
|
||||||
|
|
||||||
if (prompt?.role === "user") {
|
if (prompt?.role === "user") units.push({ type: "message", message: prompt });
|
||||||
units.push({ type: "message", message: prompt, sourceMessageCount: 1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const flushActivity = () => {
|
const flushActivity = () => {
|
||||||
if (!activity.length) return;
|
if (!activity.length) return;
|
||||||
units.push({
|
units.push({
|
||||||
type: "activity",
|
type: "activity",
|
||||||
messages: activity,
|
messages: activity,
|
||||||
sourceMessageCount: activitySourceMessageCount,
|
|
||||||
turnLatencyMs: activityTurnLatencyMs(activity, activity),
|
turnLatencyMs: activityTurnLatencyMs(activity, activity),
|
||||||
startedAtMs,
|
startedAtMs,
|
||||||
});
|
});
|
||||||
activity = [];
|
activity = [];
|
||||||
activitySourceMessageCount = 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) {
|
for (const message of messages.slice(prompt?.role === "user" ? 1 : 0)) {
|
||||||
if (isRawActivity(message)) {
|
if (isRawActivity(message)) {
|
||||||
activity.push(message);
|
activity.push(message);
|
||||||
activitySourceMessageCount += 1;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isAssistantAnswer(message)) {
|
if (isAssistantAnswer(message)) {
|
||||||
@@ -209,15 +200,10 @@ function projectLiveTurn(messages: UIMessage[]): TurnUnit[] {
|
|||||||
activity.push(reasoningOnlyMessageFromAnswer(message));
|
activity.push(reasoningOnlyMessageFromAnswer(message));
|
||||||
}
|
}
|
||||||
flushActivity();
|
flushActivity();
|
||||||
units.push({
|
units.push({ type: "message", message: stripInlineReasoning(message) });
|
||||||
type: "message",
|
|
||||||
message: stripInlineReasoning(message),
|
|
||||||
sourceMessageCount: 1,
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
activity.push(message);
|
activity.push(message);
|
||||||
activitySourceMessageCount += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
flushActivity();
|
flushActivity();
|
||||||
@@ -229,16 +215,7 @@ function isRawActivity(message: UIMessage): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAssistantAnswer(message: UIMessage): boolean {
|
function isAssistantAnswer(message: UIMessage): boolean {
|
||||||
if (message.role !== "assistant" || message.kind === "trace" || message.activityKind === "model") {
|
return message.role === "assistant" && message.kind !== "trace" && message.content.trim().length > 0;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (message.turnPhase === "reasoning" || message.turnPhase === "activity") return false;
|
|
||||||
return (
|
|
||||||
message.turnPhase === "answer"
|
|
||||||
|| message.content.trim().length > 0
|
|
||||||
|| !!message.media?.length
|
|
||||||
|| !!message.images?.length
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
|
function orderMessagesByTurnSeq(messages: UIMessage[]): UIMessage[] {
|
||||||
@@ -271,6 +248,18 @@ function mergeAssistantAnswers(answers: UIMessage[]): UIMessage {
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function modelActivitySnippet(message: UIMessage): UIMessage {
|
||||||
|
return {
|
||||||
|
...stripInlineReasoning(message),
|
||||||
|
id: `${message.id}-activity`,
|
||||||
|
activityKind: "model",
|
||||||
|
turnPhase: "activity",
|
||||||
|
// Keep the source stream state so the activity surface can render this
|
||||||
|
// segment with the same Markdown streaming semantics as a normal answer.
|
||||||
|
isStreaming: message.isStreaming,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
|
||||||
return {
|
return {
|
||||||
id: `${message.id}-reasoning`,
|
id: `${message.id}-reasoning`,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export type AnsiSegment = {
|
|||||||
style?: AnsiStyle;
|
style?: AnsiStyle;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AnsiStyle = {
|
export type AnsiStyle = {
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
fontStyle?: "italic";
|
fontStyle?: "italic";
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import type {
|
|||||||
ProviderOAuthCompletionResult,
|
ProviderOAuthCompletionResult,
|
||||||
ProviderOAuthLoginResult,
|
ProviderOAuthLoginResult,
|
||||||
ProviderSettingsUpdate,
|
ProviderSettingsUpdate,
|
||||||
RecoveryState,
|
|
||||||
SessionDeleteResult,
|
SessionDeleteResult,
|
||||||
SessionHandle,
|
SessionHandle,
|
||||||
SessionAutomationsPayload,
|
SessionAutomationsPayload,
|
||||||
@@ -193,7 +192,6 @@ export async function listSessions(
|
|||||||
preview?: string;
|
preview?: string;
|
||||||
model_preset?: string | null;
|
model_preset?: string | null;
|
||||||
run_started_at?: number | null;
|
run_started_at?: number | null;
|
||||||
recovery_state?: RecoveryState | null;
|
|
||||||
workspace_scope?: WorkspaceScopePayload | null;
|
workspace_scope?: WorkspaceScopePayload | null;
|
||||||
handle?: SessionHandle | null;
|
handle?: SessionHandle | null;
|
||||||
};
|
};
|
||||||
@@ -214,7 +212,6 @@ export async function listSessions(
|
|||||||
preview: s.preview ?? "",
|
preview: s.preview ?? "",
|
||||||
modelPreset: s.model_preset ?? null,
|
modelPreset: s.model_preset ?? null,
|
||||||
runStartedAt: s.run_started_at ?? null,
|
runStartedAt: s.run_started_at ?? null,
|
||||||
recoveryState: s.recovery_state ?? null,
|
|
||||||
workspaceScope: s.workspace_scope ?? null,
|
workspaceScope: s.workspace_scope ?? null,
|
||||||
handle,
|
handle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const DEFAULT_HTTP_TIMEOUT_MS = 20_000;
|
export const DEFAULT_HTTP_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
export async function fetchWithTimeout(
|
export async function fetchWithTimeout(
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type EncodeResponse,
|
type EncodeResponse,
|
||||||
} from "@/workers/imageEncode.worker";
|
} from "@/workers/imageEncode.worker";
|
||||||
|
|
||||||
export type { EncodeResponse, EncodeFailure } from "@/workers/imageEncode.worker";
|
export type { EncodeResponse, EncodeSuccess, EncodeFailure } from "@/workers/imageEncode.worker";
|
||||||
|
|
||||||
type Pending = {
|
type Pending = {
|
||||||
resolve: (r: EncodeResponse) => void;
|
resolve: (r: EncodeResponse) => void;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user