feat(goal): /goal command & long-running tasks (long_task)

* feat(long-task): add LongTaskTool for multi-step agent tasks

Implements a meta-ReAct loop where long-running tasks are broken into
sequential subagent steps, each starting fresh with the original goal
and progress from the previous step. This prevents context drift when
agents work on complex, multi-step tasks.

- Extract build_tool_registry() from SubagentManager for reuse
- Add run_step() for synchronous subagent execution (no bus announcement)
- Add HandoffTool and CompleteTool as signal mechanisms via shared dict
- Add LongTaskTool orchestrator with simplified prompt (8 iterations/step)
- Register LongTaskTool in main agent loop
- Add _extract_handoff_from_messages fallback for robustness

* fix(long-task): add debug logging for step-level observability

* feat(long-task): major overhaul with structured handoffs, validation, and observability

- Structured HandoffState: HandoffTool now accepts files_created,
  files_modified, next_step_hint, and verification fields instead of
  a plain string. Progress is passed between steps as structured data.

- Completion validation round: After complete() is called, a dedicated
  validator step runs to verify the claim against the original goal.
  If validation fails, the task continues rather than returning
  a false completion.

- Dynamic prompt system: 3 Jinja2 templates (step_start, step_middle,
  step_final) selected based on step number. Final steps get tighter
  budget and stronger "wrap up" guidance.

- Automatic file change tracking: Extracts write_file/edit_file events
  from tool_events and injects them into the next step's context if
  the subagent forgot to report them explicitly.

- Budget tracking & adaptive strategy: Cumulative token usage is tracked
  across steps. Per-step tool budget drops from 8 to 4 in the last
  two steps to force handoff/completion.

- Crash retry with graceful degradation: A step that crashes is retried
  once. Persistent crashes terminate the task and return partial progress.

- Full observability hooks for future WebUI integration:
  - set_hooks() with on_step_start, on_step_complete, on_handoff,
    on_validation_started, on_validation_passed, on_validation_failed,
    on_task_complete, on_task_error, and catch-all on_event.
  - Readable state properties: current_step, total_steps, status,
    last_handoff, cumulative_usage, goal.
  - inject_correction() allows external code to send user corrections
    that are injected into the next step's prompt.

- run_step() accepts optional max_iterations for dynamic budget control.

All 27 long-task tests and 11 subagent tests pass.

* test(long-task): add boundary tests and fix race conditions

- Add 7 edge-case tests: validation crash resilience, hook exception safety, mid-run correction injection, FIFO correction ordering, explicit file changes overriding auto-detection, final budget for max_steps=1, and dynamic budget switching boundaries

- Fix assertion in test_long_task_completes_after_multiple_handoffs to match exact prompt format

- Remove asyncio timing hack from test_state_exposure

- Add asyncio.sleep(0) yield in test_inject_correction_during_execution to prevent race between signal injection and step continuation

- All 34 tests passing

* fix(long-task): address code review findings

- Declare _scopes = {"core"} explicitly to prevent recursive nesting in subagent scope
- Document fragile coupling in _extract_file_changes: path extraction depends on
  write_file/edit_file detail format; add debug log for unexpected formats
- Align final-template threshold (max_steps - 2) with budget switch threshold
- Eliminate hasattr(self, "_state") in _reset_state by initializing in __init__

* fix(long-task): honor final signal and file tracking

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(long-task): improve prompt structure and agent contract

- Expand LongTaskTool.description to instruct parent agent on goal
  construction, return value semantics, and how to handle results.
- Expand CompleteTool.description to emphasize that the summary IS the
  final answer returned to the parent agent.
- Prefix validated return value with an explicit "final answer" directive
  to stop parent agent from re-running work.
- Redesign step_start.md: Step 1 is now explicitly for exploration,
  planning, and skeleton-building. complete() is discouraged.
- Remove bulky payload debug logging from _emit(); add targeted
  info/warning/error logs at key state transitions instead.
- Add signal_type to HandoffState for cleaner signal detection.

* test(long-task): expect wrapped completion message after validation

Align assertions with LongTaskTool final return shape on main.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): turn timing strip, latency, and session-switch restore

- Agent loop: publish goal_status run/idle for WebSocket turns; attach
  wall-clock latency_ms on turn_end and persisted assistant metadata.
- WebSocket channel: forward goal_status and latency fields to clients.
- NanobotClient: track goal_status started_at per chat without requiring
  onChat; useNanobotStream restores run strip when returning to a chat.
- Thread UI: composer/shell viewport hooks for run duration and latency;
  format helpers and i18n strings.
- MessageBubble: drop trailing StreamCursor (layout artifact vs block markdown).
- Builtin / tests: model command coverage, websocket and loop tests.

Covers multi-session UX and round-trip timing visibility for the WebUI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: keep message-tool file attachments after canonical history hydrate

- MessageTool records per-turn media paths delivered to the active chat.
- nanobot.utils.session_attachments stages out-of-media-root files and
  merges into the last assistant message before save (loop stays a thin call).
- WebUI MediaCell: use a signed URL as a real download link when present.

Fixes attachments flashing then vanishing on turn_end when paths lived
outside get_media_dir (e.g. workspace files).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): agent activity cluster, stable keys, LTR sheen labels

- Group reasoning and tool traces in AgentActivityCluster with i18n summaries
- Stabilize React list keys for activity clusters (first message id anchor)
- Replace background-clip shimmer with overlay sheen for streaming labels
- ThreadMessages/MessageList integration and locale strings

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): render assistant reasoning with Markdown + deferred stream

- Use MarkdownText for ReasoningBubble body (same GFM/KaTeX path as replies)
- Apply muted/italic prose tokens so thinking stays visually subordinate
- useDeferredValue while reasoningStreaming to ease parser work during deltas
- Preload markdown chunk when trace opens; add regression test with preloaded renderer

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): default-collapse agent activity cluster while Working

Outer fold no longer auto-expands during isTurnStreaming; user opens to see traces.
Header sheen and live summary unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(long_task): cumulative run history, file union, and prompt tuning

Inject cross-step summaries and merged file paths into middle/final step
templates so chains do not lose early context. Strip the last run-history
block when it duplicates Previous Progress to save tokens. Add optional
cumulative_prompt_max_chars and cumulative_step_body_max_chars parameters
with clamped defaults.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): session switch keeps in-flight thread and replays buffered WS

Save the prior chat message list to the per-chat cache in a layout effect
when chatId changes (before stale writes could corrupt another chat).
Skip one post-switch layout cache tick so we do not snapshot the wrong tab.

Buffer inbound events per chat_id when no onChat subscriber is registered
(e.g. user focused another session) and drain on resubscribe up to a cap,
so streaming deltas are not lost while off-tab.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): snap thread scroll to bottom on session open (no smooth glide)

Use scroll-behavior auto on the viewport, instant programmatic scroll when
following new messages and on scrollToBottomSignal. Keep smooth only for
the explicit scroll-to-bottom button.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): respect manual scroll-up after opening a session

Track when the user leaves the bottom with a ref and skip ResizeObserver
and deferred bottom snaps until they return or the conversation is reset.
Remove the time-based force-bottom window that overrode atBottom.

Multi-frame scrollToBottom honours the same guard unless force (scroll button).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Publish long_task UI snapshots on outbound metadata

- Add OUTBOUND_META_AGENT_UI (_agent_ui) for channel-agnostic structured state
- LongTaskTool publishes {kind: long_task, data: snapshot} on the bus with _progress
- WebSocket send forwards metadata as agent_ui for WebUI clients
- Tests for bus payload, WS frame, and progress assertions
- Fix loop progress tests: ignore _goal_status in streaming final filter and
  avoid brittle outbound[-1] ordering after goal status idle messages

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: WebUI long_task activity card and resilient history merge

Add optional ui_summary to the long_task tool for one-line UI labels. Stream
long_task agent_ui into a dedicated message row with timeline, markdown peek,
and a right sheet for details. Merge canonical history after turn_end while
re-inserting long_task rows before the final assistant reply. Collapse
duplicate task_start/step_start steps in the timeline and extend i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor: align long_task with thread_goal and drop orchestrator UI

- Persist sustained objectives via session metadata (long_task / complete_goal); no subagent wiring or tool-driven agent_ui payloads.\n- Remove WebUI long-task activity UI, types, and translations; history merge preserves trace replay only, with legacy long_task rows normalized to traces.\n- Drop long_task prompt templates and get_long_task_run_dir; add webui thread disk helper for gateway persistence tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(agent): thread goal runtime context, tools, and skill

- Add thread_goal_state helper and mirror active objectives into Runtime Context
- Wire loop/context/memory/events as needed for goal metadata in turns
- Expand long_task / complete_goal semantics (pivot/cancel/honest recap)
- Add always-on thread-goal SKILL.md; align /goal command prompt
- Tests for context builder and thread goal state
- Remove unused webui ChatPane component

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(thread-goal): add websocket snapshot helper and publish goal updates from long_task

Introduce thread_goal_ws_blob for bounded JSON snapshots, attach snapshots to
websocket turn_end metadata in AgentLoop, and let long_task fan-out dedicated
thread_goal frames on the websocket channel after persisting session metadata.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(channels): websocket thread_goal frames, turn_end replay, and session API scrub for subagent inject

Emit thread_goal events and optional thread_goal on turn_end; scrub persisted
subagent announce blobs on GET /api/sessions/.../messages and shorten session
list previews so WebUI does not surface full Task/Summarize scaffolding.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): merge ephemeral traces per user turn when reconciling canonical history

Preserve disk/live trace rows inside the matching user–assistant segment instead
of stacking every trace before the final assistant reply (fixes inflated tool
counts after refresh or session switch).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): show assistant reply copy only on the last slice before the next user turn

Avoid duplicate copy affordances on intermediate assistant bubbles that precede
more agent activity in the same turn (tools or further assistant text).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(webui): thread_goal stream plumbing, composer goal strip, sky glow, and client-side subagent scrub projection

Track thread_goal and turn_goal snapshots in NanobotClient, hydrate React state
from thread_goal frames and turn_end, surface objective/elapsed in the composer,
add breathing sky halo CSS while goals are active, mirror server scrub logic on
history hydration and webui_thread snapshots, and extend tests/client mocks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(channels): add Slack Socket Mode connect timeout with actionable timeout errors

Abort hung websockets.connect handshakes after a bounded wait, log REST-vs-WSS
guidance, surface RuntimeError to channel startup, and log successful WSS setup.

Co-authored-by: Cursor <cursoragent@cursor.com>

* webui: expand thread goal in composer bottom sheet

Add ChevronUp control on the run/goal strip that opens a bottom Sheet
with full ui_summary and objective. Inline preview logic in RunElapsedStrip,
add i18n strings across locales, and a composer unit test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(webui): widen dedupeToolCallsForUi input for session API typing

fetchSessionMessages types tool_calls as unknown; accept unknown so tsc
build passes when passing message.tool_calls through.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(agent): extract WebSocket turn run status to webui_turn_helpers

* refactor(skills): rename thread-goal to long-task and document idempotent goals

* feat(skills): rename sustained-goal skill to long-goal and tighten long_task guidance

* chore: remove unused subagent/context/router helpers

* feat(session): rename sustained goal to goal_state and align WS/WebUI

- Move helpers from agent/thread_goal_state to session/goal_state:
  GOAL_STATE_KEY, goal_state_runtime_lines, goal_state_ws_blob, parse_goal_state.
- Session metadata now uses "goal_state"; still read legacy "thread_goal";
  long_task writes drop the legacy key after save.
- WebSocket: event/field goal_state, _goal_state_sync; turn_end carries goal_state;
  accept legacy _thread_goal_sync/thread_goal inbound metadata for dispatch.
- WebUI: GoalStateWsPayload, goalState hook/client props, i18n keys goalState*.
- Runtime Context copy uses "Goal (active):" instead of "Thread goal".

* feat(agent): stream Anthropic thinking deltas and fix stream idle timeout

* refactor(webui): transcript jsonl as sole timeline source

* fix(agent): reject mismatched WS message chat_id and stream reasoning deltas

* feat(webui): hydrate sustained goal and run timer after websocket subscribe

* chore(webui,websocket): remove unused fetch helpers and legacy thread_goal WS paths

* Raise default max_tokens and context window in agent schema.

Align AgentDefaults and ModelPresetConfig with typical Claude-scale usage
(32k completion budget, 256k context window) and update migration tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(gateway): bootstrap prefers in-memory model; clarify websocket naming

* fix(websocket): websocket _handle_message passes is_dm; refresh /status test expectations

---------

Co-authored-by: chengyongru <2755839590@qq.com>
Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren
2026-05-16 01:14:11 +08:00
committed by GitHub
co-authored by Cursor chengyongru chengyongru
parent 2d17a095dc
commit 1c2ea1aad2
99 changed files with 4886 additions and 848 deletions
+17 -11
View File
@@ -6,10 +6,11 @@ import platform
from contextlib import suppress
from importlib.resources import files as pkg_files
from pathlib import Path
from typing import Any
from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
from nanobot.session.goal_state import goal_state_runtime_lines
from nanobot.utils.helpers import (
current_time_str,
detect_image_mime,
@@ -90,8 +91,11 @@ class ContextBuilder:
@staticmethod
def _build_runtime_context(
channel: str | None, chat_id: str | None, timezone: str | None = None,
channel: str | None,
chat_id: str | None,
timezone: str | None = None,
sender_id: str | None = None,
supplemental_lines: Sequence[str] | None = None,
) -> str:
"""Build untrusted runtime metadata block appended after user content."""
lines = [f"Current Time: {current_time_str(timezone)}"]
@@ -99,6 +103,8 @@ class ContextBuilder:
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
if sender_id:
lines += [f"Sender ID: {sender_id}"]
if supplemental_lines:
lines.extend(supplemental_lines)
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
@staticmethod
@@ -147,9 +153,17 @@ class ContextBuilder:
current_role: str = "user",
sender_id: str | None = None,
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
runtime_ctx = self._build_runtime_context(channel, chat_id, self.timezone, sender_id=sender_id)
extra = goal_state_runtime_lines(session_metadata)
runtime_ctx = self._build_runtime_context(
channel,
chat_id,
self.timezone,
sender_id=sender_id,
supplemental_lines=extra or None,
)
user_content = self._build_user_content(current_message, media)
# Merge runtime context and user content into a single user message
@@ -197,11 +211,3 @@ class ContextBuilder:
return text
return images + [{"type": "text", "text": text}]
def add_tool_result(
self, messages: list[dict[str, Any]],
tool_call_id: str, tool_name: str, result: Any,
) -> list[dict[str, Any]]:
"""Add a tool result to the message list."""
messages.append({"role": "tool", "tool_call_id": tool_call_id, "name": tool_name, "content": result})
return messages
+57 -9
View File
@@ -32,6 +32,7 @@ from nanobot.command import CommandContext, CommandRouter, register_builtin_comm
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.goal_state import goal_state_runtime_lines, goal_state_ws_blob
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.artifacts import generated_image_paths_from_messages
from nanobot.utils.document import extract_documents
@@ -39,7 +40,9 @@ from nanobot.utils.helpers import image_placeholder_text
from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
from nanobot.utils.webui_turn_helpers import publish_turn_run_status
if TYPE_CHECKING:
from nanobot.config.schema import (
@@ -104,6 +107,9 @@ class TurnContext:
pending_queue: asyncio.Queue | None = None
pending_summary: str | None = None
turn_wall_started_at: float = field(default_factory=time.time)
turn_latency_ms: int | None = None
trace: list[StateTraceEntry] = field(default_factory=list)
@@ -223,6 +229,7 @@ class AgentLoop:
self.restrict_to_workspace = restrict_to_workspace
self._start_time = time.time()
self._last_usage: dict[str, int] = {}
self._pending_turn_latency_ms: dict[str, int] = {}
self._extra_hooks: list[AgentHook] = hooks or []
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
@@ -437,6 +444,7 @@ class AgentLoop:
bus=self.bus,
subagent_manager=self.subagents,
cron_service=self.cron_service,
sessions=self.sessions,
provider_snapshot_loader=self._provider_snapshot_loader,
image_generation_provider_configs=self._image_generation_provider_configs,
timezone=self.context.timezone or "UTC",
@@ -598,6 +606,7 @@ class AgentLoop:
chat_id=self._runtime_chat_id(msg),
sender_id=msg.sender_id,
session_summary=pending_summary,
session_metadata=session.metadata,
)
async def _dispatch_command_inline(
@@ -714,10 +723,13 @@ class AgentLoop:
content, media = extract_documents(content, media)
media = media or None
user_content = self.context._build_user_content(content, media)
extra = goal_state_runtime_lines(session.metadata) if session is not None else []
runtime_ctx = self.context._build_runtime_context(
pending_msg.channel,
self._runtime_chat_id(pending_msg),
self.context.timezone,
sender_id=pending_msg.sender_id,
supplemental_lines=extra or None,
)
if isinstance(user_content, str):
merged: str | list[dict[str, Any]] = f"{user_content}\n\n{runtime_ctx}"
@@ -930,9 +942,15 @@ class AgentLoop:
# Signal that the turn is fully complete (all tools executed,
# final text streamed). This lets WS clients know when to
# definitively stop the loading indicator.
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
if turn_lat is not None:
turn_metadata["latency_ms"] = int(turn_lat)
sess_turn = self.sessions.get_or_create(session_key)
turn_metadata["goal_state"] = goal_state_ws_blob(sess_turn.metadata)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="", metadata={**msg.metadata, "_turn_end": True},
content="", metadata=turn_metadata,
))
if msg.metadata.get("webui") is True:
async def _generate_title_and_notify() -> None:
@@ -1004,6 +1022,8 @@ class AgentLoop:
"Re-published {} leftover message(s) to bus for session {}",
leftover, session_key,
)
await publish_turn_run_status(self.bus, msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections."""
@@ -1081,7 +1101,9 @@ class AgentLoop:
current_role=current_role,
sender_id=msg.sender_id,
session_summary=pending,
session_metadata=session.metadata,
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
messages, session=session, channel=channel, chat_id=chat_id,
message_id=msg.metadata.get("message_id"),
@@ -1089,7 +1111,11 @@ class AgentLoop:
session_key=key,
pending_queue=pending_queue,
)
self._save_turn(session, all_msgs, 1 + len(history))
wall_done = time.time()
latency_ms = max(0, int((wall_done - t_wall) * 1000))
self._save_turn(session, all_msgs, 1 + len(history), turn_latency_ms=latency_ms)
if channel == "websocket":
self._pending_turn_latency_ms[key] = latency_ms
session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
self._clear_runtime_checkpoint(session)
self.sessions.save(session)
@@ -1210,6 +1236,8 @@ class AgentLoop:
had_injections: bool,
generated_media: list[str],
on_stream: Callable[[str], Awaitable[None]] | None,
*,
turn_latency_ms: int | None = None,
) -> OutboundMessage | None:
"""Assemble the final outbound message from turn results."""
# MessageTool suppression
@@ -1223,6 +1251,8 @@ class AgentLoop:
meta = dict(msg.metadata or {})
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
meta["_streamed"] = True
if turn_latency_ms is not None:
meta["latency_ms"] = int(turn_latency_ms)
return OutboundMessage(
channel=msg.channel,
@@ -1325,6 +1355,7 @@ class AgentLoop:
return "ok"
async def _state_run(self, ctx: TurnContext) -> str:
await publish_turn_run_status(self.bus, ctx.msg, "running")
result = await self._run_agent_loop(
ctx.initial_messages,
on_progress=ctx.on_progress,
@@ -1354,13 +1385,17 @@ class AgentLoop:
ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0)
skip_msgs = ctx.all_messages[ctx.save_skip:]
ctx.generated_media = generated_image_paths_from_messages(skip_msgs)
last_msg = ctx.all_messages[-1] if ctx.all_messages else None
if ctx.generated_media and last_msg and last_msg.get("role") == "assistant":
existing_media = last_msg.get("media")
media = existing_media if isinstance(existing_media, list) else []
last_msg["media"] = list(dict.fromkeys([*media, *ctx.generated_media]))
mt = self.tools.get("message")
extra = getattr(mt, "turn_delivered_media_paths", lambda: [])() if mt else []
merge_turn_media_into_last_assistant(ctx.all_messages, ctx.generated_media, extra)
self._save_turn(ctx.session, ctx.all_messages, ctx.save_skip)
ctx.turn_latency_ms = max(0, int((time.time() - ctx.turn_wall_started_at) * 1000))
self._save_turn(
ctx.session, ctx.all_messages, ctx.save_skip,
turn_latency_ms=ctx.turn_latency_ms,
)
if ctx.msg.channel == "websocket":
self._pending_turn_latency_ms[ctx.session_key] = ctx.turn_latency_ms
ctx.session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
self._clear_pending_user_turn(ctx.session)
self._clear_runtime_checkpoint(ctx.session)
@@ -1382,6 +1417,7 @@ class AgentLoop:
ctx.had_injections,
ctx.generated_media,
ctx.on_stream,
turn_latency_ms=ctx.turn_latency_ms,
)
return "ok"
@@ -1425,10 +1461,18 @@ class AgentLoop:
return filtered
def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None:
def _save_turn(
self,
session: Session,
messages: list[dict],
skip: int,
*,
turn_latency_ms: int | None = None,
) -> None:
"""Save new-turn messages into session, truncating large tool results."""
from datetime import datetime
last_assistant_idx: int | None = None
for m in messages[skip:]:
entry = dict(m)
role, content = entry.get("role"), entry.get("content")
@@ -1458,6 +1502,10 @@ class AgentLoop:
entry["content"] = filtered
entry.setdefault("timestamp", datetime.now().isoformat())
session.messages.append(entry)
if role == "assistant":
last_assistant_idx = len(session.messages) - 1
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.updated_at = datetime.now()
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
+1
View File
@@ -604,6 +604,7 @@ class Consolidator:
chat_id=chat_id,
sender_id=None,
session_summary=summary,
session_metadata=session.metadata,
)
return estimate_prompt_tokens_chain(
self.provider,
+7
View File
@@ -626,9 +626,16 @@ class AgentRunner:
context.streamed_content = True
await hook.on_stream(context, delta)
async def _thinking(delta: str) -> None:
if not delta:
return
context.streamed_reasoning = True
await hook.emit_reasoning(delta)
coro = self.provider.chat_stream_with_retry(
**kwargs,
on_content_delta=_stream,
on_thinking_delta=_thinking,
)
elif wants_progress_streaming:
stream_buf = ""
+9 -3
View File
@@ -108,12 +108,18 @@ class SubagentManager:
restrict_to_workspace=self.restrict_to_workspace,
)
def _build_tools(self) -> ToolRegistry:
def _build_tools(
self,
workspace: Path | None = None,
tools_config: ToolsConfig | None = None,
) -> ToolRegistry:
"""Build an isolated subagent tool registry via ToolLoader."""
root = self.workspace if workspace is None else workspace
registry = ToolRegistry()
cfg = tools_config if tools_config is not None else self._subagent_tools_config()
ctx = ToolContext(
config=self._subagent_tools_config(),
workspace=str(self.workspace),
config=cfg,
workspace=str(root.resolve()),
file_state_store=FileStates(),
)
ToolLoader().load(ctx, registry, scope="subagent")
+1
View File
@@ -28,6 +28,7 @@ class ToolContext:
bus: Any | None = None
subagent_manager: Any | None = None
cron_service: Any | None = None
sessions: Any | None = None
file_state_store: Any = field(default=None)
provider_snapshot_loader: Callable[[], Any] | None = None
image_generation_provider_configs: dict[str, Any] | None = None
-5
View File
@@ -594,11 +594,6 @@ def _find_matches(content: str, old_text: str) -> list[_MatchSpan]:
return []
def _find_match_line_numbers(content: str, old_text: str) -> list[int]:
"""Return 1-based starting line numbers for the current matching strategies."""
return [match.line for match in _find_matches(content, old_text)]
def _collapse_internal_whitespace(text: str) -> str:
return "\n".join(" ".join(line.split()) for line in text.splitlines())
+1 -1
View File
@@ -112,5 +112,5 @@ class ToolLoader:
if not is_plugin_source:
builtin_names.add(tool.name)
except Exception:
logger.error("Failed to register tool: %s", cls_label)
logger.exception("Failed to register tool: %s", cls_label)
return registered
+233
View File
@@ -0,0 +1,233 @@
"""Sustained goal tools on the main agent (Codex-style).
Follow the built-in **long-goal** skill for lifecycle rules and how to phrase
objectives (especially **idempotent**, compaction-safe goals). Load that skill
from the skills listing (path shown there) before composing ``long_task.goal`` text.
``long_task`` registers an objective on the session (JSON-serializable metadata).
Active objectives are mirrored each turn into the Runtime Context block (see
``nanobot.session.goal_state.goal_state_runtime_lines``) so compaction cannot hide them.
Work proceeds in ordinary agent turns (same runner, compaction as configured).
Call ``complete_goal`` when the sustained objective should stop being tracked:
finished successfully, or cancelled / superseded / redirectedin every case the recap should match reality.
There is **no** sub-agent orchestrator and **no** special WebSocket ``agent_ui`` stream.
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.context import ContextAware, RequestContext
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.events import OutboundMessage
from nanobot.session.goal_state import (
GOAL_STATE_KEY,
discard_legacy_goal_state_key,
goal_state_raw,
goal_state_ws_blob,
parse_goal_state,
)
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
def _iso_now() -> str:
return datetime.now().isoformat()
class _GoalToolsMixin(ContextAware):
"""Shared routing context + Session lookup."""
def __init__(self, sessions: SessionManager, bus: Any | None = None) -> None:
self._sessions = sessions
self._bus = bus
self._request_ctx: RequestContext | None = None
def set_context(self, ctx: RequestContext) -> None:
self._request_ctx = ctx
def _session(self):
if self._request_ctx is None:
return None
key = self._request_ctx.session_key
if not key:
return None
return self._sessions.get_or_create(key)
async def _publish_goal_state_ws(self, metadata: dict[str, Any]) -> None:
"""Fan-out authoritative goal snapshot for this WebSocket chat only."""
bus = self._bus
rc = self._request_ctx
if bus is None or rc is None or rc.channel != "websocket":
return
cid = (rc.chat_id or "").strip()
if not cid:
return
await bus.publish_outbound(
OutboundMessage(
channel="websocket",
chat_id=cid,
content="",
metadata={
"_goal_state_sync": True,
"goal_state": goal_state_ws_blob(metadata),
},
),
)
@tool_parameters(
tool_parameters_schema(
goal=StringSchema(
"Full objective text for sustained execution on this chat thread. "
"Required: read the entire **long-goal** skill before composing this argument "
"(locate **long-goal** in the skills listing and open its file path, e.g. read_file)—do **not** "
"call `long_task` until you have read it. "
"Apply that skill literally: desired outcomes and acceptance criteria; "
"idempotent, self-contained wording (safe across compaction and resume; "
"no duplicate destructive steps); explicit deliverables, scope boundaries, and verification.",
max_length=12_000,
),
ui_summary=StringSchema(
"Optional one-line label for session lists / logs (≤120 chars).",
max_length=120,
nullable=True,
),
required=["goal"],
)
)
class LongTaskTool(Tool, _GoalToolsMixin):
"""Begin or replace focus on a long-running objective stored on the session."""
def __init__(self, sessions: Any, bus: Any | None = None) -> None:
_GoalToolsMixin.__init__(self, sessions, bus)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None # guarded by enabled()
return cls(sessions=sess, bus=getattr(ctx, "bus", None))
@classmethod
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
@property
def name(self) -> str:
return "long_task"
@property
def description(self) -> str:
return (
"Declare a sustained objective for this conversation. "
"Before calling: read the **long-goal** skill from its path in the skills listing—goals must be "
"idempotent and self-contained (clear end state, scope, verification), "
"not brittle step lists that break on retry or compaction. "
"Execution stays on the main agent across turns (use normal tools). "
"The active objective is mirrored each turn under Runtime Context as "
"\"Goal (active):\" plus the stored text. "
"When—and only when—the objective is fully satisfied, call complete_goal. "
"Do not call complete_goal for partial progress or because you are tired. "
"If an objective is already active, finish or complete_goal before starting another."
)
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
sess = self._session()
if sess is None:
return (
"Error: long_task requires an active chat session (missing routing context)."
)
prior = parse_goal_state(goal_state_raw(sess.metadata))
if isinstance(prior, dict) and prior.get("status") == "active":
return (
"Error: a sustained goal is already active. "
"Use complete_goal when finished, or ask the user before replacing it."
)
summary = (ui_summary or "").strip()[:120]
blob = {
"status": "active",
"objective": goal.strip(),
"ui_summary": summary,
"started_at": _iso_now(),
}
sess.metadata[GOAL_STATE_KEY] = blob
discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess)
await self._publish_goal_state_ws(sess.metadata)
extra = f"\nSummary line: {summary}" if summary else ""
return (
"Goal recorded. Keep working toward the objective using ordinary tools. "
"When fully done (verified against what was asked), call complete_goal with a "
f"short recap.{extra}"
)
@tool_parameters(
tool_parameters_schema(
recap=StringSchema(
"Brief recap for the user (plain text). When the goal succeeded, confirm outcomes; "
"if the user cancelled, pivoted, or replaced the objective, say so honestly.",
max_length=8000,
nullable=True,
),
required=[],
)
)
class CompleteGoalTool(Tool, _GoalToolsMixin):
"""Mark the active sustained goal finished after all required work is verified."""
def __init__(self, sessions: Any, bus: Any | None = None) -> None:
_GoalToolsMixin.__init__(self, sessions, bus)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None
return cls(sessions=sess, bus=getattr(ctx, "bus", None))
@classmethod
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
@property
def name(self) -> str:
return "complete_goal"
@property
def description(self) -> str:
return (
"End bookkeeping for the active sustained goal. "
"Use when the objective is fully achieved and verified—recap what was delivered. "
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
"what actually happened (not necessarily success). "
"If no goal is active, the tool reports that and leaves metadata unchanged."
)
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
sess = self._session()
if sess is None:
return "Error: complete_goal requires an active chat session."
prior = parse_goal_state(goal_state_raw(sess.metadata))
if not isinstance(prior, dict) or prior.get("status") != "active":
return "No active goal to complete."
ended = _iso_now()
sess.metadata[GOAL_STATE_KEY] = {
**prior,
"status": "completed",
"completed_at": ended,
"recap": (recap or "").strip(),
}
discard_legacy_goal_state_key(sess.metadata)
self._sessions.save(sess)
await self._publish_goal_state_ws(sess.metadata)
tail = (recap or "").strip()
if tail:
return f"Goal marked complete ({ended}). Recap:\n{tail}"
return f"Goal marked complete ({ended})."
+28
View File
@@ -24,6 +24,8 @@ from nanobot.config.paths import get_workspace_path
),
chat_id=StringSchema(
"Optional target chat/user ID for cross-channel/proactive delivery. "
"On WebSocket/WebUI turns: omit chat_id to use the server's conversation id "
"(never pass client_id values like anon-…). "
"Do not set this to the current runtime chat for a normal reply."
),
media=ArraySchema(
@@ -72,6 +74,10 @@ class MessageTool(Tool, ContextAware):
default={},
)
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar(
"message_turn_delivered_media",
default=(),
)
self._record_channel_delivery_var: ContextVar[bool] = ContextVar(
"message_record_channel_delivery",
default=False,
@@ -100,6 +106,11 @@ class MessageTool(Tool, ContextAware):
def start_turn(self) -> None:
"""Reset per-turn send tracking."""
self._sent_in_turn = False
self._turn_delivered_media_var.set(())
def turn_delivered_media_paths(self) -> list[str]:
"""Absolute paths attached via this tool to the active chat in the current turn."""
return list(self._turn_delivered_media_var.get())
def set_record_channel_delivery(self, active: bool):
"""Mark tool-sent messages as proactive channel deliveries."""
@@ -172,6 +183,20 @@ class MessageTool(Tool, ContextAware):
default_channel = self._default_channel.get()
default_chat_id = self._default_chat_id.get()
channel = channel or default_channel
explicit_chat_id = chat_id
if (
default_channel == "websocket"
and channel == "websocket"
and explicit_chat_id is not None
and str(explicit_chat_id).strip() != ""
and str(explicit_chat_id).strip() != str(default_chat_id).strip()
):
return (
"Error: chat_id does not match the active WebSocket conversation. "
"Omit chat_id (and usually channel) so delivery uses the current "
"conversation id from context — WebSocket client_id strings "
"(e.g. anon-…) are not chat ids."
)
chat_id = chat_id or default_chat_id
# Only inherit default message_id when targeting the same channel+chat.
# Cross-chat sends must not carry the original message_id, because
@@ -215,6 +240,9 @@ class MessageTool(Tool, ContextAware):
await self._send_callback(msg)
if channel == default_channel and chat_id == default_chat_id:
self._sent_in_turn = True
if media:
prev = self._turn_delivered_media_var.get()
self._turn_delivered_media_var.set(prev + tuple(str(p) for p in media))
media_info = f" with {len(media)} attachments" if media else ""
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
+11 -1
View File
@@ -4,6 +4,11 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
# Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI
# payloads. Value is JSON-serializable with at least ``kind``; rich clients may
# render it and other channels may ignore unknown keys.
OUTBOUND_META_AGENT_UI = "_agent_ui"
@dataclass
class InboundMessage:
@@ -26,7 +31,12 @@ class InboundMessage:
@dataclass
class OutboundMessage:
"""Message to send to a chat channel."""
"""Message to send to a chat channel.
``metadata`` can carry routing (``message_id``, ), trace flags (``_progress``),
and optional ``OUTBOUND_META_AGENT_UI`` blobs for rich clients; non-WebUI
channels may ignore unknown keys.
"""
channel: str
chat_id: str
+7 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import hashlib
from collections.abc import Callable
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -55,10 +56,12 @@ class ChannelManager:
bus: MessageBus,
*,
session_manager: "SessionManager | None" = None,
webui_runtime_model_name: Callable[[], str | None] | None = None,
):
self.config = config
self.bus = bus
self._session_manager = session_manager
self._webui_runtime_model_name = webui_runtime_model_name
self.channels: dict[str, BaseChannel] = {}
self._dispatch_task: asyncio.Task | None = None
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
@@ -89,11 +92,14 @@ class ChannelManager:
kwargs: dict[str, Any] = {}
# Only the WebSocket channel currently hosts the embedded webui
# surface; other channels stay oblivious to these knobs.
if cls.name == "websocket" and self._session_manager is not None:
if cls.name == "websocket":
if self._session_manager is not None:
kwargs["session_manager"] = self._session_manager
static_path = _default_webui_dist()
if static_path is not None:
kwargs["static_dist_path"] = static_path
if self._webui_runtime_model_name is not None:
kwargs["runtime_model_name"] = self._webui_runtime_model_name
channel = cls(section, self.bus, **kwargs)
channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key
+21 -1
View File
@@ -52,6 +52,10 @@ class SlackConfig(Base):
SLACK_MAX_MESSAGE_LEN = 39_000 # Slack API allows ~40k; leave margin
SLACK_DOWNLOAD_TIMEOUT = 30.0
# Abort Socket Mode WSS handshake after this many seconds. REST auth_test can still
# succeed while WSS blocks (firewall / region). slack-sdk does not apply HTTP(S)_PROXY
# to websockets.connect — see slack_sdk.socket_mode.websockets.SocketModeClient.connect.
SLACK_SOCKET_CONNECT_TIMEOUT_S = 45.0
_HTML_DOWNLOAD_PREFIXES = (b"<!doctype html", b"<html")
@@ -109,7 +113,23 @@ class SlackChannel(BaseChannel):
self.logger.warning("auth_test failed: {}", e)
self.logger.info("Starting Socket Mode client...")
await self._socket_client.connect()
try:
await asyncio.wait_for(
self._socket_client.connect(),
timeout=SLACK_SOCKET_CONNECT_TIMEOUT_S,
)
except asyncio.TimeoutError:
self.logger.error(
"Slack Socket Mode WebSocket handshake timed out after {:.0f}s. "
"auth_test uses HTTPS and may still succeed while WSS is blocked. "
"Check outbound access to Slack WebSockets; slack-sdk Socket Mode "
"does not apply HTTP(S)_PROXY to websockets.connect.",
SLACK_SOCKET_CONNECT_TIMEOUT_S,
)
await self.stop()
raise RuntimeError("Slack Socket Mode WebSocket connect timed out") from None
self.logger.info("Slack Socket Mode WebSocket connected (events enabled)")
while self._running:
await asyncio.sleep(1)
+235 -31
View File
@@ -17,6 +17,7 @@ import shutil
import ssl
import time
import uuid
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self
from urllib.parse import parse_qs, unquote, urlparse
@@ -29,17 +30,22 @@ from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from websockets.http11 import Response
from nanobot.bus.events import OutboundMessage
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.utils.helpers import safe_filename
from nanobot.utils.media_decode import (
FileSizeExceeded,
save_base64_data_url,
)
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
from nanobot.utils.webui_thread_disk import delete_webui_thread
from nanobot.utils.webui_transcript import append_transcript_object, build_webui_thread_response
from nanobot.utils.webui_turn_helpers import websocket_turn_wall_started_at
if TYPE_CHECKING:
from nanobot.session.manager import SessionManager
@@ -152,7 +158,7 @@ def publish_runtime_model_update(
model: str,
model_preset: str | None,
) -> None:
"""Publish a WebUI runtime-model update onto the outbound bus."""
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
bus.outbound.put_nowait(OutboundMessage(
channel="websocket",
chat_id="*",
@@ -165,18 +171,35 @@ def publish_runtime_model_update(
))
def _read_webui_model_name() -> str | None:
"""Return the resolved startup model for readonly WebUI display."""
def _default_model_name_from_config() -> str | None:
"""Resolved model string from on-disk config (bootstrap fallback)."""
try:
from nanobot.config.loader import load_config
model = load_config().resolve_preset().model.strip()
return model or None
except Exception as e:
logger.debug("webui bootstrap could not load model name: {}", e)
logger.debug("bootstrap model_name could not load from config: {}", e)
return None
def _resolve_bootstrap_model_name(
runtime_name: Callable[[], str | None] | None,
) -> str | None:
"""Prefer an in-process resolver (e.g. AgentLoop); else config-derived default."""
if runtime_name is not None:
try:
raw = runtime_name()
except Exception as e:
logger.debug("bootstrap runtime model resolver failed: {}", e)
else:
if isinstance(raw, str):
stripped = raw.strip()
if stripped:
return stripped
return _default_model_name_from_config()
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
"""Parse normalized path and query parameters in one pass."""
parsed = urlparse("ws://x" + path_with_query)
@@ -436,6 +459,7 @@ class WebSocketChannel(BaseChannel):
*,
session_manager: "SessionManager | None" = None,
static_dist_path: Path | None = None,
runtime_model_name: Callable[[], str | None] | None = None,
):
if isinstance(config, dict):
config = WebSocketConfig.model_validate(config)
@@ -449,7 +473,7 @@ class WebSocketChannel(BaseChannel):
self._conn_default: dict[Any, str] = {}
# Single-use tokens consumed at WebSocket handshake.
self._issued_tokens: dict[str, float] = {}
# Multi-use tokens for the embedded webui's REST surface; checked but not consumed.
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
self._api_tokens: dict[str, float] = {}
self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None
@@ -457,6 +481,7 @@ class WebSocketChannel(BaseChannel):
self._static_dist_path: Path | None = (
static_dist_path.resolve() if static_dist_path is not None else None
)
self._runtime_model_name = runtime_model_name
# Process-local secret used to HMAC-sign media URLs. The signed URL is
# the capability — anyone who holds a valid URL can fetch that one
# file, nothing else. The secret regenerates on restart so links
@@ -482,6 +507,36 @@ class WebSocketChannel(BaseChannel):
self._subs.pop(cid, None)
self._conn_default.pop(connection, None)
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
Goal metadata lives on the session JSONL and survives gateway restarts, but
connected clients normally see it via ``goal_state`` / ``turn_end`` frames.
Pushing here makes refresh + reconnect restore the strip without a new model turn.
"""
if self._session_manager is None:
return
row = self._session_manager.read_session_file(f"websocket:{chat_id}")
meta = row.get("metadata", {}) if isinstance(row, dict) else {}
if not isinstance(meta, dict):
meta = {}
blob = goal_state_ws_blob(meta)
if not blob.get("active"):
return
await self.send_goal_state(chat_id, blob)
async def _maybe_push_turn_run_wall_clock(self, chat_id: str) -> None:
"""Replay ``goal_status: running`` when a turn is still active (same-process refresh)."""
t0 = websocket_turn_wall_started_at(chat_id)
if t0 is None:
return
await self.send_goal_status(chat_id, "running", started_at=t0)
async def _hydrate_after_subscribe(self, chat_id: str) -> None:
"""Replay goal/run strip state after subscribe (same-process refresh)."""
await self._maybe_push_active_goal_state(chat_id)
await self._maybe_push_turn_run_wall_clock(chat_id)
async def _send_event(self, connection: Any, event: str, **fields: Any) -> None:
"""Send a control event (attached, error, ...) to a single connection."""
payload: dict[str, Any] = {"event": event}
@@ -575,11 +630,11 @@ class WebSocketChannel(BaseChannel):
if got == issue_expected:
return self._handle_token_issue_http(connection, request)
# 2. WebUI bootstrap: mints tokens for the embedded UI.
# 2. Bootstrap (`/webui/bootstrap`): mint WS/API tokens + shared session metadata.
if got == "/webui/bootstrap":
return self._handle_webui_bootstrap(connection, request)
return self._handle_bootstrap(connection, request)
# 3. REST surface for the embedded UI.
# 3. REST handlers co-located with this channel (sessions, settings, …).
if got == "/api/sessions":
return self._handle_sessions_list(request)
@@ -602,6 +657,10 @@ class WebSocketChannel(BaseChannel):
if m:
return self._handle_session_messages(request, m.group(1))
m = re.match(r"^/api/sessions/([^/]+)/webui-thread$", got)
if m:
return self._handle_webui_thread_get(request, m.group(1))
# NOTE: websockets' HTTP parser only accepts GET, so we cannot expose a
# true ``DELETE`` verb. The action is folded into the path instead.
m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
@@ -659,7 +718,7 @@ class WebSocketChannel(BaseChannel):
if now > expiry:
self._api_tokens.pop(token_key, None)
def _handle_webui_bootstrap(self, connection: Any, request: Any) -> Response:
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
# When a secret is configured (token_issue_secret or static token),
# validate it regardless of source IP. This secures deployments
# behind a reverse proxy where all connections appear as localhost.
@@ -669,7 +728,7 @@ class WebSocketChannel(BaseChannel):
return _http_error(401, "Unauthorized")
elif not _is_localhost(connection):
# No secret configured: only allow localhost (local dev mode).
return _http_error(403, "webui bootstrap is localhost-only")
return _http_error(403, "bootstrap is localhost-only")
# Cap outstanding tokens to avoid runaway growth from a misbehaving client.
self._purge_expired_issued_tokens()
self._purge_expired_api_tokens()
@@ -693,7 +752,7 @@ class WebSocketChannel(BaseChannel):
"token": token,
"ws_path": self._expected_path(),
"expires_in": self.config.token_ttl_s,
"model_name": _read_webui_model_name(),
"model_name": _resolve_bootstrap_model_name(self._runtime_model_name),
}
)
@@ -703,10 +762,8 @@ class WebSocketChannel(BaseChannel):
if self._session_manager is None:
return _http_error(503, "session manager unavailable")
sessions = self._session_manager.list_sessions()
# The webui is only meaningful for websocket-channel chats — CLI /
# Slack / Lark / Discord sessions can't be resumed from the browser,
# so leaking them into the sidebar is just noise. Filter to the
# ``websocket:`` prefix and strip absolute paths on the way out.
# Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc.
# keys are not intended for resume over this HTTP surface.
cleaned = [
{k: v for k, v in s.items() if k != "path"}
for s in sessions
@@ -918,8 +975,8 @@ class WebSocketChannel(BaseChannel):
return _http_json_response(self._settings_payload(requires_restart=False))
@staticmethod
def _is_webui_session_key(key: str) -> bool:
"""Return True when *key* belongs to the webui's websocket-only surface."""
def _is_websocket_channel_session_key(key: str) -> bool:
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
return key.startswith("websocket:")
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
@@ -930,14 +987,16 @@ class WebSocketChannel(BaseChannel):
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
# The embedded webui only understands websocket-channel sessions. Keep
# its read surface aligned with ``/api/sessions`` instead of letting a
# caller probe arbitrary CLI / Slack / Lark history by handcrafted URL.
if not self._is_webui_session_key(decoded_key):
# Only ``websocket:…`` sessions are listed/served here — same boundary as
# ``/api/sessions``. Block handcrafted URLs from probing CLI / Slack / etc.
if not self._is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
data = self._session_manager.read_session_file(decoded_key)
if data is None:
return _http_error(404, "session not found")
messages = data.get("messages")
if isinstance(messages, list):
scrub_subagent_messages_for_channel(messages)
# Decorate persisted user messages with signed media URLs so the
# client can render previews. The raw on-disk ``media`` paths are
# stripped on the way out — they leak server filesystem layout and
@@ -945,6 +1004,74 @@ class WebSocketChannel(BaseChannel):
self._augment_media_urls(data)
return _http_json_response(data)
def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
if not self._is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
data = build_webui_thread_response(
decoded_key,
augment_user_media=self._augment_transcript_user_media,
)
if data is None:
return _http_error(404, "webui thread not found")
return _http_json_response(data)
def _try_append_webui_transcript(self, chat_id: str, wire: dict[str, Any]) -> None:
sk = f"websocket:{chat_id}"
try:
dup = json.loads(json.dumps(wire, ensure_ascii=False))
append_transcript_object(sk, dup)
except (ValueError, TypeError) as e:
self.logger.warning("webui transcript append failed: {}", e)
def _augment_transcript_user_media(self, paths: list[str]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for pstr in paths:
path = Path(pstr)
att = self._sign_or_stage_media_path(path)
if att is None:
continue
mime, _ = mimetypes.guess_type(path.name)
kind = "video" if mime and mime.startswith("video/") else "image"
out.append(
{"kind": kind, "url": att["url"], "name": att.get("name", path.name)},
)
return out
async def _handle_message(
self,
sender_id: str,
chat_id: str,
content: str,
media: list[str] | None = None,
metadata: dict[str, Any] | None = None,
session_key: str | None = None,
is_dm: bool = False,
) -> None:
meta = metadata or {}
if meta.get("webui"):
user_obj: dict[str, Any] = {
"event": "user",
"chat_id": chat_id,
"text": content,
}
if media:
user_obj["media_paths"] = list(media)
self._try_append_webui_transcript(chat_id, user_obj)
await super()._handle_message(
sender_id,
chat_id,
content,
media,
metadata,
session_key,
is_dm,
)
def _augment_media_urls(self, payload: dict[str, Any]) -> None:
"""Mutate *payload* in place: each message's ``media`` path list is
replaced by a parallel ``media_urls`` list of signed fetch URLs.
@@ -983,7 +1110,7 @@ class WebSocketChannel(BaseChannel):
The URL is self-authenticating: the signature binds the payload to
this process's ``_media_secret``, so only paths we chose to sign can
be fetched. The returned path is relative to the server origin; the
client joins it against the existing webui base.
client joins it against this server's HTTP origin (same host as WS).
"""
try:
media_root = get_media_dir().resolve()
@@ -1079,12 +1206,12 @@ class WebSocketChannel(BaseChannel):
decoded_key = _decode_api_key(key)
if decoded_key is None:
return _http_error(400, "invalid session key")
# Same boundary as ``_handle_session_messages``: the webui may only
# mutate websocket sessions, and deletion really does unlink the local
# JSONL, so keep the blast radius narrow and explicit.
if not self._is_webui_session_key(decoded_key):
# Same boundary as ``_handle_session_messages``: mutations apply only to
# websocket-channel sessions; deletion unlinks local JSONL — keep scope narrow.
if not self._is_websocket_channel_session_key(decoded_key):
return _http_error(404, "session not found")
deleted = self._session_manager.delete_session(decoded_key)
delete_webui_thread(decoded_key)
return _http_json_response({"deleted": bool(deleted)})
def _serve_static(self, request_path: str) -> Response | None:
@@ -1232,6 +1359,7 @@ class WebSocketChannel(BaseChannel):
# Register only after ready is successfully sent to avoid out-of-order sends
self._conn_default[connection] = default_chat_id
self._attach(connection, default_chat_id)
await self._hydrate_after_subscribe(default_chat_id)
async for raw in connection:
if isinstance(raw, bytes):
@@ -1344,6 +1472,7 @@ class WebSocketChannel(BaseChannel):
new_id = str(uuid.uuid4())
self._attach(connection, new_id)
await self._send_event(connection, "attached", chat_id=new_id)
await self._hydrate_after_subscribe(new_id)
return
if t == "attach":
cid = envelope.get("chat_id")
@@ -1352,6 +1481,7 @@ class WebSocketChannel(BaseChannel):
return
self._attach(connection, cid)
await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid)
return
if t == "message":
cid = envelope.get("chat_id")
@@ -1387,6 +1517,7 @@ class WebSocketChannel(BaseChannel):
# Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid)
await self._hydrate_after_subscribe(cid)
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
if envelope.get("webui") is True:
metadata["webui"] = True
@@ -1452,14 +1583,34 @@ class WebSocketChannel(BaseChannel):
msg.metadata.get("_progress")
or msg.metadata.get("_turn_end")
or msg.metadata.get("_session_updated")
or msg.metadata.get("_goal_status")
or msg.metadata.get("_goal_state_sync")
):
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
else:
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
return
if msg.metadata.get("_goal_state_sync"):
blob = msg.metadata.get("goal_state")
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
return
if msg.metadata.get("_goal_status"):
status = msg.metadata.get("goal_status")
if status in ("running", "idle"):
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
await self.send_goal_status(
msg.chat_id,
status,
started_at=float(started_raw) if isinstance(started_raw, int | float) else None,
)
return
# Signal that the agent has fully finished processing the current turn.
if msg.metadata.get("_turn_end"):
await self.send_turn_end(msg.chat_id)
lat = msg.metadata.get("latency_ms")
lat_i = int(lat) if isinstance(lat, (int, float)) else None
gs = msg.metadata.get("goal_state")
gs_blob = gs if isinstance(gs, dict) else None
await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob)
return
if msg.metadata.get("_session_updated"):
await self.send_session_updated(msg.chat_id)
@@ -1481,8 +1632,14 @@ class WebSocketChannel(BaseChannel):
payload["media_urls"] = urls
if msg.reply_to:
payload["reply_to"] = msg.reply_to
lat = msg.metadata.get("latency_ms")
if isinstance(lat, (int, float)):
payload["latency_ms"] = int(lat)
if msg.metadata.get("_tool_events"):
payload["tool_events"] = msg.metadata["_tool_events"]
agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI)
if agent_ui is not None:
payload["agent_ui"] = agent_ui
# Mark intermediate agent breadcrumbs (tool-call hints, generic
# progress strings) so WS clients can render them as subordinate
# trace rows rather than conversational replies.
@@ -1490,6 +1647,7 @@ class WebSocketChannel(BaseChannel):
payload["kind"] = "tool_hint"
elif msg.metadata.get("_progress"):
payload["kind"] = "progress"
self._try_append_webui_transcript(msg.chat_id, payload)
raw = json.dumps(payload, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" ")
@@ -1501,7 +1659,7 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] | None = None,
) -> None:
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
WebUI receives a stream that opens, updates in place, and closes
clients receive a stream that opens, updates in place, and closes
rendered above the active assistant bubble with a shimmer header
until the matching ``reasoning_end`` arrives.
"""
@@ -1517,6 +1675,7 @@ class WebSocketChannel(BaseChannel):
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._try_append_webui_transcript(chat_id, body)
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" reasoning ")
@@ -1538,6 +1697,7 @@ class WebSocketChannel(BaseChannel):
stream_id = meta.get("_stream_id")
if stream_id is not None:
body["stream_id"] = stream_id
self._try_append_webui_transcript(chat_id, body)
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" reasoning_end ")
@@ -1562,20 +1722,64 @@ class WebSocketChannel(BaseChannel):
}
if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"]
self._try_append_webui_transcript(chat_id, body)
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ")
async def send_turn_end(self, chat_id: str) -> None:
async def send_turn_end(
self,
chat_id: str,
latency_ms: int | None = None,
*,
goal_state: dict[str, Any] | None = None,
) -> None:
"""Signal that the agent has fully finished processing the current turn."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
if latency_ms is not None:
body["latency_ms"] = int(latency_ms)
if goal_state is not None:
body["goal_state"] = goal_state
self._try_append_webui_transcript(chat_id, body)
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_end ")
async def send_goal_state(self, chat_id: str, blob: dict[str, Any]) -> None:
"""Push persisted goal-state snapshot for *chat_id* (multi-chat isolation)."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body = {"event": "goal_state", "chat_id": chat_id, "goal_state": blob}
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" goal_state ")
async def send_goal_status(
self,
chat_id: str,
status: str,
*,
started_at: float | None = None,
) -> None:
"""Notify subscribed clients that a turn started or finished (wall-clock hint)."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {
"event": "goal_status",
"chat_id": chat_id,
"status": status,
}
if status == "running" and started_at is not None:
body["started_at"] = started_at
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" goal_status ")
async def send_session_updated(self, chat_id: str) -> None:
"""Notify clients that session metadata changed outside the main turn."""
conns = list(self._subs.get(chat_id, ()))
@@ -1592,7 +1796,7 @@ class WebSocketChannel(BaseChannel):
model_name: Any,
model_preset: Any = None,
) -> None:
"""Broadcast runtime model changes to all active WebUI clients."""
"""Broadcast runtime model changes to every open websocket connection."""
conns = list(self._conn_chats)
if not conns or not isinstance(model_name, str) or not model_name.strip():
return
+13 -1
View File
@@ -829,9 +829,21 @@ def _run_gateway(
cron.on_job = on_cron_job
def _webui_runtime_model_name() -> str | None:
model = getattr(agent, "model", None)
if isinstance(model, str):
stripped = model.strip()
return stripped or None
return None
# Create channel manager (forwards SessionManager so the WebSocket channel
# can serve the embedded webui's REST surface).
channels = ChannelManager(config, bus, session_manager=session_manager)
channels = ChannelManager(
config,
bus,
session_manager=session_manager,
webui_runtime_model_name=_webui_runtime_model_name,
)
def _pick_heartbeat_target() -> tuple[str, str]:
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
+1 -1
View File
@@ -22,7 +22,7 @@ def get_model_context_limit(model: str, provider: str = "auto") -> int | None:
return None
def get_model_suggestions(partial: str, provider: str = "auto", limit: int = 20) -> list[str]:
def get_model_suggestions(_partial: str, provider: str = "auto", limit: int = 20) -> list[str]:
return []
+1 -1
View File
@@ -486,7 +486,7 @@ def _input_model_with_autocomplete(
def __init__(self, provider_name: str):
self.provider = provider_name
def get_completions(self, document, complete_event):
def get_completions(self, document, _complete_event):
text = document.text_before_cursor
suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
for model in suggestions:
+50
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import os
import sys
import time
from contextlib import suppress
from dataclasses import dataclass
@@ -72,6 +73,13 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"history",
"[n]",
),
BuiltinCommandSpec(
"/goal",
"Start long-running goal",
"Tell the agent to treat the request as a long-running goal.",
"activity",
"<goal>",
),
BuiltinCommandSpec(
"/dream",
"Run Dream",
@@ -546,6 +554,46 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
)
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
Goal:
{goal}
"""
async def cmd_goal(ctx: CommandContext) -> OutboundMessage | None:
"""Rewrite /goal into a normal agent turn that nudges long_task use."""
goal = ctx.args.strip()
if not goal:
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content="Usage: /goal <long-running task description>",
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
if ctx.session is None:
return OutboundMessage(
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content=(
"A task is already running for this chat. "
"Use `/stop` first, then send `/goal <long-running task description>` again."
),
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
ctx.msg.metadata = {
**dict(ctx.msg.metadata or {}),
"original_command": "/goal",
"original_content": ctx.raw,
"goal_started_at": time.time(),
}
ctx.msg.content = _GOAL_PROMPT_TEMPLATE.format(goal=goal)
return None
async def cmd_pairing(ctx: CommandContext) -> OutboundMessage:
"""List, approve, deny or revoke pairing requests."""
from nanobot.pairing import PAIRING_COMMAND_META_KEY, handle_pairing_command
@@ -591,6 +639,8 @@ def register_builtin_commands(router: CommandRouter) -> None:
router.prefix("/model ", cmd_model)
router.exact("/history", cmd_history)
router.prefix("/history ", cmd_history)
router.exact("/goal", cmd_goal)
router.prefix("/goal ", cmd_goal)
router.exact("/dream", cmd_dream)
router.exact("/dream-log", cmd_dream_log)
router.prefix("/dream-log ", cmd_dream_log)
+2 -12
View File
@@ -32,14 +32,12 @@ class CommandRouter:
(e.g. /stop, /restart).
2. *exact* exact-match commands handled inside the dispatch lock.
3. *prefix* longest-prefix-first match (e.g. "/team ").
4. *interceptors* fallback predicates (e.g. team-mode active check).
"""
def __init__(self) -> None:
self._priority: dict[str, Handler] = {}
self._exact: dict[str, Handler] = {}
self._prefix: list[tuple[str, Handler]] = []
self._interceptors: list[Handler] = []
def priority(self, cmd: str, handler: Handler) -> None:
self._priority[cmd] = handler
@@ -51,16 +49,13 @@ class CommandRouter:
self._prefix.append((pfx, handler))
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
def intercept(self, handler: Handler) -> None:
self._interceptors.append(handler)
def is_priority(self, text: str) -> bool:
return text.strip().lower() in self._priority
def is_dispatchable_command(self, text: str) -> bool:
"""Check whether *text* matches any non-priority command tier (exact or prefix).
Does NOT check priority or interceptor tiers.
Does NOT check priority tier.
If this returns True, ``dispatch()`` is guaranteed to match a handler.
"""
cmd = text.strip().lower()
@@ -79,7 +74,7 @@ class CommandRouter:
return None
async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None:
"""Try exact, prefix, then interceptors. Returns None if unhandled."""
"""Try exact, then prefix handlers. Returns None if unhandled."""
cmd = ctx.raw.lower()
if handler := self._exact.get(cmd):
@@ -90,9 +85,4 @@ class CommandRouter:
ctx.args = ctx.raw[len(pfx):]
return await handler(ctx)
for interceptor in self._interceptors:
result = await interceptor(ctx)
if result is not None:
return result
return None
+2
View File
@@ -11,6 +11,7 @@ from nanobot.config.paths import (
get_logs_dir,
get_media_dir,
get_runtime_subdir,
get_webui_dir,
get_workspace_path,
)
from nanobot.config.schema import Config
@@ -24,6 +25,7 @@ __all__ = [
"get_media_dir",
"get_cron_dir",
"get_logs_dir",
"get_webui_dir",
"get_workspace_path",
"is_default_workspace",
"get_cli_history_path",
+5
View File
@@ -43,6 +43,11 @@ def get_logs_dir() -> Path:
return get_runtime_subdir("logs")
def get_webui_dir() -> Path:
"""Return the directory for WebUI-only persisted display threads (JSON)."""
return get_runtime_subdir("webui")
def get_workspace_path(workspace: str | None = None) -> Path:
"""Resolve and ensure the agent workspace path."""
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
+4 -4
View File
@@ -93,8 +93,8 @@ class ModelPresetConfig(Base):
model: str
provider: str = "auto"
max_tokens: int = 8192
context_window_tokens: int = 65_536
max_tokens: int = 32_000
context_window_tokens: int = 262_144
temperature: float = 0.1
reasoning_effort: str | None = None
@@ -116,8 +116,8 @@ class AgentDefaults(Base):
provider: str = (
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
)
max_tokens: int = 8192
context_window_tokens: int = 65_536
max_tokens: int = 32_000
context_window_tokens: int = 262_144
context_block_limit: int | None = None
temperature: float = 0.1
fallback_models: list[FallbackCandidate] = Field(default_factory=list)
+21 -4
View File
@@ -589,6 +589,7 @@ class AnthropicProvider(LLMProvider):
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
@@ -597,16 +598,32 @@ class AnthropicProvider(LLMProvider):
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try:
async with self._client.messages.stream(**kwargs) as stream:
if on_content_delta:
stream_iter = stream.text_stream.__aiter__()
if on_content_delta or on_thinking_delta:
# Idle timeout must track *any* SSE chunk (thinking_delta,
# tool JSON deltas, etc.), not only text_stream tokens.
# Otherwise extended thinking can stall text_stream for minutes
# while the connection is healthy (e.g. MiniMax Anthropic).
while True:
try:
text = await asyncio.wait_for(
stream_iter.__anext__(),
chunk = await asyncio.wait_for(
stream.__anext__(),
timeout=idle_timeout_s,
)
except StopAsyncIteration:
break
if (
chunk.type == "content_block_delta"
and getattr(chunk.delta, "type", None) == "thinking_delta"
):
piece = getattr(chunk.delta, "thinking", None) or ""
if piece and on_thinking_delta:
await on_thinking_delta(piece)
elif (
chunk.type == "content_block_delta"
and getattr(chunk.delta, "type", None) == "text_delta"
):
text = getattr(chunk.delta, "text", None) or ""
if text and on_content_delta:
await on_content_delta(text)
response = await asyncio.wait_for(
stream.get_final_message(),
@@ -157,7 +157,9 @@ class AzureOpenAIProvider(LLMProvider):
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
body = self._build_body(
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
+10 -1
View File
@@ -4,8 +4,8 @@ import asyncio
import json
import re
from abc import ABC, abstractmethod
from contextlib import suppress
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
@@ -499,14 +499,21 @@ class LLMProvider(ABC):
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
"""Stream a chat completion, calling *on_content_delta* for each text chunk.
*on_thinking_delta* is reserved for providers that expose incremental
thinking/reasoning on the wire; the default fallback invokes neither
callback for native deltas (only the optional single *on_content_delta*
after :meth:`chat`).
Returns the same ``LLMResponse`` as :meth:`chat`. The default
implementation falls back to a non-streaming call and delivers the
full content as a single delta. Providers that support native
streaming should override this method.
"""
_ = on_thinking_delta
response = await self.chat(
messages=messages, tools=tools, model=model,
max_tokens=max_tokens, temperature=temperature,
@@ -535,6 +542,7 @@ class LLMProvider(ABC):
reasoning_effort: object = _SENTINEL,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
retry_mode: str = "standard",
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
@@ -551,6 +559,7 @@ class LLMProvider(ABC):
max_tokens=max_tokens, temperature=temperature,
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
)
return await self._run_with_retry(
self._safe_chat_stream,
+2
View File
@@ -703,7 +703,9 @@ class BedrockProvider(LLMProvider):
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
content_parts: list[str] = []
reasoning_parts: list[str] = []
+3 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import time
import webbrowser
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from contextlib import suppress
import httpx
@@ -242,6 +242,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
reasoning_effort: str | None = None,
tool_choice: str | dict[str, object] | None = None,
on_content_delta: Callable[[str], None] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
):
await self._refresh_client_api_key()
return await super().chat_stream(
@@ -253,4 +254,5 @@ class GitHubCopilotProvider(OpenAICompatProvider):
reasoning_effort=reasoning_effort,
tool_choice=tool_choice,
on_content_delta=on_content_delta,
on_thinking_delta=on_thinking_delta,
)
@@ -99,7 +99,9 @@ class OpenAICodexProvider(LLMProvider):
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta)
def get_default_model(self) -> str:
+12 -2
View File
@@ -1160,6 +1160,7 @@ class OpenAICompatProvider(LLMProvider):
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try:
@@ -1223,10 +1224,19 @@ class OpenAICompatProvider(LLMProvider):
except StopAsyncIteration:
break
chunks.append(chunk)
if on_content_delta and chunk.choices:
text = getattr(chunk.choices[0].delta, "content", None)
if chunk.choices:
delta_obj = chunk.choices[0].delta
if on_content_delta:
text = getattr(delta_obj, "content", None)
if text:
await on_content_delta(text)
if on_thinking_delta:
reasoning = getattr(delta_obj, "reasoning_content", None) or getattr(
delta_obj, "reasoning", None,
)
r_text = self._extract_text_content(reasoning)
if r_text:
await on_thinking_delta(r_text)
return self._parse_chunks(chunks)
except asyncio.TimeoutError:
return LLMResponse(
+85
View File
@@ -0,0 +1,85 @@
"""Session metadata helpers for sustained goals (e.g. ``long_task`` / ``complete_goal``).
Tools set ``metadata[GOAL_STATE_KEY]``. Reads accept the legacy session key ``thread_goal``
for older sessions. The agent uses ``goal_state_runtime_lines`` and
``goal_state_ws_blob`` without importing tool implementations.
"""
from __future__ import annotations
import json
from typing import Any, Mapping, MutableMapping
GOAL_STATE_KEY = "goal_state"
# Older builds stored the same JSON blob under this key.
_LEGACY_GOAL_STATE_SESSION_KEY = "thread_goal"
_MAX_OBJECTIVE_IN_RUNTIME = 4000
_MAX_OBJECTIVE_WS = 600
def _session_goal_raw(metadata: Mapping[str, Any] | None) -> Any:
if not metadata:
return None
if GOAL_STATE_KEY in metadata:
return metadata.get(GOAL_STATE_KEY)
return metadata.get(_LEGACY_GOAL_STATE_SESSION_KEY)
def discard_legacy_goal_state_key(metadata: MutableMapping[str, Any]) -> None:
"""Remove legacy metadata key after migrating writes to :data:`GOAL_STATE_KEY`."""
metadata.pop(_LEGACY_GOAL_STATE_SESSION_KEY, None)
def goal_state_raw(metadata: Mapping[str, Any] | None) -> Any:
"""Return the session goal blob under :data:`GOAL_STATE_KEY` or the legacy key."""
return _session_goal_raw(metadata)
def parse_goal_state(blob: Any) -> dict[str, Any] | None:
if blob is None:
return None
if isinstance(blob, dict):
return blob
if isinstance(blob, str):
try:
parsed = json.loads(blob)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
return None
def goal_state_runtime_lines(metadata: Mapping[str, Any] | None) -> list[str]:
"""Lines appended inside the Runtime Context block when a goal is active."""
if not metadata:
return []
goal = parse_goal_state(_session_goal_raw(metadata))
if not isinstance(goal, dict) or goal.get("status") != "active":
return []
objective = str(goal.get("objective") or "").strip()
if not objective:
return ["Goal: active (no objective text stored)."]
if len(objective) > _MAX_OBJECTIVE_IN_RUNTIME:
objective = objective[:_MAX_OBJECTIVE_IN_RUNTIME].rstrip() + "\n… (truncated)"
out = ["Goal (active):", objective]
hint = str(goal.get("ui_summary") or "").strip()
if hint:
out.append(f"Summary: {hint}")
return out
def goal_state_ws_blob(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""JSON-safe snapshot for WebSocket ``goal_state`` events (one chat_id per frame)."""
goal = parse_goal_state(_session_goal_raw(metadata)) if metadata else None
if isinstance(goal, dict) and goal.get("status") == "active":
objective = str(goal.get("objective") or "").strip()
if len(objective) > _MAX_OBJECTIVE_WS:
objective = objective[:_MAX_OBJECTIVE_WS].rstrip() + ""
summary = str(goal.get("ui_summary") or "").strip()[:120]
blob: dict[str, Any] = {"active": True}
if summary:
blob["ui_summary"] = summary
if objective:
blob["objective"] = objective
return blob
return {"active": False}
+11 -2
View File
@@ -20,6 +20,7 @@ from nanobot.utils.helpers import (
image_placeholder_text,
safe_filename,
)
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
FILE_MAX_MESSAGES = 2000
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
@@ -65,6 +66,14 @@ def _text_preview(content: Any) -> str:
return text
def _message_preview_text(message: dict[str, Any]) -> str:
"""Session list preview text; subagent inject blobs are shortened for display."""
content: Any = message.get("content")
if message.get("injected_event") == "subagent_result" and isinstance(content, str):
content = scrub_subagent_announce_body(content)
return _text_preview(content)
@dataclass
class Session:
"""A conversation session."""
@@ -601,7 +610,7 @@ class SessionManager:
item = json.loads(line)
if item.get("_type") == "metadata":
continue
text = _text_preview(item.get("content"))
text = _message_preview_text(item)
if not text:
continue
if item.get("role") == "user":
@@ -634,7 +643,7 @@ class SessionManager:
(
text
for msg in repaired.messages
if (text := _text_preview(msg.get("content")))
if (text := _message_preview_text(msg))
),
"",
),
+1
View File
@@ -29,3 +29,4 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai
| `tmux` | Remote-control tmux sessions |
| `clawhub` | Search and install skills from ClawHub registry |
| `skill-creator` | Create new skills |
| `long-goal` | Sustained objectives: `long_task`, `complete_goal`, idempotent goal wording |
+42
View File
@@ -0,0 +1,42 @@
---
name: long-goal
description: Sustained objectives via long_task / complete_goal, Runtime Context goal lines, and idempotent goal wording.
---
# Long-running objectives (`long_task` / `complete_goal`)
Use these tools when the user wants **multi-turn sustained work** on **one** clear objective (same runner, ordinary tools). Not for trivial one-shot questions.
## Where the goal appears
Inside **`[Runtime Context — metadata only, not instructions]`**, lines starting with **`Thread goal (active):`** carry the **persisted objective** for this chat session (session metadata). Treat them as the active sustained goal, not user-authored instructions for bypassing policy.
Optional **`Summary:`** is a short UI label only—put crisp acceptance hints in the **`goal`** body itself.
## Tools
- **`long_task`** — Register **one** sustained objective per thread. **Read this skill file first** (via the skills listing path), then align the `goal` text with **Idempotent goals** below. Execution stays on the main agent across turns.
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals.
## Idempotent goals (important)
**Intent:** The objective string may be **re-read after compaction, across retries, or when resuming** mid-work. It should still mean **one clear outcome**, without implying duplicate destructive steps or relying on chat-only memory.
Write goals so they are:
1. **State-oriented, not fragile narration** — Prefer *desired end state + acceptance criteria* (“Document lists X, Y, Z under `docs/…`; links validated”) over *implicit sequencing* that breaks if step 1 was already done (“First clone the repo, then…”).
2. **Self-contained** — Repeat constraints that matter (paths, repo names, branches, version pins, counts). Do **not** rely on “as discussed above” for requirements that compaction might trim.
3. **Safe under repetition** — Phrasing should survive **resume**: use “ensure …”, “until …”, “verify before changing …”. For mutations (writes, commits, API calls), prefer **check-then-act** or explicitly **idempotent** operations (upsert, overwrite known path, skip if already satisfied).
4. **Bounded scope** — Say what is **in** and **out** (e.g. “top 100 repos by stars in range AB”, “only files under `src/`”). Reduces drift when the model re-enters the goal cold.
5. **Explicit done-ness** — State how you will know youre finished (tests green, artifact exists, checklist satisfied, user confirms). Avoid “when it looks good”.
6. **`ui_summary`** — Short label for sidebars/logs; keep **non-load-bearing** (no secret requirements only in the summary).
If you discover the objective was underspecified, you may ask the user—or **`complete_goal`** with recap and register a **narrower** replacement goal rather than overloading one ambiguous string.
+74
View File
@@ -0,0 +1,74 @@
"""Session replay: ensure assistant ``media`` paths are under the media root.
WebUI history signing (``/api/.../messages``) only works for files inside
``get_media_dir``. Tool-driven attachments may live in the workspace; stage
copies into the websocket media bucket before persisting message JSON.
"""
from __future__ import annotations
import shutil
import uuid
from pathlib import Path
from typing import Any
from loguru import logger
from nanobot.config.paths import get_media_dir
from nanobot.utils.helpers import safe_filename
def stage_media_paths_for_session_replay(paths: list[str]) -> list[str]:
"""Keep local files only; copy anything outside the media root into ``media/websocket``."""
root = get_media_dir().resolve()
out: list[str] = []
seen: set[str] = set()
for raw in paths:
if not isinstance(raw, str) or not raw.strip():
continue
if raw.startswith(("http://", "https://")):
continue
try:
p = Path(raw).expanduser().resolve()
except OSError:
continue
if not p.is_file():
continue
try:
p.relative_to(root)
key = str(p)
except ValueError:
try:
media_dir = get_media_dir("websocket")
staged = media_dir / f"{uuid.uuid4().hex[:12]}-{safe_filename(p.name) or 'attachment'}"
shutil.copyfile(p, staged)
key = str(staged.resolve())
except OSError as exc:
logger.warning("failed to stage session media from {}: {}", raw, exc)
continue
if key not in seen:
out.append(key)
seen.add(key)
return out
def merge_turn_media_into_last_assistant(
all_messages: list[dict[str, Any]],
generated_image_paths: list[str],
extra_attachment_paths: list[str],
) -> None:
"""Attach staged paths to the last assistant row in *all_messages* (in-place)."""
merged = list(
dict.fromkeys(
[
*stage_media_paths_for_session_replay(generated_image_paths),
*stage_media_paths_for_session_replay(extra_attachment_paths),
]
)
)
last = all_messages[-1] if all_messages else None
if not merged or not last or last.get("role") != "assistant":
return
existing = last.get("media")
base = existing if isinstance(existing, list) else []
last["media"] = list(dict.fromkeys([*base, *merged]))
+59
View File
@@ -0,0 +1,59 @@
"""Strip internal subagent inject scaffolding for human-facing channel surfaces.
Persisted subagent announcements mirror ``agent/subagent_announce.md``: header,
full ``Task:`` assignment (model context), ``Result:``, and a trailing model-only
``Summarize`` instruction. External channels (embedded WebUI, session previews)
should show only the header plus a truncated result body."""
from __future__ import annotations
from typing import Any
# Cap Result section length so WebSocket session replay stays readable; full text
# remains on disk for LLM replay (we only mutate outgoing API copies in websocket).
_SUBAGENT_CHANNEL_RESULT_MAX_CHARS = 800
def scrub_subagent_announce_body(content: str) -> str:
"""Return channel-safe text derived from a full subagent announce blob."""
stripped = content.replace("\r\n", "\n").strip()
lines = stripped.splitlines()
header = ""
if lines and lines[0].startswith("[Subagent"):
header = lines[0].strip()
lower = stripped.lower()
key = "\nresult:\n"
ri = lower.find(key)
if ri == -1:
key = "\nresult:"
ri = lower.find(key)
if ri == -1:
return header if header else stripped
after = stripped[ri + len(key) :].lstrip()
summ_marker = "summarize this naturally"
si = after.lower().find(summ_marker)
if si != -1:
after = after[:si].rstrip()
body = after.strip()
limit = _SUBAGENT_CHANNEL_RESULT_MAX_CHARS
if limit and len(body) > limit:
body = body[: limit - 1].rstrip() + ""
if header and body:
return f"{header}\n\n{body}"
return header or body or stripped
def scrub_subagent_messages_for_channel(messages: list[dict[str, Any]]) -> None:
"""Mutate message dicts in place when they carry ``subagent_result`` inject."""
for msg in messages:
if not isinstance(msg, dict):
continue
if msg.get("injected_event") != "subagent_result":
continue
raw = msg.get("content")
if not isinstance(raw, str) or not raw.strip():
continue
msg["content"] = scrub_subagent_announce_body(raw)
+31
View File
@@ -0,0 +1,31 @@
"""Legacy WebUI JSON snapshot path helpers (JSON file); transcripts use webui_transcript."""
from __future__ import annotations
from pathlib import Path
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.session.manager import SessionManager
from nanobot.utils.webui_transcript import delete_webui_transcript
def webui_thread_file_path(session_key: str) -> Path:
stem = SessionManager.safe_key(session_key)
return get_webui_dir() / f"{stem}.json"
def delete_webui_thread(session_key: str) -> bool:
"""Remove legacy WebUI JSON snapshot and append-only transcript for *session_key*."""
removed = False
path = webui_thread_file_path(session_key)
if path.is_file():
try:
path.unlink()
removed = True
except OSError as e:
logger.warning("Failed to delete webui thread file {}: {}", path, e)
if delete_webui_transcript(session_key):
removed = True
return removed
+423
View File
@@ -0,0 +1,423 @@
"""Append-only WebUI display transcript (JSONL), separate from agent session."""
from __future__ import annotations
import json
import os
import time
import uuid
from pathlib import Path
from typing import Any, Callable
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.session.manager import SessionManager
WEBUI_TRANSCRIPT_SCHEMA_VERSION = 3
_MAX_TRANSCRIPT_FILE_BYTES = 8 * 1024 * 1024
def webui_transcript_path(session_key: str) -> Path:
stem = SessionManager.safe_key(session_key)
return get_webui_dir() / f"{stem}.jsonl"
def read_transcript_lines(session_key: str) -> list[dict[str, Any]]:
path = webui_transcript_path(session_key)
if not path.is_file():
return []
size = path.stat().st_size
if size > _MAX_TRANSCRIPT_FILE_BYTES:
logger.warning("webui transcript too large, skipping: {}", path)
return []
lines_out: list[dict[str, Any]] = []
try:
with open(path, encoding="utf-8") as f:
for line_no, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
logger.warning("bad jsonl at {} line {}", path, line_no)
continue
if isinstance(obj, dict):
lines_out.append(obj)
except OSError as e:
logger.warning("read transcript failed {}: {}", path, e)
return []
return lines_out
def append_transcript_object(session_key: str, obj: dict[str, Any]) -> None:
raw = json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
if len(raw.encode("utf-8")) > _MAX_TRANSCRIPT_FILE_BYTES:
msg = "webui transcript line too large"
raise ValueError(msg)
path = webui_transcript_path(session_key)
path.parent.mkdir(parents=True, exist_ok=True)
line = raw + "\n"
with open(path, "a", encoding="utf-8") as f:
f.write(line)
f.flush()
os.fsync(f.fileno())
def delete_webui_transcript(session_key: str) -> bool:
path = webui_transcript_path(session_key)
if not path.is_file():
return False
try:
path.unlink()
return True
except OSError as e:
logger.warning("Failed to delete webui transcript {}: {}", path, e)
return False
def _format_tool_call_trace(call: Any) -> str | None:
if not call or not isinstance(call, dict):
return None
fn = call.get("function")
name = fn.get("name") if isinstance(fn, dict) else None
if not isinstance(name, str) or not name:
raw_name = call.get("name")
name = raw_name if isinstance(raw_name, str) else ""
if not name:
return None
args = (fn.get("arguments") if isinstance(fn, dict) else None) or call.get("arguments")
if isinstance(args, str) and args.strip():
return f"{name}({args})"
if args and isinstance(args, dict):
return f"{name}({json.dumps(args, ensure_ascii=False)})"
return f"{name}()"
def tool_trace_lines_from_events(events: Any) -> list[str]:
if not isinstance(events, list):
return []
lines: list[str] = []
for event in events:
if not event or not isinstance(event, dict):
continue
if event.get("phase") != "start":
continue
t = _format_tool_call_trace(event)
if t:
lines.append(t)
return lines
def replay_transcript_to_ui_messages(
lines: list[dict[str, Any]],
*,
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
) -> list[dict[str, Any]]:
"""Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI.
Mirrors the core fold in ``useNanobotStream.ts`` (delta, reasoning,
message+kind, turn_end). ``augment_user_media`` maps persisted filesystem
paths to ``{url, name?}`` / attachment dicts the client expects.
"""
messages: list[dict[str, Any]] = []
buffer_message_id: str | None = None
buffer_parts: list[str] = []
suppress_until_turn_end = False
_ts_base = int(time.time() * 1000)
def _new_id(prefix: str, idx: int) -> str:
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
for i in range(len(prev) - 1, -1, -1):
candidate = prev[i]
if candidate.get("role") == "user":
break
if candidate.get("kind") == "trace":
break
if candidate.get("role") != "assistant":
continue
content = str(candidate.get("content") or "")
has_answer = len(content) > 0
if (
candidate.get("reasoningStreaming")
or candidate.get("reasoning") is not None
or has_answer
or candidate.get("isStreaming")
):
prev[i] = {
**candidate,
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
"reasoningStreaming": True,
}
return
if not has_answer and candidate.get("isStreaming"):
prev[i] = {**candidate, "reasoning": chunk, "reasoningStreaming": True}
return
break
prev.append(
{
"id": _new_id("as", idx),
"role": "assistant",
"content": "",
"isStreaming": True,
"reasoning": chunk,
"reasoningStreaming": True,
"createdAt": _ts_base + idx,
},
)
def find_active_placeholder(prev: list[dict[str, Any]]) -> str | None:
last = prev[-1] if prev else None
if not last:
return None
if last.get("role") != "assistant" or last.get("kind") == "trace":
return None
if str(last.get("content") or ""):
return None
if not last.get("isStreaming"):
return None
return str(last.get("id"))
def close_reasoning(prev: list[dict[str, Any]]) -> None:
for i in range(len(prev) - 1, -1, -1):
if prev[i].get("reasoningStreaming"):
prev[i] = {**prev[i], "reasoningStreaming": False}
return
def is_reasoning_only_placeholder(m: dict[str, Any]) -> bool:
return (
m.get("role") == "assistant"
and m.get("kind") != "trace"
and not str(m.get("content") or "").strip()
and bool(m.get("reasoning"))
and not m.get("reasoningStreaming")
and not m.get("media")
)
def is_tool_trace_at(index: int) -> bool:
m = messages[index] if 0 <= index < len(messages) else None
return bool(m and m.get("kind") == "trace")
def prune_reasoning_only() -> None:
nonlocal messages
kept: list[dict[str, Any]] = []
for i, m in enumerate(messages):
if is_reasoning_only_placeholder(m) and not is_tool_trace_at(i + 1):
continue
kept.append(m)
messages = kept
def stamp_latency(latency_ms: int) -> None:
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "assistant" and messages[i].get("kind") != "trace":
messages[i] = {
**messages[i],
"latencyMs": latency_ms,
"isStreaming": False,
}
return
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
last = messages[-1] if messages else None
if last and is_reasoning_only_placeholder(last):
messages[-1] = {
**last,
**extra,
"isStreaming": False,
"reasoningStreaming": False,
}
else:
messages.append(
{
"id": _new_id("as", idx),
"role": "assistant",
"createdAt": _ts_base + idx,
**extra,
},
)
for idx, rec in enumerate(lines):
ev = rec.get("event")
if ev == "user":
text = rec.get("text")
text_s = text if isinstance(text, str) else ""
media_paths = rec.get("media_paths")
paths: list[str] = []
if isinstance(media_paths, list):
paths = [str(p) for p in media_paths if p]
media_att: list[dict[str, Any]] | None = None
if paths and augment_user_media is not None:
media_att = augment_user_media(paths)
row: dict[str, Any] = {
"id": _new_id("u", idx),
"role": "user",
"content": text_s,
"createdAt": _ts_base + idx,
}
if media_att:
row["media"] = media_att
if all(m.get("kind") == "image" for m in media_att):
row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att]
messages.append(row)
continue
if ev == "delta":
if suppress_until_turn_end:
continue
chunk = rec.get("text")
if not isinstance(chunk, str):
continue
adopted = find_active_placeholder(messages) if buffer_message_id is None else None
if buffer_message_id is None:
if adopted:
buffer_message_id = adopted
else:
buffer_message_id = _new_id("buf", idx)
messages.append(
{
"id": buffer_message_id,
"role": "assistant",
"content": "",
"isStreaming": True,
"createdAt": _ts_base + idx,
},
)
buffer_parts.append(chunk)
combined = "".join(buffer_parts)
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {**m, "content": combined, "isStreaming": True}
break
continue
if ev == "stream_end":
if suppress_until_turn_end:
buffer_message_id = None
buffer_parts = []
continue
buffer_message_id = None
buffer_parts = []
continue
if ev == "reasoning_delta":
if suppress_until_turn_end:
continue
chunk = rec.get("text")
if not isinstance(chunk, str) or not chunk:
continue
attach_reasoning_chunk(messages, chunk, idx)
continue
if ev == "reasoning_end":
if suppress_until_turn_end:
continue
close_reasoning(messages)
continue
if ev == "message":
if suppress_until_turn_end and rec.get("kind") in (
"tool_hint",
"progress",
"reasoning",
):
continue
kind = rec.get("kind")
if kind == "reasoning":
line = rec.get("text")
if not isinstance(line, str) or not line:
continue
attach_reasoning_chunk(messages, line, idx)
close_reasoning(messages)
continue
if kind in ("tool_hint", "progress"):
structured = tool_trace_lines_from_events(rec.get("tool_events"))
text = rec.get("text")
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
if not trace_lines:
continue
last = messages[-1] if messages else None
if last and last.get("kind") == "trace" and not last.get("isStreaming"):
prev_traces = list(last.get("traces") or [last.get("content")])
merged_traces = prev_traces + trace_lines
messages[-1] = {
**last,
"traces": merged_traces,
"content": trace_lines[-1],
}
else:
messages.append(
{
"id": _new_id("tr", idx),
"role": "tool",
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
"createdAt": _ts_base + idx,
},
)
continue
buffer_message_id = None
buffer_parts = []
text = rec.get("text")
content_s = text if isinstance(text, str) else ""
media_urls = rec.get("media_urls")
media: list[dict[str, Any]] = []
if isinstance(media_urls, list):
for m in media_urls:
if isinstance(m, dict) and m.get("url"):
media.append(
{
"kind": "image",
"url": str(m["url"]),
"name": str(m.get("name") or ""),
},
)
extra: dict[str, Any] = {"content": content_s}
if media:
extra["media"] = media
lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
extra["latencyMs"] = int(lat)
absorb_complete(extra, idx)
if media:
suppress_until_turn_end = True
continue
if ev == "turn_end":
suppress_until_turn_end = False
for i, m in enumerate(messages):
if m.get("isStreaming"):
messages[i] = {**m, "isStreaming": False}
prune_reasoning_only()
lat = rec.get("latency_ms")
if isinstance(lat, (int, float)) and lat >= 0:
stamp_latency(int(lat))
buffer_message_id = None
buffer_parts = []
continue
for m in messages:
m.pop("isStreaming", None)
m.pop("reasoningStreaming", None)
return messages
def build_webui_thread_response(
session_key: str,
*,
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
) -> dict[str, Any] | None:
"""Return a payload compatible with ``WebuiThreadPersistedPayload``."""
lines = read_transcript_lines(session_key)
if not lines:
return None
msgs = replay_transcript_to_ui_messages(lines, augment_user_media=augment_user_media)
return {
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
"sessionKey": session_key,
"messages": msgs,
}
+48
View File
@@ -0,0 +1,48 @@
"""Outbound helpers for the WebSocket/WebUI wire contract.
AgentLoop uses these without importing a concrete channel plugin; only
``channel == "websocket"`` messages are affected.
"""
from __future__ import annotations
import time
from typing import Any
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
"""Return ``time.time()`` when the active user turn began, if still running."""
return _WEBSOCKET_TURN_WALL_STARTED_AT.get(chat_id)
async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status: str) -> None:
"""Notify WebSocket clients while a user turn is executing (timing strip)."""
if msg.channel != "websocket":
return
cid = str(msg.chat_id)
meta: dict[str, Any] = {
**dict(msg.metadata or {}),
"_goal_status": True,
"goal_status": status,
}
if status == "running":
t0 = time.time()
meta["started_at"] = t0
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
else:
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
await bus.publish_outbound(
OutboundMessage(
channel=msg.channel,
chat_id=cid,
content="",
metadata=meta,
),
)
+17 -26
View File
@@ -1,13 +1,11 @@
"""Tests for ContextBuilder — system prompt and message assembly."""
import base64
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from nanobot.agent.context import ContextBuilder
from nanobot.session.goal_state import GOAL_STATE_KEY
# ---------------------------------------------------------------------------
# Helpers
@@ -285,6 +283,22 @@ class TestBuildMessages:
assert "[Runtime Context" in user_msg
assert "hello" in user_msg
def test_session_metadata_injects_active_goal_state(self, tmp_path):
builder = _builder(tmp_path)
meta = {
GOAL_STATE_KEY: {"status": "active", "objective": "Finish docs migration."},
}
messages = builder.build_messages(
[],
"hi",
channel="cli",
chat_id="x",
session_metadata=meta,
)
user_msg = str(messages[-1]["content"])
assert "Goal (active):" in user_msg
assert "Finish docs migration." in user_msg
def test_consecutive_same_role_merged(self, tmp_path):
builder = _builder(tmp_path)
history = [{"role": "user", "content": "previous user message"}]
@@ -308,26 +322,3 @@ class TestBuildMessages:
user_msg = messages[-1]["content"]
assert isinstance(user_msg, list)
assert any(b.get("type") == "image_url" for b in user_msg)
# ---------------------------------------------------------------------------
# add_tool_result
# ---------------------------------------------------------------------------
class TestAddToolResult:
def test_appends_tool_message(self, tmp_path):
builder = _builder(tmp_path)
msgs = [{"role": "user", "content": "hello"}]
result = builder.add_tool_result(msgs, "call_123", "read_file", "file content")
assert len(result) == 2
assert result[1]["role"] == "tool"
assert result[1]["tool_call_id"] == "call_123"
assert result[1]["name"] == "read_file"
assert result[1]["content"] == "file content"
def test_returns_same_list(self, tmp_path):
builder = _builder(tmp_path)
msgs = []
result = builder.add_tool_result(msgs, "id", "tool", "ok")
assert result is msgs
+31 -10
View File
@@ -204,13 +204,16 @@ class TestToolEventProgress:
if not m.metadata.get("_stream_delta")
and not m.metadata.get("_stream_end")
and not m.metadata.get("_turn_end")
and not m.metadata.get("_goal_status")
]
assert [m.content for m in deltas] == ["Hel", "lo"]
assert len(stream_end) == 1
assert final[-1].content == "Hello"
assert final[-1].metadata.get("_streamed") is True
assert outbound[-1].metadata.get("_turn_end") is True
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == ""
provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio
@@ -286,11 +289,15 @@ class TestToolEventProgress:
while bus.outbound_size > 0:
outbound.append(await bus.consume_outbound())
assert outbound[-2].content == "Done"
assert (outbound[-2].metadata or {}).get("_turn_end") is not True
assert outbound[-1].content == ""
assert (outbound[-1].metadata or {}).get("_turn_end") is True
assert outbound[-1].chat_id == "chat1"
done_msgs = [m for m in outbound if m.content == "Done"]
assert len(done_msgs) == 1
assert not done_msgs[0].metadata.get("_turn_end")
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
assert len(turn_end_msgs) == 1
assert turn_end_msgs[0].content == ""
assert turn_end_msgs[0].chat_id == "chat1"
assert outbound.index(done_msgs[0]) < outbound.index(turn_end_msgs[0])
@pytest.mark.asyncio
async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None:
@@ -323,13 +330,27 @@ class TestToolEventProgress:
metadata={"webui": True},
)), timeout=0.5)
outbound = [await bus.consume_outbound(), await bus.consume_outbound()]
assert outbound[0].content == "Done"
assert (outbound[1].metadata or {}).get("_turn_end") is True
outbound: list = []
for _ in range(12):
outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5))
if outbound[-1].metadata.get("_turn_end"):
break
else:
raise AssertionError("_turn_end message not found")
done_with_body = [m for m in outbound if m.content == "Done"]
assert len(done_with_body) == 1
assert outbound[-1].metadata.get("_turn_end") is True
await asyncio.wait_for(title_started.wait(), timeout=0.5)
release_title.set()
session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
session_updated = None
for _ in range(10):
candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
if (candidate.metadata or {}).get("_session_updated"):
session_updated = candidate
break
assert session_updated is not None
assert (session_updated.metadata or {}).get("_session_updated") is True
assert provider.chat_with_retry.await_count == 2
+19
View File
@@ -177,6 +177,25 @@ def test_save_turn_keeps_tool_results_under_16k() -> None:
assert session.messages[0]["content"] == content
def test_save_turn_stamps_latency_on_last_assistant() -> None:
loop = _mk_loop()
session = Session(key="test:latency")
loop._save_turn(
session,
[
{"role": "assistant", "content": "hello", "tool_calls": [{"id": "c1"}]},
{"role": "assistant", "content": "final answer"},
],
skip=0,
turn_latency_ms=12345,
)
assert session.messages[-1]["role"] == "assistant"
assert session.messages[-1]["content"] == "final answer"
assert session.messages[-1]["latency_ms"] == 12345
def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() -> None:
loop = _mk_loop()
session = Session(
+58 -8
View File
@@ -13,7 +13,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.hook import AgentHook
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -38,7 +38,7 @@ class _RecordingHook(AgentHook):
async def test_runner_preserves_reasoning_fields_in_assistant_history():
"""Reasoning fields ride along on the persisted assistant message so
follow-up provider calls retain the model's prior thinking context."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock()
captured_second_call: list[dict] = []
@@ -86,7 +86,7 @@ async def test_runner_preserves_reasoning_fields_in_assistant_history():
@pytest.mark.asyncio
async def test_runner_emits_anthropic_thinking_blocks():
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock()
@@ -126,7 +126,7 @@ async def test_runner_emits_anthropic_thinking_blocks():
async def test_runner_emits_inline_think_content_as_reasoning():
"""Models embedding reasoning in <think>...</think> blocks should have
that content extracted and emitted, and stripped from the answer."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock()
@@ -161,7 +161,7 @@ async def test_runner_emits_inline_think_content_as_reasoning():
async def test_runner_prefers_reasoning_content_over_inline_think():
"""Fallback priority: dedicated reasoning_content wins; inline <think>
is still scrubbed from the answer content."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock()
@@ -197,7 +197,7 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
"""`reasoning_content` arrives only on the final response; streaming the
answer must not suppress it (the answer stream and the reasoning channel
are independent only the reasoning-already-emitted bit matters)."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock()
provider.supports_progress_deltas = True
@@ -244,7 +244,7 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
"""Inline `<think>` blocks streamed incrementally during the answer
stream must not be re-emitted from the final response."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock()
provider.supports_progress_deltas = True
@@ -289,7 +289,7 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response():
"""A non-streaming response carrying ``reasoning_content`` must emit
both a reasoning delta and an end marker so channels can finalize the
in-place bubble."""
from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock()
@@ -319,3 +319,53 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response():
assert result.final_content == "answer"
assert hook.emitted == ["hidden thought"]
assert hook.end_calls == 1
class _StreamRecordingHook(_RecordingHook):
def wants_streaming(self) -> bool:
return True
async def on_stream(self, _ctx: AgentHookContext, delta: str) -> None:
pass
@pytest.mark.asyncio
async def test_runner_streams_native_thinking_deltas_without_post_hoc_dup():
"""Anthropic-style ``on_thinking_delta`` should fan out to ``emit_reasoning``;
final ``thinking_blocks`` must not emit again when already streamed."""
from nanobot.agent.runner import AgentRunner, AgentRunSpec
provider = MagicMock()
async def chat_stream_with_retry(
*, on_content_delta=None, on_thinking_delta=None, **kwargs
):
if on_thinking_delta:
await on_thinking_delta("part1")
await on_thinking_delta("part2")
if on_content_delta:
await on_content_delta("done")
return LLMResponse(
content="done",
tool_calls=[],
thinking_blocks=[{"type": "thinking", "thinking": "part1part2"}],
usage={"prompt_tokens": 1, "completion_tokens": 2},
)
provider.chat_stream_with_retry = chat_stream_with_retry
tools = MagicMock()
tools.get_definitions.return_value = []
hook = _StreamRecordingHook()
runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "q"}],
tools=tools,
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook,
))
assert result.final_content == "done"
assert hook.emitted == ["part1", "part2"]
+34
View File
@@ -0,0 +1,34 @@
"""Tests for staging attachment paths into the media bucket for session replay."""
from pathlib import Path
from nanobot.config.loader import set_config_path
from nanobot.config.paths import get_media_dir
from nanobot.utils.session_attachments import stage_media_paths_for_session_replay
def test_persist_media_stages_workspace_file(tmp_path: Path) -> None:
set_config_path(tmp_path / "config.json")
outside = tmp_path / "workspace" / "report.md"
outside.parent.mkdir(parents=True)
outside.write_text("body", encoding="utf-8")
out = stage_media_paths_for_session_replay([str(outside)])
assert len(out) == 1
staged = Path(out[0])
assert staged.is_file()
assert staged.read_text(encoding="utf-8") == "body"
assert staged.resolve().is_relative_to(get_media_dir().resolve())
def test_persist_media_keeps_files_already_under_media_root(tmp_path: Path) -> None:
set_config_path(tmp_path / "config.json")
media = get_media_dir("websocket")
media.mkdir(parents=True, exist_ok=True)
inside = media / "keep-me.txt"
inside.write_text("x", encoding="utf-8")
out = stage_media_paths_for_session_replay([str(inside.resolve())])
assert out == [str(inside.resolve())]
+155
View File
@@ -0,0 +1,155 @@
"""Tests for sustained goal tools (`long_task`, `complete_goal`)."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.long_task import (
CompleteGoalTool,
LongTaskTool,
)
from nanobot.bus.queue import MessageBus
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import SessionManager
def _tools(sm: SessionManager) -> tuple[LongTaskTool, CompleteGoalTool]:
lt = LongTaskTool(sessions=sm)
cg = CompleteGoalTool(sessions=sm)
rc = RequestContext(
channel="websocket",
chat_id="c1",
session_key="websocket:c1",
metadata={},
)
lt.set_context(rc)
cg.set_context(rc)
return lt, cg
@pytest.mark.asyncio
async def test_long_task_records_goal_metadata(tmp_path):
sm = SessionManager(tmp_path)
lt, _cg = _tools(sm)
out = await lt.execute(goal="Do the thing", ui_summary="thing")
assert "Goal recorded" in out
sess = sm.get_or_create("websocket:c1")
blob = sess.metadata.get(GOAL_STATE_KEY)
assert isinstance(blob, dict)
assert blob["status"] == "active"
assert blob["objective"] == "Do the thing"
assert blob["ui_summary"] == "thing"
@pytest.mark.asyncio
async def test_long_task_rejects_second_active_goal(tmp_path):
sm = SessionManager(tmp_path)
lt, _cg = _tools(sm)
await lt.execute(goal="First")
out = await lt.execute(goal="Second")
assert "already active" in out
@pytest.mark.asyncio
async def test_complete_goal_closes_active_goal(tmp_path):
sm = SessionManager(tmp_path)
lt, cg = _tools(sm)
await lt.execute(goal="X")
out = await cg.execute(recap="Done.")
assert "marked complete" in out
sess = sm.get_or_create("websocket:c1")
blob = sess.metadata.get(GOAL_STATE_KEY)
assert blob["status"] == "completed"
assert blob["recap"] == "Done."
@pytest.mark.asyncio
async def test_long_task_publishes_goal_state_ws_after_save(tmp_path):
bus = MagicMock()
bus.publish_outbound = AsyncMock()
sm = SessionManager(tmp_path)
lt = LongTaskTool(sessions=sm, bus=bus)
rc = RequestContext(
channel="websocket",
chat_id="chat-99",
session_key="websocket:chat-99",
metadata={},
)
lt.set_context(rc)
await lt.execute(goal="Objective alpha", ui_summary="alpha")
bus.publish_outbound.assert_awaited_once()
call = bus.publish_outbound.await_args.args[0]
assert call.channel == "websocket"
assert call.chat_id == "chat-99"
assert call.metadata.get("_goal_state_sync") is True
assert call.metadata["goal_state"] == {
"active": True,
"ui_summary": "alpha",
"objective": "Objective alpha",
}
@pytest.mark.asyncio
async def test_complete_goal_publishes_inactive_goal_state_ws(tmp_path):
bus = MagicMock()
bus.publish_outbound = AsyncMock()
sm = SessionManager(tmp_path)
lt = LongTaskTool(sessions=sm, bus=bus)
cg = CompleteGoalTool(sessions=sm, bus=bus)
rc = RequestContext(
channel="websocket",
chat_id="chat-z",
session_key="websocket:chat-z",
metadata={},
)
lt.set_context(rc)
await lt.execute(goal="X")
bus.publish_outbound.reset_mock()
cg.set_context(rc)
await cg.execute(recap="Done.")
bus.publish_outbound.assert_awaited_once()
call = bus.publish_outbound.await_args.args[0]
assert call.metadata["goal_state"] == {"active": False}
@pytest.mark.asyncio
async def test_complete_goal_without_active_is_noop_message(tmp_path):
sm = SessionManager(tmp_path)
_lt, cg = _tools(sm)
out = await cg.execute(recap="n/a")
assert "No active" in out
@pytest.mark.asyncio
async def test_long_task_skips_ws_publish_without_bus(tmp_path):
sm = SessionManager(tmp_path)
lt, _cg = _tools(sm)
out = await lt.execute(goal="Solo", ui_summary="s")
assert "Goal recorded" in out
@pytest.mark.asyncio
async def test_long_task_and_complete_goal_registered(tmp_path):
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
lt = loop.tools.get("long_task")
cg = loop.tools.get("complete_goal")
assert lt is not None and lt.name == "long_task"
assert cg is not None and cg.name == "complete_goal"
+259 -1
View File
@@ -13,7 +13,7 @@ import websockets
from websockets.exceptions import ConnectionClosed
from websockets.frames import Close
from nanobot.bus.events import OutboundMessage
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.websocket import (
WebSocketChannel,
@@ -370,6 +370,30 @@ async def test_send_progress_includes_structured_tool_events() -> None:
]
@pytest.mark.asyncio
async def test_send_progress_includes_agent_ui_blob() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
blob = {
"kind": "panel",
"data": {"version": 1, "event": "tick", "id": "r1"},
}
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="progress · panel",
metadata={"_progress": True, OUTBOUND_META_AGENT_UI: blob},
))
payload = json.loads(mock_ws.send.await_args.args[0])
assert payload["event"] == "message"
assert payload["kind"] == "progress"
assert payload["agent_ui"] == blob
@pytest.mark.asyncio
async def test_send_delta_removes_connection_on_connection_closed() -> None:
bus = MagicMock()
@@ -506,6 +530,215 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
assert body == {"event": "turn_end", "chat_id": "chat-1"}
@pytest.mark.asyncio
async def test_send_turn_end_includes_latency_ms_when_present() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "latency_ms": 1500},
))
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {"event": "turn_end", "chat_id": "chat-1", "latency_ms": 1500}
@pytest.mark.asyncio
async def test_send_turn_end_includes_goal_state_when_present() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
blob = {"active": True, "ui_summary": "Explore codebase"}
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={"_turn_end": True, "goal_state": blob},
))
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {"event": "turn_end", "chat_id": "chat-1", "goal_state": blob}
@pytest.mark.asyncio
async def test_send_goal_status_running_emits_event_with_started_at() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={
"_goal_status": True,
"goal_status": "running",
"started_at": 1_700_000_000.5,
},
))
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {
"event": "goal_status",
"chat_id": "chat-1",
"status": "running",
"started_at": 1_700_000_000.5,
}
@pytest.mark.asyncio
async def test_send_goal_status_idle_omits_started_at() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-1",
content="",
metadata={
"_goal_status": True,
"goal_status": "idle",
"goal_started_at": 99.0,
},
))
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {"event": "goal_status", "chat_id": "chat-1", "status": "idle"}
@pytest.mark.asyncio
async def test_send_goal_state_emits_blob_per_chat() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_a = AsyncMock()
mock_b = AsyncMock()
channel._attach(mock_a, "chat-a")
channel._attach(mock_b, "chat-b")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-a",
content="",
metadata={
"_goal_state_sync": True,
"goal_state": {"active": True, "ui_summary": "A"},
},
))
mock_a.send.assert_awaited_once()
mock_b.send.assert_not_called()
body = json.loads(mock_a.send.await_args.args[0])
assert body == {
"event": "goal_state",
"chat_id": "chat-a",
"goal_state": {"active": True, "ui_summary": "A"},
}
@pytest.mark.asyncio
async def test_maybe_push_active_goal_state_noop_without_session_manager() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
channel._session_manager = None
await channel._maybe_push_active_goal_state("chat-1")
mock_ws.send.assert_not_called()
@pytest.mark.asyncio
async def test_maybe_push_active_goal_state_skips_when_no_goal_on_disk() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
sm = MagicMock()
sm.read_session_file.return_value = None
channel._session_manager = sm
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
mock_ws.send.assert_not_called()
@pytest.mark.asyncio
async def test_maybe_push_active_goal_state_notifies_when_goal_active_on_disk() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
sm = MagicMock()
sm.read_session_file.return_value = {
"metadata": {
"goal_state": {
"status": "active",
"objective": "finish docs",
"ui_summary": "Docs",
},
},
"messages": [],
}
channel._session_manager = sm
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel._maybe_push_active_goal_state("chat-1")
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body["event"] == "goal_state"
assert body["chat_id"] == "chat-1"
assert body["goal_state"]["active"] is True
assert body["goal_state"]["objective"] == "finish docs"
assert body["goal_state"]["ui_summary"] == "Docs"
@pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_skips_when_no_active_turn() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
from nanobot.utils import webui_turn_helpers as wth
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
await channel._maybe_push_turn_run_wall_clock("chat-1")
mock_ws.send.assert_not_called()
@pytest.mark.asyncio
async def test_maybe_push_turn_run_wall_clock_replays_running() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
from nanobot.utils import webui_turn_helpers as wth
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
try:
wth._WEBSOCKET_TURN_WALL_STARTED_AT["chat-1"] = 1_700_000_000.0
await channel._maybe_push_turn_run_wall_clock("chat-1")
finally:
wth._WEBSOCKET_TURN_WALL_STARTED_AT.pop("chat-1", None)
mock_ws.send.assert_awaited_once()
body = json.loads(mock_ws.send.await_args.args[0])
assert body == {
"event": "goal_status",
"chat_id": "chat-1",
"status": "running",
"started_at": 1_700_000_000.0,
}
@pytest.mark.asyncio
async def test_send_session_updated_emits_session_updated_event() -> None:
bus = MagicMock()
@@ -1245,3 +1478,28 @@ def test_parse_envelope_rejects_legacy_and_garbage() -> None:
)
def test_is_valid_chat_id(value: Any, expected: bool) -> None:
assert _is_valid_chat_id(value) is expected
def test_handle_webui_thread_get_returns_json(tmp_path, monkeypatch) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
from nanobot.utils.webui_transcript import append_transcript_object
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:c1"
append_transcript_object(key, {"event": "user", "chat_id": "c1", "text": "hi"})
bus = MagicMock()
channel = _ch(bus)
channel._api_tokens["tok"] = time.monotonic() + 300.0
enc = quote(key, safe="")
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
resp = channel._handle_webui_thread_get(req, enc)
assert resp.status_code == 200
body = json.loads(resp.body.decode())
assert body["sessionKey"] == key
assert len(body["messages"]) == 1
assert body["messages"][0]["role"] == "user"
assert body["messages"][0]["content"] == "hi"
+65 -10
View File
@@ -22,6 +22,7 @@ def _ch(
session_manager: SessionManager | None = None,
static_dist_path: Path | None = None,
port: int = _PORT,
runtime_model_name: Any | None = None,
**extra: Any,
) -> WebSocketChannel:
cfg: dict[str, Any] = {
@@ -33,11 +34,16 @@ def _ch(
"websocketRequiresToken": False,
}
cfg.update(extra)
ws_kwargs: dict[str, Any] = {
"session_manager": session_manager,
"static_dist_path": static_dist_path,
}
if runtime_model_name is not None:
ws_kwargs["runtime_model_name"] = runtime_model_name
return WebSocketChannel(
cfg,
bus,
session_manager=session_manager,
static_dist_path=static_dist_path,
**ws_kwargs,
)
@@ -171,8 +177,14 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
@pytest.mark.asyncio
async def test_session_delete_removes_file(bus: MagicMock, tmp_path: Path) -> None:
async def test_session_delete_removes_file(
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
sm = _seed_session(tmp_path, key="websocket:doomed")
from nanobot.utils.webui_transcript import append_transcript_object
append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"})
channel = _ch(bus, session_manager=sm, port=29903)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
@@ -183,6 +195,8 @@ async def test_session_delete_removes_file(bus: MagicMock, tmp_path: Path) -> No
path = sm._get_session_path("websocket:doomed")
assert path.exists()
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key('websocket:doomed')}.jsonl"
assert webui_path.is_file()
resp = await _http_get(
"http://127.0.0.1:29903/api/sessions/websocket:doomed/delete",
headers=auth,
@@ -190,6 +204,7 @@ async def test_session_delete_removes_file(bus: MagicMock, tmp_path: Path) -> No
assert resp.status_code == 200
assert resp.json()["deleted"] is True
assert not path.exists()
assert not webui_path.exists()
finally:
await channel.stop()
await server_task
@@ -433,7 +448,7 @@ def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="::", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(
resp = channel._handle_bootstrap(
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
)
assert resp.status_code == 200
@@ -442,7 +457,7 @@ def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None:
def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
"""When only token (not token_issue_secret) is set, bootstrap accepts it."""
channel = _ch(bus, host="0.0.0.0", token="static-tok")
resp = channel._handle_webui_bootstrap(
resp = channel._handle_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer static-tok"})
)
assert resp.status_code == 200
@@ -452,13 +467,53 @@ def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
channel = _ch(bus, host="127.0.0.1")
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
def test_bootstrap_prefers_runtime_model_name(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nanobot.channels.websocket._default_model_name_from_config",
lambda: "from-disk",
)
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " live/model ")
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["model_name"] == "live/model"
def test_bootstrap_falls_back_when_runtime_returns_empty(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nanobot.channels.websocket._default_model_name_from_config",
lambda: "from-disk",
)
channel = _ch(bus, host="127.0.0.1", runtime_model_name=lambda: " ")
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["model_name"] == "from-disk"
def test_bootstrap_falls_back_when_runtime_raises(bus: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"nanobot.channels.websocket._default_model_name_from_config",
lambda: "from-disk",
)
def boom():
raise RuntimeError("resolver failed")
channel = _ch(bus, host="127.0.0.1", runtime_model_name=boom)
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 200
body = json.loads(resp.body)
assert body["model_name"] == "from-disk"
def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct")
resp = channel._handle_webui_bootstrap(
resp = channel._handle_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer wrong"})
)
assert resp.status_code == 401
@@ -466,7 +521,7 @@ def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None:
def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(
resp = channel._handle_bootstrap(
_REMOTE, _FakeReq({"Authorization": "Bearer s3cret"})
)
assert resp.status_code == 200
@@ -476,7 +531,7 @@ def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None:
def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None:
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(
resp = channel._handle_bootstrap(
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
)
assert resp.status_code == 200
@@ -485,5 +540,5 @@ def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None:
def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None:
"""When secret is set, even localhost must provide it (reverse-proxy safety)."""
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
resp = channel._handle_webui_bootstrap(_LOCAL, _NO_HEADERS)
resp = channel._handle_bootstrap(_LOCAL, _NO_HEADERS)
assert resp.status_code == 401
+2 -2
View File
@@ -176,7 +176,7 @@ class TestRestartCommand:
assert response is not None
assert "Model: test-model" in response.content
assert "Tokens: 0 in / 0 out" in response.content
assert "Context: 20k/65k (31% of input budget)" in response.content
assert "Context: 20k/262k (7% of input budget)" in response.content
assert "Session: 3 messages" in response.content
assert "Uptime: 2m 5s" in response.content
assert "Tasks: 0 active" in response.content
@@ -240,7 +240,7 @@ class TestRestartCommand:
assert response is not None
assert "Tokens: 1200 in / 34 out" in response.content
assert "Context: 1k/65k (1% of input budget)" in response.content
assert "Context: 1k/262k (0% of input budget)" in response.content
assert "Tasks: 0 active" in response.content
@pytest.mark.asyncio
+54
View File
@@ -9,6 +9,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.command.builtin import (
build_help_text,
builtin_command_palette,
cmd_goal,
cmd_model,
register_builtin_commands,
)
@@ -54,6 +55,13 @@ def _ctx(loop: AgentLoop, raw: str, args: str = "") -> CommandContext:
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop)
def _ctx_session(loop: AgentLoop, raw: str, args: str = "") -> CommandContext:
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=raw)
return CommandContext(
msg=msg, session=MagicMock(), key=msg.session_key, raw=raw, args=args, loop=loop,
)
@pytest.mark.asyncio
async def test_model_command_lists_current_and_available_presets(tmp_path) -> None:
loop = _make_loop(tmp_path)
@@ -136,3 +144,49 @@ def test_model_command_in_help_and_palette() -> None:
assert any(item["command"] == "/model" and item["arg_hint"] == "[preset]" for item in palette)
assert "/model [preset]" in build_help_text()
@pytest.mark.asyncio
async def test_goal_command_shows_usage_without_args(tmp_path) -> None:
loop = _make_loop(tmp_path)
out = await cmd_goal(_ctx(loop, "/goal"))
assert out is not None
assert "Usage: /goal" in out.content
@pytest.mark.asyncio
async def test_goal_command_rejects_mid_turn_without_session(tmp_path) -> None:
loop = _make_loop(tmp_path)
out = await cmd_goal(_ctx(loop, "/goal do work", args="do work"))
assert out is not None
assert "/stop" in out.content
@pytest.mark.asyncio
async def test_goal_command_rewrites_to_agent_prompt(tmp_path) -> None:
loop = _make_loop(tmp_path)
ctx = _ctx_session(loop, "/goal audit the repo", args="audit the repo")
out = await cmd_goal(ctx)
assert out is None
assert "audit the repo" in ctx.msg.content
assert "long_task" in ctx.msg.content
assert ctx.msg.metadata.get("original_command") == "/goal"
assert ctx.msg.metadata.get("original_content") == "/goal audit the repo"
assert isinstance(ctx.msg.metadata.get("goal_started_at"), int | float)
@pytest.mark.asyncio
async def test_goal_command_registered_on_router(tmp_path) -> None:
router = CommandRouter()
register_builtin_commands(router)
loop = _make_loop(tmp_path)
ctx = _ctx_session(loop, "/goal ship it", args="ship it")
out = await router.dispatch(ctx)
assert out is None
assert "ship it" in ctx.msg.content
def test_goal_command_in_help_and_palette() -> None:
palette = builtin_command_palette()
assert any(item["command"] == "/goal" and item["arg_hint"] == "<goal>" for item in palette)
assert "/goal <goal>" in build_help_text()
@@ -26,12 +26,14 @@ class TestIsDispatchableCommand:
assert router.is_dispatchable_command("/dream")
assert router.is_dispatchable_command("/dream-log")
assert router.is_dispatchable_command("/dream-restore")
assert router.is_dispatchable_command("/goal")
assert router.is_dispatchable_command("/pairing")
def test_prefix_commands_match(self, router: CommandRouter) -> None:
assert router.is_dispatchable_command("/dream-log abc123")
assert router.is_dispatchable_command("/dream-restore def456")
assert router.is_dispatchable_command("/model fast")
assert router.is_dispatchable_command("/goal migrate the database")
assert router.is_dispatchable_command("/pairing list")
assert router.is_dispatchable_command("/pairing approve CODE")
+2 -2
View File
@@ -34,7 +34,7 @@ def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path)
config = load_config(config_path)
assert config.agents.defaults.max_tokens == 1234
assert config.agents.defaults.context_window_tokens == 65_536
assert config.agents.defaults.context_window_tokens == 262_144
assert not hasattr(config.agents.defaults, "memory_window")
@@ -60,7 +60,7 @@ def test_save_config_writes_context_window_tokens_but_not_memory_window(tmp_path
defaults = saved["agents"]["defaults"]
assert defaults["maxTokens"] == 2222
assert defaults["contextWindowTokens"] == 65_536
assert defaults["contextWindowTokens"] == 262_144
assert "memoryWindow" not in defaults
@@ -0,0 +1,149 @@
"""Anthropic streaming idle timeout should follow the full SSE stream, not text only."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.providers.anthropic_provider import AnthropicProvider
def _final_message_stub(text: str = "Hi") -> SimpleNamespace:
return SimpleNamespace(
content=[SimpleNamespace(type="text", text=text)],
stop_reason="end_turn",
usage=SimpleNamespace(
input_tokens=3,
output_tokens=2,
cache_creation_input_tokens=None,
cache_read_input_tokens=None,
),
)
class _FakeAsyncStream:
"""Minimal async iterator + context manager mimicking AsyncMessageStream."""
def __init__(self, chunks: list[SimpleNamespace]) -> None:
self._chunks = chunks
self._idx = 0
self.get_final_message = AsyncMock(return_value=_final_message_stub())
async def __anext__(self) -> SimpleNamespace:
if self._idx >= len(self._chunks):
raise StopAsyncIteration
c = self._chunks[self._idx]
self._idx += 1
return c
def __aiter__(self) -> _FakeAsyncStream:
return self
async def __aenter__(self) -> _FakeAsyncStream:
return self
async def __aexit__(self, *_exc: object) -> None:
pass
@pytest.mark.asyncio
async def test_chat_stream_calls_on_content_delta_only_for_text_delta() -> None:
"""Thinking deltas must be consumed without invoking on_content_delta."""
provider = AnthropicProvider(api_key="sk-test")
provider._client = MagicMock()
chunks = [
SimpleNamespace(
type="content_block_delta",
delta=SimpleNamespace(type="thinking_delta", thinking="think"),
),
SimpleNamespace(
type="content_block_delta",
delta=SimpleNamespace(type="text_delta", text="Hi"),
),
]
fake = _FakeAsyncStream(chunks)
stream_cm = MagicMock()
stream_cm.__aenter__ = AsyncMock(return_value=fake)
stream_cm.__aexit__ = AsyncMock(return_value=None)
provider._client.messages.stream = MagicMock(return_value=stream_cm)
out: list[str] = []
async def on_delta(s: str) -> None:
out.append(s)
await provider.chat_stream(
messages=[{"role": "user", "content": "hello"}],
on_content_delta=on_delta,
on_thinking_delta=None,
)
assert out == ["Hi"]
fake.get_final_message.assert_awaited_once()
@pytest.mark.asyncio
async def test_chat_stream_invokes_on_thinking_delta_for_thinking_delta() -> None:
provider = AnthropicProvider(api_key="sk-test")
provider._client = MagicMock()
chunks = [
SimpleNamespace(
type="content_block_delta",
delta=SimpleNamespace(type="thinking_delta", thinking="a"),
),
SimpleNamespace(
type="content_block_delta",
delta=SimpleNamespace(type="thinking_delta", thinking="b"),
),
SimpleNamespace(
type="content_block_delta",
delta=SimpleNamespace(type="text_delta", text="X"),
),
]
fake = _FakeAsyncStream(chunks)
stream_cm = MagicMock()
stream_cm.__aenter__ = AsyncMock(return_value=fake)
stream_cm.__aexit__ = AsyncMock(return_value=None)
provider._client.messages.stream = MagicMock(return_value=stream_cm)
thinking_parts: list[str] = []
text_parts: list[str] = []
async def on_thinking(s: str) -> None:
thinking_parts.append(s)
async def on_text(s: str) -> None:
text_parts.append(s)
await provider.chat_stream(
messages=[{"role": "user", "content": "hello"}],
on_content_delta=on_text,
on_thinking_delta=on_thinking,
)
assert thinking_parts == ["a", "b"]
assert text_parts == ["X"]
@pytest.mark.asyncio
async def test_chat_stream_without_callback_still_finalizes() -> None:
provider = AnthropicProvider(api_key="sk-test")
provider._client = MagicMock()
fake = _FakeAsyncStream([])
fake.get_final_message = AsyncMock(return_value=_final_message_stub("ok"))
stream_cm = MagicMock()
stream_cm.__aenter__ = AsyncMock(return_value=fake)
stream_cm.__aexit__ = AsyncMock(return_value=None)
provider._client.messages.stream = MagicMock(return_value=stream_cm)
res = await provider.chat_stream(
messages=[{"role": "user", "content": "hello"}],
on_content_delta=None,
)
assert res.content == "ok"
fake.get_final_message.assert_awaited_once()
+104
View File
@@ -98,6 +98,110 @@ def _fake_chat_stream(text: str = "ok"):
return _stream()
def _fake_chat_stream_reasoning_chunks():
"""Mimic DeepSeek-style ``chat.completions`` stream: ``reasoning_content`` then ``content``."""
async def _stream():
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason=None,
delta=SimpleNamespace(
content=None,
reasoning_content="step1",
reasoning=None,
tool_calls=None,
),
),
],
usage=None,
)
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason=None,
delta=SimpleNamespace(
content=None,
reasoning_content="step2",
reasoning=None,
tool_calls=None,
),
),
],
usage=None,
)
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason=None,
delta=SimpleNamespace(
content="answer",
reasoning_content=None,
tool_calls=None,
),
),
],
usage=None,
)
yield SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="stop",
delta=SimpleNamespace(
content=None,
reasoning_content=None,
tool_calls=None,
),
),
],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=5,
total_tokens=15,
),
)
return _stream()
@pytest.mark.asyncio
async def test_openai_compat_stream_forwards_reasoning_deltas_deepseek_style() -> None:
"""Regression: DeepSeek-V4 / reasoner expose ``delta.reasoning_content`` during streaming."""
mock_chat = AsyncMock(return_value=_fake_chat_stream_reasoning_chunks())
spec = find_by_name("deepseek")
thinking: list[str] = []
content: list[str] = []
async def on_thinking(d: str) -> None:
thinking.append(d)
async def on_content(d: str) -> None:
content.append(d)
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_openai:
client_instance = mock_openai.return_value
client_instance.chat.completions.create = mock_chat
provider = OpenAICompatProvider(
api_key="sk-test",
default_model="deepseek-v4-pro",
spec=spec,
)
result = await provider.chat_stream(
messages=[{"role": "user", "content": "hi"}],
model="deepseek-v4-pro",
reasoning_effort="high",
on_content_delta=on_content,
on_thinking_delta=on_thinking,
)
assert thinking == ["step1", "step2"]
assert content == ["answer"]
assert result.reasoning_content == "step1step2"
assert result.content == "answer"
mock_chat.assert_awaited_once()
class _FakeResponsesError(Exception):
def __init__(self, status_code: int, text: str):
super().__init__(text)
+90
View File
@@ -0,0 +1,90 @@
"""Tests for ``goal_state`` session metadata helpers."""
from __future__ import annotations
from nanobot.session.goal_state import (
GOAL_STATE_KEY,
discard_legacy_goal_state_key,
goal_state_runtime_lines,
goal_state_ws_blob,
parse_goal_state,
)
def test_runtime_lines_empty_when_no_metadata():
assert goal_state_runtime_lines(None) == []
assert goal_state_runtime_lines({}) == []
def test_runtime_lines_empty_when_completed():
meta = {
GOAL_STATE_KEY: {"status": "completed", "objective": "was doing X"},
}
assert goal_state_runtime_lines(meta) == []
def test_runtime_lines_include_objective_when_active():
meta = {
GOAL_STATE_KEY: {
"status": "active",
"objective": "Ship the fix.",
"ui_summary": "fix",
},
}
lines = goal_state_runtime_lines(meta)
assert "Goal (active):" in lines
assert "Ship the fix." in lines
assert any("Summary: fix" in ln for ln in lines)
def test_runtime_lines_read_legacy_thread_goal_key():
meta = {"thread_goal": {"status": "active", "objective": "Legacy key.", "ui_summary": "L"}}
lines = goal_state_runtime_lines(meta)
assert "Legacy key." in lines
def test_goal_state_key_takes_precedence_over_legacy():
meta = {
GOAL_STATE_KEY: {"status": "active", "objective": "New key wins.", "ui_summary": "n"},
"thread_goal": {"status": "active", "objective": "Ignored.", "ui_summary": "o"},
}
lines = goal_state_runtime_lines(meta)
assert "New key wins." in lines
assert "Ignored." not in "".join(lines)
def test_discard_legacy_goal_state_key():
meta: dict = {"thread_goal": {"x": 1}, GOAL_STATE_KEY: {"status": "active"}}
discard_legacy_goal_state_key(meta)
assert "thread_goal" not in meta
assert GOAL_STATE_KEY in meta
def test_parse_goal_state_accepts_json_string():
assert parse_goal_state('{"status":"active","objective":"x"}') == {
"status": "active",
"objective": "x",
}
def test_goal_state_ws_blob_inactive_when_missing_or_completed():
assert goal_state_ws_blob(None) == {"active": False}
assert goal_state_ws_blob({}) == {"active": False}
assert goal_state_ws_blob({GOAL_STATE_KEY: {"status": "completed", "objective": "x"}}) == {
"active": False,
}
def test_goal_state_ws_blob_active_shape():
meta = {
GOAL_STATE_KEY: {
"status": "active",
"objective": "Build feature.",
"ui_summary": "feat",
},
}
assert goal_state_ws_blob(meta) == {
"active": True,
"ui_summary": "feat",
"objective": "Build feature.",
}
+130
View File
@@ -305,3 +305,133 @@ async def test_message_tool_resolves_mixed_media_paths() -> None:
"https://example.com/url.png",
"http://example.com/http.png",
]
@pytest.mark.asyncio
async def test_message_tool_tracks_turn_media_for_same_target(tmp_path) -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={}))
tool.start_turn()
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
await tool.execute(content="see file", channel="websocket", chat_id="chat-1", media=[str(f)])
assert tool.turn_delivered_media_paths() == [str(f.resolve())]
@pytest.mark.asyncio
async def test_message_tool_start_turn_clears_tracked_media(tmp_path) -> None:
async def _send(msg: OutboundMessage) -> None:
pass
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={}))
tool.start_turn()
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
await tool.execute(content="see file", media=[str(f)])
tool.start_turn()
assert tool.turn_delivered_media_paths() == []
@pytest.mark.asyncio
async def test_message_tool_cross_target_does_not_track_turn_media(tmp_path) -> None:
async def _send(msg: OutboundMessage) -> None:
pass
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
tool.set_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={}))
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
await tool.execute(
content="see file",
channel="telegram",
chat_id="tg-other",
media=[str(f)],
)
assert tool.turn_delivered_media_paths() == []
@pytest.mark.asyncio
async def test_message_tool_rejects_wrong_explicit_ws_chat_id(tmp_path) -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
conv = "550e8400-e29b-41d4-a716-446655440000"
tool.set_context(RequestContext(channel="websocket", chat_id=conv, metadata={}))
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
result = await tool.execute(
content="see file",
channel="websocket",
chat_id="anon-deadbeefcafe",
media=[str(f)],
)
assert result.startswith("Error: chat_id does not match")
assert sent == []
@pytest.mark.asyncio
async def test_message_tool_allows_ws_explicit_when_matches_context(tmp_path) -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
conv = "550e8400-e29b-41d4-a716-446655440000"
tool.set_context(RequestContext(channel="websocket", chat_id=conv, metadata={}))
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
result = await tool.execute(
content="see file",
channel="websocket",
chat_id=conv,
media=[str(f)],
)
assert result.startswith("Message sent")
assert sent[0].chat_id == conv
@pytest.mark.asyncio
async def test_message_tool_cli_context_may_target_other_ws_chat(tmp_path) -> None:
"""Cron / CLI handlers keep non-websocket defaults; explicit websocket + uuid remains valid."""
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(send_callback=_send)
from nanobot.agent.tools.context import RequestContext
target = "550e8400-e29b-41d4-a716-446655440000"
tool.set_context(RequestContext(channel="cli", chat_id="direct", metadata={}))
f = tmp_path / "doc.md"
f.write_text("hello", encoding="utf-8")
result = await tool.execute(
content="ping",
channel="websocket",
chat_id=target,
media=[str(f)],
)
assert result.startswith("Message sent")
assert sent[0].channel == "websocket"
assert sent[0].chat_id == target
@@ -0,0 +1,57 @@
"""Tests for subagent announce text shaping on external channel surfaces."""
from nanobot.utils.subagent_channel_display import (
scrub_subagent_announce_body,
scrub_subagent_messages_for_channel,
)
def test_scrub_subagent_keeps_header_and_result_only() -> None:
raw = """[Subagent 'Phase1' failed]
Task: Collect GitHub stats.
Result:
gh CLI missing.
Summarize this naturally for the user. Keep it brief."""
out = scrub_subagent_announce_body(raw)
assert out == "[Subagent 'Phase1' failed]\n\ngh CLI missing."
assert "Task:" not in out
assert "Summarize" not in out
def test_scrub_subagent_messages_mutates_matching_rows() -> None:
messages: list[dict] = [
{"role": "assistant", "content": "hi"},
{
"role": "assistant",
"content": (
"[Subagent 'x' completed successfully]\n\nTask: t\n\nResult:\nr\n\nSummarize this naturally"
),
"injected_event": "subagent_result",
},
]
scrub_subagent_messages_for_channel(messages)
assert messages[0]["content"] == "hi"
assert "Task:" not in messages[1]["content"]
assert "[Subagent 'x' completed successfully]" in messages[1]["content"]
assert "r" in messages[1]["content"]
def test_scrub_normalizes_crlf_before_result_marker() -> None:
raw = "[Subagent 'z' failed]\r\n\r\nTask: x\r\n\r\nResult:\r\none line\r\n\r\nSummarize this naturally"
out = scrub_subagent_announce_body(raw)
assert "Task:" not in out
assert out.startswith("[Subagent 'z' failed]")
assert "one line" in out
def test_scrub_truncates_very_long_result() -> None:
body = "x" * 900
raw = f"[Subagent 'z' failed]\n\nTask: t\n\nResult:\n{body}\n\nSummarize this naturally"
out = scrub_subagent_announce_body(raw)
assert out.endswith("")
assert len(out) < len(raw)
assert body not in out
+20
View File
@@ -0,0 +1,20 @@
"""Tests for WebUI on-disk cleanup (legacy JSON + transcript JSONL)."""
from __future__ import annotations
from nanobot.utils.webui_thread_disk import delete_webui_thread, webui_thread_file_path
from nanobot.utils.webui_transcript import append_transcript_object, webui_transcript_path
def test_delete_webui_thread_removes_legacy_json_and_transcript(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:k1"
json_path = webui_thread_file_path(key)
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text('{"x":1}', encoding="utf-8")
append_transcript_object(key, {"event": "user", "chat_id": "k1", "text": "hi"})
assert webui_transcript_path(key).is_file()
assert delete_webui_thread(key) is True
assert not json_path.is_file()
assert not webui_transcript_path(key).is_file()
assert delete_webui_thread(key) is False
+55
View File
@@ -0,0 +1,55 @@
"""Tests for append-only WebUI transcript replay."""
from __future__ import annotations
from nanobot.utils.webui_transcript import (
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
append_transcript_object,
read_transcript_lines,
replay_transcript_to_ui_messages,
)
def test_append_and_read_roundtrip(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t1"
append_transcript_object(key, {"event": "user", "chat_id": "t1", "text": "hello"})
lines = read_transcript_lines(key)
assert len(lines) == 1
assert lines[0]["text"] == "hello"
def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t2"
for ev in (
{"event": "user", "chat_id": "t2", "text": "q"},
{"event": "reasoning_delta", "chat_id": "t2", "text": "think"},
{"event": "reasoning_end", "chat_id": "t2"},
{"event": "delta", "chat_id": "t2", "text": "a"},
{"event": "stream_end", "chat_id": "t2"},
{"event": "turn_end", "chat_id": "t2", "latency_ms": 42},
):
append_transcript_object(key, ev)
lines = read_transcript_lines(key)
msgs = replay_transcript_to_ui_messages(lines)
assert len(msgs) == 2
assert msgs[0]["role"] == "user"
assert msgs[0]["content"] == "q"
assert msgs[1]["role"] == "assistant"
assert msgs[1]["content"] == "a"
assert msgs[1]["reasoning"] == "think"
assert msgs[1]["latencyMs"] == 42
def test_build_response_schema(monkeypatch, tmp_path) -> None:
from nanobot.utils.webui_transcript import build_webui_thread_response
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t3"
append_transcript_object(key, {"event": "user", "chat_id": "t3", "text": "x"})
out = build_webui_thread_response(key, augment_user_media=None)
assert out is not None
assert out["schemaVersion"] == WEBUI_TRANSCRIPT_SCHEMA_VERSION
assert out["sessionKey"] == key
assert len(out["messages"]) == 1
+55
View File
@@ -0,0 +1,55 @@
"""Tests for WebSocket turn timing strip bookkeeping."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.bus.events import InboundMessage
from nanobot.utils import webui_turn_helpers as wth
@pytest.fixture(autouse=True)
def _clear_turn_wall_clock() -> None:
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
yield
wth._WEBSOCKET_TURN_WALL_STARTED_AT.clear()
@pytest.mark.asyncio
async def test_publish_turn_run_status_running_records_wall_clock() -> None:
bus = MagicMock()
bus.publish_outbound = AsyncMock()
msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-a", content="hi")
await wth.publish_turn_run_status(bus, msg, "running")
assert "chat-a" in wth._WEBSOCKET_TURN_WALL_STARTED_AT
t0 = wth.websocket_turn_wall_started_at("chat-a")
assert isinstance(t0, float)
call = bus.publish_outbound.await_args[0][0]
assert call.chat_id == "chat-a"
assert call.metadata.get("started_at") == t0
@pytest.mark.asyncio
async def test_publish_turn_run_status_idle_clears_wall_clock() -> None:
bus = MagicMock()
bus.publish_outbound = AsyncMock()
msg = InboundMessage(channel="websocket", sender_id="u", chat_id="chat-b", content="hi")
await wth.publish_turn_run_status(bus, msg, "running")
assert wth.websocket_turn_wall_started_at("chat-b") is not None
await wth.publish_turn_run_status(bus, msg, "idle")
assert wth.websocket_turn_wall_started_at("chat-b") is None
@pytest.mark.asyncio
async def test_publish_turn_run_status_non_websocket_noop_registry() -> None:
bus = MagicMock()
bus.publish_outbound = AsyncMock()
msg = InboundMessage(channel="telegram", sender_id="u", chat_id="1", content="hi")
await wth.publish_turn_run_status(bus, msg, "running")
assert wth._WEBSOCKET_TURN_WALL_STARTED_AT == {}
+13 -18
View File
@@ -7,7 +7,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
@@ -20,12 +19,6 @@ interface ChatListProps {
emptyLabel?: string;
}
function titleFor(s: ChatSummary, fallbackTitle: string): string {
const p = (s.title || s.preview)?.trim();
if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p;
return fallbackTitle;
}
export function ChatList({
sessions,
activeKey,
@@ -58,8 +51,8 @@ export function ChatList({
});
return (
<ScrollArea className="h-full">
<div className="space-y-3 px-2 py-1.5">
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
<div className="min-w-0 space-y-3 px-2 py-1.5">
{groups.map((group) => (
<section key={group.label} aria-label={group.label}>
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
@@ -68,15 +61,16 @@ export function ChatList({
<ul className="space-y-0.5">
{group.sessions.map((s) => {
const active = s.key === activeKey;
const title = titleFor(
s,
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
);
const fallbackTitle = t("chat.fallbackTitle", {
id: s.chatId.slice(0, 6),
});
const rawLabel = (s.title || s.preview)?.trim();
const title = rawLabel || fallbackTitle;
return (
<li key={s.key}>
<li key={s.key} className="min-w-0">
<div
className={cn(
"group flex min-h-8 items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
"group flex min-h-8 min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
@@ -85,14 +79,15 @@ export function ChatList({
<button
type="button"
onClick={() => onSelect(s.key)}
className="min-w-0 flex-1 py-1.5 text-left"
title={rawLabel || fallbackTitle}
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
>
<span className="block w-full truncate font-medium leading-5">{title}</span>
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/75 opacity-0 transition-opacity",
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
@@ -124,7 +119,7 @@ export function ChatList({
</section>
))}
</div>
</ScrollArea>
</div>
);
}
-115
View File
@@ -1,115 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Composer } from "@/components/Composer";
import { MessageList } from "@/components/MessageList";
import { useClient } from "@/providers/ClientProvider";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import type { ChatSummary } from "@/lib/types";
interface ChatPaneProps {
session: ChatSummary | null;
/** Provision a new chat and mark it active. Returns the new chat_id or null. */
onNewChat: () => Promise<string | null>;
}
/**
* The chat surface: persisted history on top, live stream below, composer
* pinned at the bottom. When no session is active we render a centered
* welcome card with a fully-functional composer typing a first message
* quietly provisions a new chat and routes the message through.
*/
export function ChatPane({ session, onNewChat }: ChatPaneProps) {
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
const { client } = useClient();
const [booting, setBooting] = useState(false);
const pendingFirstRef = useRef<string | null>(null);
const initial = useMemo(() => historical, [historical]);
const { messages, isStreaming, send, setMessages } = useNanobotStream(
chatId,
initial,
hasPendingToolCalls,
);
useEffect(() => {
if (!loading && chatId) setMessages(historical);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical]);
// Once a session becomes active, flush any first-message stashed from the
// welcome composer so the user's keystroke "just sends".
useEffect(() => {
if (!chatId) return;
const pending = pendingFirstRef.current;
if (!pending) return;
pendingFirstRef.current = null;
client.sendMessage(chatId, pending);
setMessages((prev) => [
...prev,
{
id: crypto.randomUUID(),
role: "user",
content: pending,
createdAt: Date.now(),
},
]);
setBooting(false);
}, [chatId, client, setMessages]);
const handleWelcomeSend = useCallback(
async (content: string) => {
if (booting) return;
setBooting(true);
pendingFirstRef.current = content;
const newId = await onNewChat();
if (!newId) {
// Creation failed — release the lock so the user can retry.
pendingFirstRef.current = null;
setBooting(false);
}
},
[booting, onNewChat],
);
if (!session) {
return (
<section className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6">
<div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<h1 className="text-xl font-medium tracking-tight text-foreground/90">
What can I do for you?
</h1>
<p className="max-w-md text-center text-sm text-muted-foreground">
Your conversations are persisted locally under the nanobot
workspace. Start typing and I'll open a new chat.
</p>
</div>
<div className="w-full animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<Composer
compact
disabled={booting}
onSend={handleWelcomeSend}
placeholder={
booting ? "Opening a new chat…" : "Ask anything..."
}
/>
</div>
</div>
</section>
);
}
return (
<section className="relative flex min-h-0 flex-1 flex-col">
<MessageList messages={messages} isStreaming={isStreaming} />
<Composer
onSend={send}
disabled={!chatId}
placeholder="Type your message…"
/>
</section>
);
}
+193 -100
View File
@@ -1,14 +1,24 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
useCallback,
useDeferredValue,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText } from "@/components/MarkdownText";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { formatTurnLatency } from "@/lib/format";
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
showAssistantCopyAction?: boolean;
}
/**
@@ -20,7 +30,10 @@ interface MessageBubbleProps {
* Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
* collapsible group so intermediate steps never masquerade as replies.
*/
export function MessageBubble({ message }: MessageBubbleProps) {
export function MessageBubble({
message,
showAssistantCopyAction = true,
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
@@ -89,6 +102,14 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
const showCopyButton = showAssistantCopyAction && showAssistantActions;
const latencyMs = message.latencyMs;
const showLatencyFooter =
message.role === "assistant"
&& latencyMs != null
&& !message.isStreaming
&& (!empty || hasReasoning || media.length > 0);
const showAssistantFooterRow = showCopyButton || showLatencyFooter;
return (
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{hasReasoning ? (
@@ -99,17 +120,17 @@ export function MessageBubble({ message }: MessageBubbleProps) {
) : empty && message.isStreaming ? null : (
<>
<MarkdownText>{message.content}</MarkdownText>
{message.isStreaming && <StreamCursor />}
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantActions ? (
<div className="mt-2 flex items-center gap-1 text-muted-foreground">
{showAssistantFooterRow ? (
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
{showCopyButton ? (
<button
type="button"
onClick={onCopyAssistantReply}
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
title={copied ? t("message.copiedReply") : t("message.copyReply")}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-full",
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
@@ -120,6 +141,15 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
) : null}
{showLatencyFooter ? (
<span
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
title={t("message.turnLatencyTitle")}
>
{formatTurnLatency(latencyMs)}
</span>
) : null}
</div>
) : null}
</>
@@ -187,14 +217,34 @@ function MediaCell({ media }: { media: UIMediaAttachment }) {
: t("message.fileAttachment", { defaultValue: "File attachment" });
const Icon = media.kind === "video" ? PlaySquare : FileIcon;
const inner = (
<>
<Icon className="h-4 w-4 flex-none" aria-hidden />
<span className="truncate">{media.name ?? label}</span>
</>
);
if (hasUrl) {
return (
<a
href={media.url}
download={media.name ?? label}
title={media.name ?? undefined}
aria-label={label}
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground hover:underline"
>
{inner}
</a>
);
}
return (
<div
className="flex max-w-[18rem] items-center gap-2 rounded-[14px] border border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground"
title={media.name ?? undefined}
aria-label={label}
>
<Icon className="h-4 w-4 flex-none" aria-hidden />
<span className="truncate">{media.name ?? label}</span>
{inner}
</div>
);
}
@@ -338,20 +388,6 @@ function UserImageCell({
);
}
/** Blinking cursor appended at the end of streaming text. */
function StreamCursor() {
const { t } = useTranslation();
return (
<span
aria-label={t("message.streaming")}
className={cn(
"ml-0.5 inline-block h-[1em] w-[3px] translate-y-[2px] align-middle",
"rounded-sm bg-foreground/70 animate-pulse",
)}
/>
);
}
/** Pre-token-arrival placeholder: three bouncing dots. */
function TypingDots() {
const { t } = useTranslation();
@@ -379,6 +415,139 @@ function Dot({ delay }: { delay: string }) {
);
}
/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */
export function StreamingLabelSheen({
children,
active,
className,
}: {
children: ReactNode;
active: boolean;
className?: string;
}) {
return (
<span className={cn("relative block min-w-0 py-px", className)}>
<span
className={cn(
"relative z-0 block font-medium leading-normal text-muted-foreground",
!active && "truncate",
)}
>
{children}
</span>
{active ? (
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
<span className="reasoning-sheen-stripe" />
</span>
) : null}
</span>
);
}
interface ReasoningBubbleProps {
text: string;
streaming: boolean;
hasBodyBelow: boolean;
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
embeddedInCluster?: boolean;
}
/**
* Subordinate "thinking" trace shown above an assistant turn.
*
* Lifecycle:
* - While ``streaming`` is true (``reasoning_delta`` frames still arriving),
* the bubble defaults to open and the header shows a sheen + pulse so
* the user sees the model "thinking out loud" in real time.
* - Expanded reasoning uses the same Markdown pipeline as assistant replies
* (deferred while streaming to reduce parser thrash), so headings and
* emphasis render instead of leaking raw ``###`` / ``**``.
* - On ``reasoning_end`` the bubble auto-collapses for prose density
* the user can re-expand to inspect the chain of thought. The local
* toggle persists once the user interacts.
*/
export function ReasoningBubble({
text,
streaming,
hasBodyBelow,
embeddedInCluster = false,
}: ReasoningBubbleProps) {
const { t } = useTranslation();
const deferredText = useDeferredValue(text);
const markdownSource = streaming ? deferredText : text;
const [userToggled, setUserToggled] = useState(false);
const [openLocal, setOpenLocal] = useState(true);
const open = userToggled ? openLocal : streaming;
const onToggle = () => {
setUserToggled(true);
setOpenLocal((v) => (userToggled ? !v : !open));
};
useEffect(() => {
if (open && text.length > 0) {
preloadMarkdownText();
}
}, [open, text.length]);
return (
<div
className={cn(
"w-full",
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
hasBodyBelow && "mb-2",
)}
>
<button
type="button"
onClick={onToggle}
className={cn(
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
)}
aria-expanded={open}
aria-live={streaming ? "polite" : undefined}
>
<Sparkles
className={cn("h-3.5 w-3.5", streaming && "animate-pulse")}
aria-hidden
/>
<StreamingLabelSheen active={streaming} className="min-w-0 flex-1 text-left">
{streaming
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
: t("message.reasoning", { defaultValue: "Thinking" })}
</StreamingLabelSheen>
<ChevronRight
aria-hidden
className={cn(
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
open && "rotate-90",
)}
/>
</button>
{open && text.length > 0 && (
<div
className={cn(
"mt-1 min-w-0 border-l border-muted-foreground/20 pl-3",
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
)}
>
<MarkdownText
className={cn(
"text-[12.5px] italic text-muted-foreground/88",
"prose-p:my-1.5 prose-li:my-0.5",
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
"prose-headings:text-muted-foreground/92 prose-strong:text-muted-foreground",
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
"prose-a:text-muted-foreground/95 prose-a:underline hover:prose-a:opacity-90",
"prose-code:text-[0.92em]",
)}
>
{markdownSource}
</MarkdownText>
</div>
)}
</div>
);
}
interface TraceGroupProps {
message: UIMessage;
animClass: string;
@@ -389,7 +558,7 @@ interface TraceGroupProps {
* collapsed because tool traces are supporting evidence, not the answer.
* A single click expands the exact calls when the user wants details.
*/
function TraceGroup({ message, animClass }: TraceGroupProps) {
export function TraceGroup({ message, animClass }: TraceGroupProps) {
const { t } = useTranslation();
const lines = message.traces ?? [message.content];
const count = lines.length;
@@ -439,79 +608,3 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
</div>
);
}
interface ReasoningBubbleProps {
text: string;
streaming: boolean;
hasBodyBelow: boolean;
}
/**
* Subordinate "thinking" trace shown above an assistant turn.
*
* Lifecycle:
* - While ``streaming`` is true (``reasoning_delta`` frames still arriving),
* the bubble defaults to open and the header runs a shimmer + pulse so
* the user sees the model "thinking out loud" in real time.
* - On ``reasoning_end`` the bubble auto-collapses for prose density
* the user can re-expand to inspect the chain of thought. The local
* toggle persists once the user interacts.
*/
function ReasoningBubble({ text, streaming, hasBodyBelow }: ReasoningBubbleProps) {
const { t } = useTranslation();
const [userToggled, setUserToggled] = useState(false);
const [openLocal, setOpenLocal] = useState(true);
const open = userToggled ? openLocal : streaming;
const onToggle = () => {
setUserToggled(true);
setOpenLocal((v) => (userToggled ? !v : !open));
};
return (
<div
className={cn(
"w-full animate-in fade-in-0 slide-in-from-top-1 duration-200",
hasBodyBelow && "mb-2",
)}
>
<button
type="button"
onClick={onToggle}
className={cn(
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
streaming && "reasoning-shimmer",
)}
aria-expanded={open}
aria-live={streaming ? "polite" : undefined}
>
<Sparkles
className={cn("h-3.5 w-3.5", streaming && "animate-pulse")}
aria-hidden
/>
<span className="font-medium">
{streaming
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
: t("message.reasoning", { defaultValue: "Thinking" })}
</span>
<ChevronRight
aria-hidden
className={cn(
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
open && "rotate-90",
)}
/>
</button>
{open && text.length > 0 && (
<div
className={cn(
"mt-1 space-y-0.5 whitespace-pre-wrap break-words border-l border-muted-foreground/20 pl-3",
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
"text-[12.5px] italic leading-relaxed text-muted-foreground/85",
)}
>
{text}
</div>
)}
</div>
);
}
+2 -2
View File
@@ -50,7 +50,7 @@ export function Sidebar(props: SidebarProps) {
return (
<nav
aria-label={t("sidebar.navigation")}
className="flex h-full w-full flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
>
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
<picture className="block min-w-0">
@@ -104,7 +104,7 @@ export function Sidebar(props: SidebarProps) {
{t("sidebar.newChat")}
</Button>
</div>
<div className="flex-1 overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatList
sessions={filteredSessions}
activeKey={props.activeKey}
@@ -0,0 +1,150 @@
import { useState } from "react";
import { ChevronRight, Layers } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
if (m.role !== "assistant" || m.kind === "trace") return false;
if (m.content.trim().length > 0) return false;
return !!(m.reasoning?.length || m.reasoningStreaming || m.isStreaming);
}
export function isAgentActivityMember(m: UIMessage): boolean {
return isReasoningOnlyAssistant(m) || m.kind === "trace";
}
function countToolCalls(messages: UIMessage[]): number {
let n = 0;
for (const m of messages) {
if (m.kind !== "trace") continue;
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
n += Math.max(lines, 1);
}
return n;
}
interface AgentActivityClusterProps {
messages: UIMessage[];
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
isTurnStreaming: boolean;
hasBodyBelow: boolean;
}
/**
* Outer fold wrapping interleaved reasoning-only assistant rows and tool-trace rows.
* Fixed max height with inner scroll; each block keeps its own small collapsible (reasoning / tools).
*/
export function AgentActivityCluster({
messages,
isTurnStreaming,
hasBodyBelow,
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
const toolCalls = countToolCalls(messages);
const [userToggledOuter, setUserToggledOuter] = useState(false);
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
const headerBusy = isTurnStreaming;
const summary =
isTurnStreaming
? reasoningSteps > 0
? t("message.agentActivityLiveSummary", {
reasoning: reasoningSteps,
tools: toolCalls,
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
})
: t("message.agentActivityLiveToolsOnly", {
tools: toolCalls,
defaultValue: "Working… · {{tools}} tool calls",
})
: reasoningSteps > 0
? t("message.agentActivitySummary", {
reasoning: reasoningSteps,
tools: toolCalls,
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
})
: t("message.agentActivityToolsOnly", {
tools: toolCalls,
defaultValue: "{{tools}} tool calls",
});
const toggleOuter = () => {
setUserToggledOuter(true);
setOuterOpenLocal((v) => (userToggledOuter ? !v : !outerExpanded));
};
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
type="button"
onClick={toggleOuter}
className={cn(
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
)}
aria-expanded={outerExpanded}
>
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
<StreamingLabelSheen
active={headerBusy}
className="min-w-0 flex-1 text-left"
>
{summary}
</StreamingLabelSheen>
<ChevronRight
aria-hidden
className={cn(
"h-3.5 w-3.5 shrink-0 transition-transform duration-200",
outerExpanded && "rotate-90",
)}
/>
</button>
{outerExpanded && (
<div
className={cn(
"mt-1 overflow-hidden rounded-md border border-border/50 bg-muted/25",
)}
>
<div
className={cn(
CLUSTER_SCROLL_MAX_CLASS,
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
)}
>
<div className="flex flex-col gap-2">
{messages.map((m) => {
if (isReasoningOnlyAssistant(m)) {
return (
<ReasoningBubble
key={m.id}
text={m.reasoning ?? ""}
streaming={!!m.reasoningStreaming}
hasBodyBelow={false}
embeddedInCluster
/>
);
}
if (m.kind === "trace") {
return <TraceGroup key={m.id} message={m} animClass="" />;
}
return null;
})}
</div>
</div>
</div>
)}
</div>
);
}
+147 -1
View File
@@ -13,6 +13,7 @@ import {
BookOpen,
Check,
ChevronDown,
ChevronUp,
CircleHelp,
History,
ImageIcon,
@@ -22,6 +23,7 @@ import {
Sparkles,
Square,
SquarePen,
Target,
Undo2,
X,
type LucideIcon,
@@ -29,6 +31,12 @@ import {
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
useAttachedImages,
type AttachedImage,
@@ -37,7 +45,7 @@ import {
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import type { SlashCommand } from "@/lib/types";
import type { SlashCommand, GoalStateWsPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@@ -61,6 +69,10 @@ interface ThreadComposerProps {
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
/** Unix seconds from server; turn elapsed timer above input while set. */
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -126,6 +138,133 @@ function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } {
return { top, bottom };
}
function goalStateStripPreview(
goal: GoalStateWsPayload | undefined,
t: (key: string) => string,
): string | null {
if (!goal?.active) return null;
const summary = goal.ui_summary?.trim();
if (summary) return summary;
const obj = goal.objective?.trim();
if (obj) return obj.length > 72 ? `${obj.slice(0, 72)}` : obj;
return t("thread.composer.goalStateFallback");
}
function RunElapsedStrip({
startedAt,
goalState,
}: {
startedAt: number | null;
goalState?: GoalStateWsPayload;
}) {
const { t } = useTranslation();
const [goalSheetOpen, setGoalSheetOpen] = useState(false);
const [, setTick] = useState(0);
useEffect(() => {
if (startedAt == null) return;
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [startedAt]);
const showTimer = startedAt != null;
const stripLabel = goalStateStripPreview(goalState, t);
const showGoal = !!stripLabel?.trim();
if (!showTimer && !showGoal) return null;
const objectiveFull = goalState?.objective?.trim() ?? "";
const summaryFull = goalState?.ui_summary?.trim() ?? "";
const canExpandGoal = !!(goalState?.active && (objectiveFull || summaryFull));
const elapsed =
startedAt != null ? Math.max(0, Math.floor(Date.now() / 1000 - startedAt)) : 0;
const m = Math.floor(elapsed / 60);
const s = elapsed % 60;
const shortElapsed = m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`;
const timerTitle = showTimer
? t("thread.composer.runRuntimeTitle", { elapsed: shortElapsed })
: null;
const ariaParts = [timerTitle, showGoal ? stripLabel : null].filter(Boolean);
const ariaLabel = ariaParts.join(" · ");
return (
<>
<div
className="flex min-h-[36px] items-center gap-2 border-b border-black/[0.04] px-3 py-2 dark:border-white/[0.06]"
role="status"
aria-label={ariaLabel}
>
{showTimer ? (
<Activity className="h-4 w-4 shrink-0 text-primary/80" aria-hidden />
) : (
<Target className="h-4 w-4 shrink-0 text-primary/75" aria-hidden />
)}
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[12px] font-medium text-foreground/75">
{timerTitle ? <span className="shrink-0">{timerTitle}</span> : null}
{timerTitle && showGoal ? (
<span className="shrink-0 text-muted-foreground/45" aria-hidden>
·
</span>
) : null}
{showGoal ? (
<span className="truncate">
{t("thread.composer.goalStateStrip", { label: stripLabel })}
</span>
) : null}
</span>
{canExpandGoal ? (
<button
type="button"
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
"text-muted-foreground transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("thread.composer.goalStateExpandAria")}
title={t("thread.composer.goalStateExpandAria")}
onClick={() => setGoalSheetOpen(true)}
>
<ChevronUp className="h-4 w-4" aria-hidden />
</button>
) : null}
</div>
<Sheet open={goalSheetOpen} onOpenChange={setGoalSheetOpen}>
<SheetContent
side="bottom"
showCloseButton
aria-describedby={undefined}
className={cn(
"max-h-[min(85vh,560px)] rounded-t-2xl border-t px-4 pb-6 pt-4",
"gap-3 sm:max-w-lg sm:rounded-t-2xl",
)}
>
<SheetHeader className="space-y-1 text-left">
<SheetTitle>{t("thread.composer.goalStateSheetTitle")}</SheetTitle>
</SheetHeader>
<div className="flex max-h-[min(58vh,420px)] flex-col gap-4 overflow-y-auto pr-0.5 text-[14px] leading-relaxed">
{summaryFull ? (
<section>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("thread.composer.goalStateSummaryHeading")}
</p>
<p className="whitespace-pre-wrap text-foreground/90">{summaryFull}</p>
</section>
) : null}
{objectiveFull ? (
<section>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{t("thread.composer.goalStateObjectiveHeading")}
</p>
<p className="whitespace-pre-wrap text-foreground/90">{objectiveFull}</p>
</section>
) : null}
</div>
</SheetContent>
</Sheet>
</>
);
}
export function ThreadComposer({
onSend,
disabled,
@@ -137,6 +276,8 @@ export function ThreadComposer({
imageMode: controlledImageMode,
onImageModeChange,
onStop,
runStartedAt = null,
goalState,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
@@ -513,6 +654,8 @@ export function ThreadComposer({
"focus-within:ring-1 focus-within:ring-foreground/8",
disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active &&
"thread-goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
)}
>
{images.length > 0 ? (
@@ -543,6 +686,9 @@ export function ThreadComposer({
))}
</div>
) : null}
{runStartedAt != null || goalState?.active ? (
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
) : null}
<textarea
ref={textareaRef}
value={value}
+99 -17
View File
@@ -1,23 +1,90 @@
import { MessageBubble } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
import {
AgentActivityCluster,
isAgentActivityMember,
} from "@/components/thread/AgentActivityCluster";
import type { UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
/** When true, agent turn still in flight — keeps activity cluster expanded. */
isStreaming?: boolean;
}
export function ThreadMessages({ messages }: ThreadMessagesProps) {
export type DisplayUnit =
| { type: "cluster"; messages: UIMessage[] }
| { type: "single"; message: UIMessage };
/** True when this unit index is the last assistant text slice before the next user message (or end of thread). */
export function isFinalAssistantSliceBeforeNextUser(
units: DisplayUnit[],
index: number,
): boolean {
const u = units[index];
if (u.type !== "single" || u.message.role !== "assistant") return true;
for (let j = index + 1; j < units.length; j++) {
const v = units[j];
if (v.type === "single" && v.message.role === "user") break;
return false;
}
return true;
}
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
const out: DisplayUnit[] = [];
let i = 0;
while (i < messages.length) {
const m = messages[i];
if (isAgentActivityMember(m)) {
const cluster: UIMessage[] = [];
while (i < messages.length && isAgentActivityMember(messages[i])) {
cluster.push(messages[i]);
i += 1;
}
out.push({ type: "cluster", messages: cluster });
continue;
}
out.push({ type: "single", message: m });
i += 1;
}
return out;
}
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
const units = buildDisplayUnits(messages);
return (
<div className="flex w-full flex-col">
{messages.map((message, index) => {
const prev = messages[index - 1];
const compact = isAuxiliaryRow(message) && prev && isAuxiliaryRow(prev);
{units.map((unit, index) => {
const prev = units[index - 1];
const marginTop =
index > 0
? marginAfterPrevUnit(prev)
: "";
const next = units[index + 1];
const hasBodyBelow =
unit.type === "cluster"
&& next?.type === "single"
&& next.message.role === "assistant";
return (
<div
key={message.id}
className={cn(index > 0 && (compact ? "mt-2" : "mt-5"))}
>
<MessageBubble message={message} />
<div key={unitKey(unit, index)} className={marginTop}>
{unit.type === "cluster" ? (
<AgentActivityCluster
messages={unit.messages}
isTurnStreaming={isStreaming}
hasBodyBelow={hasBodyBelow}
/>
) : (
<MessageBubble
message={unit.message}
showAssistantCopyAction={
unit.message.role === "assistant"
? isFinalAssistantSliceBeforeNextUser(units, index)
: true
}
/>
)}
</div>
);
})}
@@ -25,13 +92,28 @@ export function ThreadMessages({ messages }: ThreadMessagesProps) {
);
}
function isAuxiliaryRow(message: UIMessage): boolean {
return (
message.kind === "trace"
function unitKey(unit: DisplayUnit, index: number): string {
if (unit.type === "cluster") {
const anchor = unit.messages[0]?.id;
return anchor != null ? `cluster-${anchor}` : `cluster-idx-${index}`;
}
return unit.message.id;
}
function marginAfterPrevUnit(prev: DisplayUnit): string {
if (prev.type === "cluster") {
return "mt-4";
}
const p = prev.message;
const denseP =
p.kind === "trace"
|| (
message.role === "assistant"
&& message.content.trim().length === 0
&& (!!message.reasoning || !!message.reasoningStreaming)
)
p.role === "assistant"
&& p.content.trim().length === 0
&& (!!p.reasoning || !!p.reasoningStreaming)
);
if (denseP) {
return "mt-2";
}
return "mt-5";
}
+63 -19
View File
@@ -21,8 +21,14 @@ import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useN
import { useSessionHistory } from "@/hooks/useSessions";
import { listSlashCommands } from "@/lib/api";
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
}
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
@@ -95,9 +101,13 @@ export function ThreadShell({
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const lastCachedChatIdRef = useRef<string | null>(null);
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
const skipLayoutCacheRef = useRef(false);
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
const initial = useMemo(() => {
if (!chatId) return historical;
@@ -111,12 +121,21 @@ export function ThreadShell({
const {
messages,
isStreaming,
runStartedAt,
goalState,
send,
stop,
setMessages,
streamError,
dismissStreamError,
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
useEffect(() => {
if (chatId && historyKey) sessionKeyByChatIdRef.current.set(chatId, historyKey);
}, [chatId, historyKey]);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const showHeroComposer = messages.length === 0 && !loading;
useEffect(() => {
@@ -134,13 +153,16 @@ export function ThreadShell({
if (hasNewCanonicalHistory && historical.length > 0) {
pendingCanonicalHydrateRef.current.delete(chatId);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
messageCacheRef.current.set(chatId, historical);
return historical;
const normalized = projectWebuiThreadMessages(historical);
messageCacheRef.current.set(chatId, normalized);
return normalized;
}
if (cached && cached.length > 0) return cached;
if (historical.length === 0 && prev.length > 0) return prev;
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
return historical;
const next = projectWebuiThreadMessages(historical);
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical, historyVersion]);
@@ -161,26 +183,44 @@ export function ThreadShell({
useEffect(() => {
if (chatId) return;
setMessages(historical);
setMessages(projectWebuiThreadMessages(historical));
}, [chatId, historical, setMessages]);
useLayoutEffect(() => {
if (chatId) {
const prev = prevChatIdForCacheRef.current;
if (prev && prev !== chatId) {
messageCacheRef.current.set(prev, projectWebuiThreadMessages(messages));
skipLayoutCacheRef.current = true;
}
prevChatIdForCacheRef.current = chatId;
} else {
if (prevChatIdForCacheRef.current) {
messageCacheRef.current.set(
prevChatIdForCacheRef.current,
projectWebuiThreadMessages(messages),
);
skipLayoutCacheRef.current = true;
}
prevChatIdForCacheRef.current = null;
}
}, [chatId, messages]);
// Persist thread to in-memory cache after paint so ``useNanobotStream``'s chat switch
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
useEffect(() => {
if (!chatId) {
lastCachedChatIdRef.current = null;
return;
}
if (loading) return;
// Skip the first cache write after a chat switch. During that render,
// `messages` can still belong to the previous chat until the stream hook
// resets its local state for the new session.
if (lastCachedChatIdRef.current !== chatId) {
lastCachedChatIdRef.current = chatId;
if (messages.length > 0) {
messageCacheRef.current.set(chatId, messages);
}
if (skipLayoutCacheRef.current) {
skipLayoutCacheRef.current = false;
return;
}
messageCacheRef.current.set(chatId, messages);
if (loading) {
return;
}
messageCacheRef.current.set(chatId, projectWebuiThreadMessages(messages));
}, [chatId, loading, messages]);
useEffect(() => {
@@ -296,6 +336,8 @@ export function ThreadShell({
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
runStartedAt={runStartedAt}
goalState={goalState}
/>
) : (
<ThreadComposer
@@ -312,6 +354,8 @@ export function ThreadShell({
slashCommands={slashCommands}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
goalState={goalState}
/>
)}
{showHeroComposer ? quickActions : null}
@@ -341,7 +385,7 @@ export function ThreadShell({
minimal={!session && !loading}
/>
<ThreadViewport
messages={messages}
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
+33 -16
View File
@@ -33,7 +33,8 @@ export function ThreadViewport({
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const scrollFrameIdsRef = useRef<number[]>([]);
const forceBottomUntilRef = useRef(0);
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
const userReadingHistoryRef = useRef(false);
const [atBottom, setAtBottom] = useState(true);
const hasMessages = messages.length > 0;
@@ -56,31 +57,44 @@ export function ThreadViewport({
setAtBottom(true);
}, []);
const scrollToBottom = useCallback((smooth = false, frames = 1) => {
const scrollToBottom = useCallback(
(smooth = false, frames = 1, options?: { force?: boolean }) => {
const force = options?.force ?? false;
cancelScheduledBottomScroll();
const run = () => {
if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth);
};
run();
for (let i = 1; i < frames; i += 1) {
const id = window.requestAnimationFrame(() => scrollToBottomNow(smooth));
const id = window.requestAnimationFrame(() => {
if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth);
});
scrollFrameIdsRef.current.push(id);
}
}, [cancelScheduledBottomScroll, scrollToBottomNow]);
},
[cancelScheduledBottomScroll, scrollToBottomNow],
);
useEffect(() => {
if (!atBottom) return;
scrollToBottom(!isStreaming);
}, [messages, isStreaming, atBottom, scrollToBottom]);
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
// browsers; session switches and history hydration should never slide from top.
scrollToBottom(false);
}, [messages, atBottom, scrollToBottom]);
useEffect(() => {
if (scrollToBottomSignal <= 0) return;
forceBottomUntilRef.current = Date.now() + 2_000;
scrollToBottom(true, 8);
userReadingHistoryRef.current = false;
scrollToBottom(false, 8);
}, [scrollToBottomSignal, scrollToBottom]);
useLayoutEffect(() => {
if (lastConversationKeyRef.current === conversationKey) return;
lastConversationKeyRef.current = conversationKey;
pendingConversationScrollRef.current = true;
forceBottomUntilRef.current = Date.now() + 2_000;
userReadingHistoryRef.current = false;
setAtBottom(true);
}, [conversationKey]);
@@ -102,12 +116,12 @@ export function ThreadViewport({
const target = contentRef.current;
if (!target || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (!atBottom && Date.now() > forceBottomUntilRef.current) return;
if (userReadingHistoryRef.current) return;
scrollToBottom(false, 4);
});
observer.observe(target);
return () => observer.disconnect();
}, [atBottom, hasMessages, scrollToBottom]);
}, [hasMessages, scrollToBottom]);
useEffect(() => {
const el = scrollRef.current;
@@ -115,7 +129,9 @@ export function ThreadViewport({
const onScroll = () => {
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setAtBottom(distance < NEAR_BOTTOM_PX);
const near = distance < NEAR_BOTTOM_PX;
setAtBottom(near);
userReadingHistoryRef.current = !near;
};
onScroll();
@@ -128,7 +144,7 @@ export function ThreadViewport({
<div
ref={scrollRef}
className={cn(
"absolute inset-0 overflow-y-auto scroll-smooth scrollbar-thin",
"absolute inset-0 overflow-y-auto scroll-auto scrollbar-thin",
"[&::-webkit-scrollbar]:w-1.5",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
@@ -139,7 +155,7 @@ export function ThreadViewport({
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div className="flex-1 px-4 pb-20 pt-4">
<div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages messages={messages} />
<ThreadMessages messages={messages} isStreaming={isStreaming} />
</div>
</div>
@@ -171,9 +187,10 @@ export function ThreadViewport({
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true)}
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
"absolute bottom-28 left-1/2 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
"absolute bottom-48 left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
+1 -1
View File
@@ -12,7 +12,7 @@ const ScrollArea = React.forwardRef<
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
<ScrollAreaPrimitive.Viewport className="h-full w-full min-w-0 rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
+80 -13
View File
@@ -117,31 +117,92 @@
--cjk-line-height: 1.625;
}
/* Shimmer band sweeping across the reasoning header while
``reasoning_delta`` frames are arriving. Pure CSS, no JS animation,
respects ``prefers-reduced-motion``. */
@keyframes reasoning-shimmer-sweep {
/* LR sheen over solid label text (overlay stripe). Avoids ``background-clip:
text`` loop seams that read as RTL erase or one-frame transparent glyphs. */
@keyframes reasoning-sheen-ltr {
0% {
background-position: -200% 0;
left: -44%;
}
100% {
background-position: 200% 0;
left: 118%;
}
}
.reasoning-shimmer {
background-image: linear-gradient(
.reasoning-sheen-track {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
border-radius: 2px;
pointer-events: none;
}
.reasoning-sheen-stripe {
position: absolute;
top: 0;
bottom: 0;
width: 44%;
min-width: 3.25rem;
left: -44%;
border-radius: inherit;
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--muted-foreground) / 0.18) 50%,
hsl(0 0% 100% / 0.07) 34%,
hsl(0 0% 100% / 0.76) 50%,
hsl(0 0% 100% / 0.07) 66%,
transparent 100%
);
background-size: 200% 100%;
background-repeat: no-repeat;
animation: reasoning-shimmer-sweep 2.2s linear infinite;
mix-blend-mode: soft-light;
opacity: 0.95;
animation: reasoning-sheen-ltr 5.2s linear infinite;
}
.dark .reasoning-sheen-stripe {
mix-blend-mode: overlay;
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.reasoning-shimmer {
.reasoning-sheen-stripe {
animation: none;
opacity: 0;
visibility: hidden;
}
}
/** Goal halo: pale sky blue (not ``--primary``, which often reads as neutral gray). */
@keyframes thread-goal-glow-breathe {
0%,
100% {
filter: drop-shadow(0 0 10px hsl(204 72% 52% / 0.22))
drop-shadow(0 0 24px hsl(199 80% 58% / 0.14));
}
50% {
filter: drop-shadow(0 0 17px hsl(204 78% 48% / 0.32))
drop-shadow(0 0 38px hsl(199 85% 55% / 0.2));
}
}
.thread-goal-shell-glow {
animation: thread-goal-glow-breathe 4.8s ease-in-out infinite;
}
@keyframes thread-goal-glow-breathe-dark {
0%,
100% {
filter: drop-shadow(0 0 12px hsl(198 90% 72% / 0.28))
drop-shadow(0 0 28px hsl(195 95% 65% / 0.16));
}
50% {
filter: drop-shadow(0 0 20px hsl(198 95% 78% / 0.42))
drop-shadow(0 0 42px hsl(195 100% 70% / 0.24));
}
}
.dark .thread-goal-shell-glow {
animation-name: thread-goal-glow-breathe-dark;
}
@media (prefers-reduced-motion: reduce) {
.thread-goal-shell-glow {
animation: none;
filter: drop-shadow(0 0 14px hsl(204 70% 50% / 0.24));
}
.dark .thread-goal-shell-glow {
filter: drop-shadow(0 0 14px hsl(198 88% 70% / 0.32));
}
}
@@ -158,4 +219,10 @@
background-color: hsl(var(--muted-foreground) / 0.4);
border-radius: 9999px;
}
.scrollbar-track-transparent {
scrollbar-gutter: stable;
}
.scrollbar-track-transparent::-webkit-scrollbar-track {
background: transparent;
}
}
+53 -4
View File
@@ -8,6 +8,7 @@ import type {
InboundEvent,
OutboundImageGeneration,
OutboundMedia,
GoalStateWsPayload,
UIImage,
UIMessage,
} from "@/lib/types";
@@ -134,6 +135,17 @@ function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
});
}
function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.role === "assistant" && m.kind !== "trace") {
const merged: UIMessage = { ...m, latencyMs, isStreaming: false };
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
}
return prev;
}
function absorbCompleteAssistantMessage(
prev: UIMessage[],
message: Omit<UIMessage, "id" | "role" | "createdAt">,
@@ -164,7 +176,7 @@ function absorbCompleteAssistantMessage(
/**
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
* a streaming flag, and a ``send`` function. Initial history must be seeded
* separately (e.g. via ``fetchSessionMessages``) since the server only replays
* separately (e.g. via ``fetchWebuiThread``) since the server only replays
* live events.
*/
/** Payload passed to ``send`` when the user attaches one or more images.
@@ -190,6 +202,10 @@ export function useNanobotStream(
): {
messages: UIMessage[];
isStreaming: boolean;
/** Unix epoch seconds when the current user turn started (WebSocket ``goal_status``). */
runStartedAt: number | null;
/** Latest sustained goal for this ``chatId`` (``goal_state`` WS events). */
goalState: GoalStateWsPayload | undefined;
send: (content: string, images?: SendImage[], options?: SendOptions) => void;
stop: () => void;
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
@@ -209,6 +225,9 @@ export function useNanobotStream(
? initialMessages[initialMessages.length - 1].kind === "trace"
: false;
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
const [runStartedAt, setRunStartedAt] = useState<number | null>(null);
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null);
const suppressStreamUntilTurnEndRef = useRef(false);
@@ -238,6 +257,8 @@ export function useNanobotStream(
: false) || hasPendingToolCalls,
);
setStreamError(null);
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
buffer.current = null;
suppressStreamUntilTurnEndRef.current = false;
if (streamEndTimerRef.current !== null) {
@@ -245,7 +266,7 @@ export function useNanobotStream(
streamEndTimerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId]);
}, [chatId, client]);
useEffect(() => {
if (hasPendingToolCalls) setIsStreaming(true);
@@ -332,7 +353,24 @@ export function useNanobotStream(
return;
}
if (ev.event === "goal_state") {
setGoalState(ev.goal_state);
return;
}
if (ev.event === "goal_status") {
if (ev.status === "running" && typeof ev.started_at === "number") {
setRunStartedAt(ev.started_at);
} else {
setRunStartedAt(null);
}
return;
}
if (ev.event === "turn_end") {
if ("goal_state" in ev && ev.goal_state != null && typeof ev.goal_state === "object") {
setGoalState(ev.goal_state);
}
// Definitive signal that the turn is fully complete. Cancel any
// pending debounce timer and stop the loading indicator immediately.
if (streamEndTimerRef.current !== null) {
@@ -341,8 +379,12 @@ export function useNanobotStream(
}
setIsStreaming(false);
setMessages((prev) => {
const finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
return pruneReasoningOnlyPlaceholders(finalized);
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
finalized = pruneReasoningOnlyPlaceholders(finalized);
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
}
return finalized;
});
suppressStreamUntilTurnEndRef.current = false;
onTurnEnd?.();
@@ -415,9 +457,14 @@ export function useNanobotStream(
setMessages((prev) => {
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
const content = ev.text;
const lat =
typeof ev.latency_ms === "number" && ev.latency_ms >= 0
? Math.round(ev.latency_ms)
: undefined;
return absorbCompleteAssistantMessage(filtered, {
content,
...(hasMedia ? { media } : {}),
...(lat !== undefined ? { latencyMs: lat } : {}),
});
});
if (hasMedia) {
@@ -485,6 +532,8 @@ export function useNanobotStream(
return {
messages,
isStreaming,
runStartedAt,
goalState,
send,
stop,
setMessages,
+20 -81
View File
@@ -5,40 +5,14 @@ import i18n from "@/i18n";
import {
ApiError,
deleteSession as apiDeleteSession,
fetchSessionMessages,
fetchWebuiThread,
listSessions,
} from "@/lib/api";
import { deriveTitle } from "@/lib/format";
import { toMediaAttachment } from "@/lib/media";
import { formatToolCallTrace } from "@/lib/tool-traces";
import type { ChatSummary, UIMessage } from "@/lib/types";
const EMPTY_MESSAGES: UIMessage[] = [];
type HistoryMessage = Awaited<ReturnType<typeof fetchSessionMessages>>["messages"][number];
function reasoningFromHistory(message: HistoryMessage): string | undefined {
if (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) {
return message.reasoning_content;
}
if (!Array.isArray(message.thinking_blocks)) return undefined;
const parts = message.thinking_blocks
.map((block) => {
if (!block || typeof block !== "object") return "";
const thinking = (block as { thinking?: unknown }).thinking;
return typeof thinking === "string" ? thinking.trim() : "";
})
.filter(Boolean);
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
function toolTracesFromHistory(message: HistoryMessage): string[] {
if (!Array.isArray(message.tool_calls)) return [];
return message.tool_calls
.map(formatToolCallTrace)
.filter((trace): trace is string => !!trace);
}
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
export function useSessions(): {
sessions: ChatSummary[];
@@ -118,8 +92,7 @@ export function useSessionHistory(key: string | null): {
error: string | null;
refresh: () => void;
version: number;
/** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
* final text yet the model was still processing when the page loaded. */
/** ``true`` when the replayed transcript ends with a trace row (turn still in flight). */
hasPendingToolCalls: boolean;
} {
const { token } = useClient();
@@ -170,58 +143,26 @@ export function useSessionHistory(key: string | null): {
});
(async () => {
try {
const body = await fetchSessionMessages(token, key);
const body = await fetchWebuiThread(token, key);
if (cancelled) return;
const ui: UIMessage[] = body.messages.flatMap((m, idx) => {
if (m.role !== "user" && m.role !== "assistant") return [];
if (typeof m.content !== "string") return [];
// Hydrate signed media URLs into generic UI attachments. Image-only
// user turns still populate the legacy ``images`` slot so the
// existing optimistic-send and lightbox paths remain unchanged.
const media =
Array.isArray(m.media_urls) && m.media_urls.length > 0
? m.media_urls.map((mu) => toMediaAttachment(mu))
: undefined;
const images =
m.role === "user" && media?.every((item) => item.kind === "image")
? media.map((item) => ({ url: item.url, name: item.name }))
: undefined;
const row: UIMessage = {
id: `hist-${idx}`,
role: m.role,
content: m.content,
createdAt: m.timestamp ? Date.parse(m.timestamp) : Date.now(),
...(images ? { images } : {}),
...(media ? { media } : {}),
...(m.role === "assistant" && reasoningFromHistory(m)
? { reasoning: reasoningFromHistory(m), reasoningStreaming: false }
: {}),
};
const traces = m.role === "assistant" ? toolTracesFromHistory(m) : [];
if (traces.length === 0) {
return row.content.trim() || row.media?.length ? [row] : [];
if (!body?.messages?.length) {
setState((prev) => ({
key,
messages: [],
loading: false,
error: null,
hasPendingToolCalls: false,
version: prev.key === key ? prev.version + 1 : 1,
}));
return;
}
return [
...(row.content.trim() || row.reasoning || row.media?.length ? [row] : []),
{
id: `hist-${idx}-tools`,
role: "tool" as const,
kind: "trace" as const,
content: traces[traces.length - 1],
traces,
createdAt: m.timestamp ? Date.parse(m.timestamp) : Date.now(),
},
];
});
// Tool result rows can trail the assistant tool-call row while the turn
// is still running, so check the last conversational row.
const lastRaw = [...body.messages]
.reverse()
.find((m) => m.role === "user" || m.role === "assistant");
const hasPending =
lastRaw?.role === "assistant" &&
Array.isArray(lastRaw.tool_calls) &&
lastRaw.tool_calls.length > 0;
const ui: UIMessage[] = body.messages.map((m, idx) => ({
...m,
id: m.id ?? `hist-${idx}`,
createdAt: typeof m.createdAt === "number" ? m.createdAt : Date.now(),
}));
const last = ui[ui.length - 1];
const hasPending = last?.kind === "trace";
setState((prev) => ({
key,
messages: ui,
@@ -232,8 +173,6 @@ export function useSessionHistory(key: string | null): {
}));
} catch (e) {
if (cancelled) return;
// A 404 just means the session hasn't been persisted yet (brand-new
// chat, first message not sent). That's a normal state, not an error.
if (e instanceof ApiError && e.status === 404) {
setState((prev) => ({
key,
+22 -1
View File
@@ -244,6 +244,13 @@
"placeholderStreaming": "Model is responding…",
"inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline",
"runRuntimeTitle": "Running · {{elapsed}}",
"goalStateStrip": "Goal · {{label}}",
"goalStateFallback": "Goal",
"goalStateExpandAria": "Show full goal",
"goalStateSheetTitle": "Thread goal",
"goalStateSummaryHeading": "Summary",
"goalStateObjectiveHeading": "Objective",
"send": "Send message",
"stop": "Stop response",
"attachImage": "Attach image",
@@ -307,6 +314,10 @@
"title": "Restore memory",
"description": "Revert memory to a previous Dream snapshot."
},
"goal": {
"title": "Long-running goal",
"description": "Tell the agent to treat this as a sustained multi-step goal."
},
"help": {
"title": "Show help",
"description": "List available slash commands."
@@ -332,11 +343,21 @@
"assistantTyping": "Assistant is typing",
"toolSingle": "Using a tool",
"toolMany": "Used {{count}} tools",
"toolSummary": "{{count}} tool",
"toolSummaryMany": "{{count}} tools",
"reasoningTools": "Reasoning · {{count}} tools",
"reasoningToolsSingular": "Reasoning · 1 tool",
"reasoning": "Thinking",
"reasoningStreaming": "Thinking…",
"reasoningSummary": "Reasoning",
"agentActivitySummary": "{{reasoning}} steps · {{tools}} tool calls",
"agentActivityToolsOnly": "{{tools}} tool calls",
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
"imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply"
"copiedReply": "Copied reply",
"turnLatencyTitle": "Response time (end-to-end)"
},
"lightbox": {
"title": "Image preview",
+24 -1
View File
@@ -218,6 +218,13 @@
"placeholderStreaming": "El modelo está respondiendo…",
"inputAria": "Entrada de mensaje",
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
"runRuntimeTitle": "En ejecución · {{elapsed}}",
"goalStateStrip": "Objetivo · {{label}}",
"goalStateFallback": "Objetivo",
"goalStateExpandAria": "Ver objetivo completo",
"goalStateSheetTitle": "Objetivo del hilo",
"goalStateSummaryHeading": "Resumen",
"goalStateObjectiveHeading": "Objetivo",
"send": "Enviar mensaje",
"stop": "Detener respuesta",
"attachImage": "Adjuntar imagen",
@@ -286,6 +293,10 @@
"title": "Restaurar memoria",
"description": "Revierte la memoria a una instantánea Dream anterior."
},
"goal": {
"title": "Objetivo a largo plazo",
"description": "Indica al agente que trate esto como un objetivo sostenido en varios pasos."
},
"help": {
"title": "Mostrar ayuda",
"description": "Lista los comandos slash disponibles."
@@ -300,7 +311,19 @@
"assistantTyping": "El asistente está escribiendo",
"toolSingle": "Usando una herramienta",
"toolMany": "Se usaron {{count}} herramientas",
"imageAttachment": "Imagen adjunta"
"toolSummary": "{{count}} herramienta",
"toolSummaryMany": "{{count}} herramientas",
"reasoningTools": "Razonamiento · {{count}} herramientas",
"reasoningToolsSingular": "Razonamiento · 1 herramienta",
"reasoning": "Razonamiento",
"reasoningStreaming": "Pensando…",
"reasoningSummary": "Razonamiento",
"agentActivitySummary": "{{reasoning}} pasos · {{tools}} llamadas a herramientas",
"agentActivityToolsOnly": "{{tools}} llamadas a herramientas",
"agentActivityLiveSummary": "En curso… · {{reasoning}} pasos · {{tools}} llamadas a herramientas",
"agentActivityLiveToolsOnly": "En curso… · {{tools}} llamadas a herramientas",
"imageAttachment": "Imagen adjunta",
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)"
},
"lightbox": {
"title": "Vista previa de imagen",
+24 -1
View File
@@ -218,6 +218,13 @@
"placeholderStreaming": "Le modèle est en train de répondre…",
"inputAria": "Champ de message",
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
"runRuntimeTitle": "Exécution · {{elapsed}}",
"goalStateStrip": "Objectif · {{label}}",
"goalStateFallback": "Objectif",
"goalStateExpandAria": "Afficher lobjectif complet",
"goalStateSheetTitle": "Objectif du fil",
"goalStateSummaryHeading": "Résumé",
"goalStateObjectiveHeading": "Objectif",
"send": "Envoyer le message",
"stop": "Arrêter la réponse",
"attachImage": "Joindre une image",
@@ -286,6 +293,10 @@
"title": "Restaurer la mémoire",
"description": "Revenir à un instantané Dream précédent."
},
"goal": {
"title": "Objectif long terme",
"description": "Demandez à lagent de traiter ceci comme un objectif multi‑étapes durable."
},
"help": {
"title": "Afficher laide",
"description": "Lister les commandes slash disponibles."
@@ -300,7 +311,19 @@
"assistantTyping": "Lassistant est en train d’écrire",
"toolSingle": "Utilisation dun outil",
"toolMany": "{{count}} outils utilisés",
"imageAttachment": "Pièce jointe image"
"toolSummary": "{{count}} outil",
"toolSummaryMany": "{{count}} outils",
"reasoningTools": "Raisonnement · {{count}} outils",
"reasoningToolsSingular": "Raisonnement · 1 outil",
"reasoning": "Raisonnement",
"reasoningStreaming": "En réflexion…",
"reasoningSummary": "Raisonnement",
"agentActivitySummary": "{{reasoning}} étapes · {{tools}} appels doutils",
"agentActivityToolsOnly": "{{tools}} appels doutils",
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels doutils",
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels doutils",
"imageAttachment": "Pièce jointe image",
"turnLatencyTitle": "Temps de réponse (de bout en bout)"
},
"lightbox": {
"title": "Aperçu de limage",
+24 -1
View File
@@ -218,6 +218,13 @@
"placeholderStreaming": "Model sedang merespons…",
"inputAria": "Input pesan",
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
"runRuntimeTitle": "Berjalan · {{elapsed}}",
"goalStateStrip": "Tujuan · {{label}}",
"goalStateFallback": "Tujuan",
"goalStateExpandAria": "Lihat tujuan lengkap",
"goalStateSheetTitle": "Tujuan thread",
"goalStateSummaryHeading": "Ringkasan",
"goalStateObjectiveHeading": "Tujuan",
"send": "Kirim pesan",
"stop": "Hentikan respons",
"attachImage": "Lampirkan gambar",
@@ -286,6 +293,10 @@
"title": "Pulihkan memori",
"description": "Kembalikan memori ke snapshot Dream sebelumnya."
},
"goal": {
"title": "Tujuan jangka panjang",
"description": "Instruksikan agen memperlakukan ini sebagai tujuan multi-langkah yang berkelanjutan."
},
"help": {
"title": "Tampilkan bantuan",
"description": "Daftar perintah slash yang tersedia."
@@ -300,7 +311,19 @@
"assistantTyping": "Asisten sedang mengetik",
"toolSingle": "Menggunakan sebuah alat",
"toolMany": "Menggunakan {{count}} alat",
"imageAttachment": "Lampiran gambar"
"toolSummary": "{{count}} alat",
"toolSummaryMany": "{{count}} alat",
"reasoningTools": "Penalaran · {{count}} alat",
"reasoningToolsSingular": "Penalaran · 1 alat",
"reasoning": "Penalaran",
"reasoningStreaming": "Berpikir…",
"reasoningSummary": "Penalaran",
"agentActivitySummary": "{{reasoning}} langkah · {{tools}} panggilan alat",
"agentActivityToolsOnly": "{{tools}} panggilan alat",
"agentActivityLiveSummary": "Berjalan… · {{reasoning}} langkah · {{tools}} panggilan alat",
"agentActivityLiveToolsOnly": "Berjalan… · {{tools}} panggilan alat",
"imageAttachment": "Lampiran gambar",
"turnLatencyTitle": "Waktu respons (ujung ke ujung)"
},
"lightbox": {
"title": "Pratinjau gambar",
+24 -1
View File
@@ -218,6 +218,13 @@
"placeholderStreaming": "モデルが応答しています…",
"inputAria": "メッセージ入力欄",
"sendHint": "Enter で送信 · Shift+Enter で改行",
"runRuntimeTitle": "実行中 · {{elapsed}}",
"goalStateStrip": "目標 · {{label}}",
"goalStateFallback": "目標",
"goalStateExpandAria": "目標の全文を表示",
"goalStateSheetTitle": "スレッドの目標",
"goalStateSummaryHeading": "要約",
"goalStateObjectiveHeading": "目的",
"send": "メッセージを送信",
"stop": "応答を停止",
"attachImage": "画像を添付",
@@ -286,6 +293,10 @@
"title": "メモリを復元",
"description": "以前の Dream スナップショットへメモリを戻します。"
},
"goal": {
"title": "長期目標",
"description": "持続的な複数ステップの目標として扱うようエージェントに伝えます。"
},
"help": {
"title": "ヘルプを表示",
"description": "利用可能なスラッシュコマンドを一覧表示します。"
@@ -300,7 +311,19 @@
"assistantTyping": "アシスタントが入力中",
"toolSingle": "ツールを使用中",
"toolMany": "{{count}} 個のツールを使用",
"imageAttachment": "画像の添付"
"toolSummary": "{{count}} 個のツール",
"toolSummaryMany": "{{count}} 個のツール",
"reasoningTools": "思考 · {{count}} 個のツール",
"reasoningToolsSingular": "思考 · 1 個のツール",
"reasoning": "思考",
"reasoningStreaming": "思考中…",
"reasoningSummary": "思考",
"agentActivitySummary": "{{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
"agentActivityToolsOnly": "ツール呼び出し {{tools}} 回",
"agentActivityLiveSummary": "実行中… · {{reasoning}} ステップ · ツール呼び出し {{tools}} 回",
"agentActivityLiveToolsOnly": "実行中… · ツール呼び出し {{tools}} 回",
"imageAttachment": "画像の添付",
"turnLatencyTitle": "応答時間(全行程)"
},
"lightbox": {
"title": "画像プレビュー",
+24 -1
View File
@@ -218,6 +218,13 @@
"placeholderStreaming": "모델이 응답 중입니다…",
"inputAria": "메시지 입력",
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
"runRuntimeTitle": "실행 중 · {{elapsed}}",
"goalStateStrip": "목표 · {{label}}",
"goalStateFallback": "목표",
"goalStateExpandAria": "전체 목표 보기",
"goalStateSheetTitle": "스레드 목표",
"goalStateSummaryHeading": "요약",
"goalStateObjectiveHeading": "목표 설명",
"send": "메시지 보내기",
"stop": "응답 중지",
"attachImage": "이미지 첨부",
@@ -286,6 +293,10 @@
"title": "메모리 복원",
"description": "이전 Dream 스냅샷으로 메모리를 되돌립니다."
},
"goal": {
"title": "장기 목표",
"description": "에이전트에게 지속적인 다단계 목표로 처리하도록 지시합니다."
},
"help": {
"title": "도움말 보기",
"description": "사용 가능한 슬래시 명령을 나열합니다."
@@ -300,7 +311,19 @@
"assistantTyping": "도우미가 입력 중",
"toolSingle": "도구 사용 중",
"toolMany": "도구 {{count}}개 사용됨",
"imageAttachment": "이미지 첨부"
"toolSummary": "도구 {{count}}개",
"toolSummaryMany": "도구 {{count}}개",
"reasoningTools": "추론 · 도구 {{count}}개",
"reasoningToolsSingular": "추론 · 도구 1개",
"reasoning": "추론",
"reasoningStreaming": "추론 중…",
"reasoningSummary": "추론",
"agentActivitySummary": "{{reasoning}}단계 · 도구 호출 {{tools}}회",
"agentActivityToolsOnly": "도구 호출 {{tools}}회",
"agentActivityLiveSummary": "진행 중… · {{reasoning}}단계 · 도구 호출 {{tools}}회",
"agentActivityLiveToolsOnly": "진행 중… · 도구 호출 {{tools}}회",
"imageAttachment": "이미지 첨부",
"turnLatencyTitle": "응답 시간(엔드투엔드)"
},
"lightbox": {
"title": "이미지 미리보기",
+24 -1
View File
@@ -218,6 +218,13 @@
"placeholderStreaming": "Mô hình đang trả lời…",
"inputAria": "Ô nhập tin nhắn",
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
"runRuntimeTitle": "Đang chạy · {{elapsed}}",
"goalStateStrip": "Mục tiêu · {{label}}",
"goalStateFallback": "Mục tiêu",
"goalStateExpandAria": "Xem đầy đủ mục tiêu",
"goalStateSheetTitle": "Mục tiêu luồng",
"goalStateSummaryHeading": "Tóm tắt",
"goalStateObjectiveHeading": "Mục tiêu",
"send": "Gửi tin nhắn",
"stop": "Dừng phản hồi",
"attachImage": "Đính kèm ảnh",
@@ -286,6 +293,10 @@
"title": "Khôi phục bộ nhớ",
"description": "Đưa bộ nhớ về một snapshot Dream trước đó."
},
"goal": {
"title": "Mục tiêu dài hạn",
"description": "Yêu cầu agent xử lý đây là mục tiêu nhiều bước kéo dài."
},
"help": {
"title": "Hiển thị trợ giúp",
"description": "Liệt kê các lệnh slash có sẵn."
@@ -300,7 +311,19 @@
"assistantTyping": "Trợ lý đang nhập",
"toolSingle": "Đang dùng một công cụ",
"toolMany": "Đã dùng {{count}} công cụ",
"imageAttachment": "Tệp hình ảnh đính kèm"
"toolSummary": "{{count}} công cụ",
"toolSummaryMany": "{{count}} công cụ",
"reasoningTools": "Suy luận · {{count}} công cụ",
"reasoningToolsSingular": "Suy luận · 1 công cụ",
"reasoning": "Suy luận",
"reasoningStreaming": "Đang suy nghĩ…",
"reasoningSummary": "Suy luận",
"agentActivitySummary": "{{reasoning}} bước · {{tools}} lần gọi công cụ",
"agentActivityToolsOnly": "{{tools}} lần gọi công cụ",
"agentActivityLiveSummary": "Đang chạy… · {{reasoning}} bước · {{tools}} lần gọi công cụ",
"agentActivityLiveToolsOnly": "Đang chạy… · {{tools}} lần gọi công cụ",
"imageAttachment": "Tệp hình ảnh đính kèm",
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)"
},
"lightbox": {
"title": "Xem trước ảnh",
+22 -1
View File
@@ -232,6 +232,13 @@
"placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行",
"runRuntimeTitle": "运行中 · {{elapsed}}",
"goalStateStrip": "目标 · {{label}}",
"goalStateFallback": "目标",
"goalStateExpandAria": "查看完整目标",
"goalStateSheetTitle": "会话目标",
"goalStateSummaryHeading": "摘要",
"goalStateObjectiveHeading": "目标描述",
"send": "发送消息",
"stop": "停止响应",
"attachImage": "添加图片",
@@ -295,6 +302,10 @@
"title": "恢复记忆",
"description": "将记忆恢复到之前的 Dream 快照。"
},
"goal": {
"title": "长期目标",
"description": "让助手把当前请求当作需要多步骤持续推进的目标。"
},
"help": {
"title": "查看帮助",
"description": "列出可用的斜杠命令。"
@@ -320,11 +331,21 @@
"assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具",
"toolSummary": "{{count}} 个工具",
"toolSummaryMany": "{{count}} 个工具",
"reasoningTools": "推理 · {{count}} 个工具",
"reasoningToolsSingular": "推理 · 1 个工具",
"reasoning": "思考过程",
"reasoningStreaming": "正在思考…",
"reasoningSummary": "推理",
"agentActivitySummary": "{{reasoning}} 步 · {{tools}} 次工具调用",
"agentActivityToolsOnly": "{{tools}} 次工具调用",
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
"imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复"
"copiedReply": "已复制回复",
"turnLatencyTitle": "本轮耗时(端到端)"
},
"lightbox": {
"title": "图片预览",
+24 -1
View File
@@ -218,6 +218,13 @@
"placeholderStreaming": "模型正在回覆…",
"inputAria": "訊息輸入框",
"sendHint": "Enter 送出 · Shift+Enter 換行",
"runRuntimeTitle": "執行中 · {{elapsed}}",
"goalStateStrip": "目標 · {{label}}",
"goalStateFallback": "目標",
"goalStateExpandAria": "查看完整目標",
"goalStateSheetTitle": "對話目標",
"goalStateSummaryHeading": "摘要",
"goalStateObjectiveHeading": "目標描述",
"send": "送出訊息",
"stop": "停止回覆",
"attachImage": "附加圖片",
@@ -286,6 +293,10 @@
"title": "恢復記憶",
"description": "將記憶恢復到之前的 Dream 快照。"
},
"goal": {
"title": "長期目標",
"description": "請助理把這則請求當成需要多步驟持續推進的目標。"
},
"help": {
"title": "查看說明",
"description": "列出可用的斜線命令。"
@@ -300,7 +311,19 @@
"assistantTyping": "助理正在輸入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 個工具",
"imageAttachment": "圖片附件"
"toolSummary": "{{count}} 個工具",
"toolSummaryMany": "{{count}} 個工具",
"reasoningTools": "推理 · {{count}} 個工具",
"reasoningToolsSingular": "推理 · 1 個工具",
"reasoning": "思考過程",
"reasoningStreaming": "正在思考…",
"reasoningSummary": "推理",
"agentActivitySummary": "{{reasoning}} 步 · {{tools}} 次工具呼叫",
"agentActivityToolsOnly": "{{tools}} 次工具呼叫",
"agentActivityLiveSummary": "進行中… · {{reasoning}} 步 · {{tools}} 次工具呼叫",
"agentActivityLiveToolsOnly": "進行中… · {{tools}} 次工具呼叫",
"imageAttachment": "圖片附件",
"turnLatencyTitle": "本輪耗時(端到端)"
},
"lightbox": {
"title": "圖片預覽",
+12 -33
View File
@@ -5,6 +5,7 @@ import type {
SettingsUpdate,
SlashCommand,
WebSearchSettingsUpdate,
WebuiThreadPersistedPayload,
} from "./types";
export class ApiError extends Error {
@@ -66,42 +67,20 @@ export async function listSessions(
}));
}
/** Signed image URL attached to a historical user message. The server
* emits these in place of raw on-disk paths so the client can render
* previews without learning where media lives on disk. Each URL is a
* self-authenticating ``/api/media/...`` route (see backend
* ``_sign_media_path``) safe to drop into an ``<img src>`` attribute. */
export interface SessionMediaUrl {
url: string;
name?: string;
}
export async function fetchSessionMessages(
/** Disk-backed WebUI display thread snapshot (separate from agent session). */
export async function fetchWebuiThread(
token: string,
key: string,
base: string = "",
): Promise<{
key: string;
created_at: string | null;
updated_at: string | null;
messages: Array<{
role: string;
content: string;
timestamp?: string;
tool_calls?: unknown;
reasoning_content?: string | null;
thinking_blocks?: unknown;
tool_call_id?: string;
name?: string;
/** Present on ``user`` turns that attached images. Paths have already
* been stripped server-side; only the signed fetch URLs survive. */
media_urls?: SessionMediaUrl[];
}>;
}> {
return request(
`${base}/api/sessions/${encodeURIComponent(key)}/messages`,
token,
);
): Promise<WebuiThreadPersistedPayload | null> {
const url = `${base}/api/sessions/${encodeURIComponent(key)}/webui-thread`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
credentials: "same-origin",
});
if (res.status === 404) return null;
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
return (await res.json()) as WebuiThreadPersistedPayload;
}
export async function deleteSession(
+30
View File
@@ -75,3 +75,33 @@ export function fmtDateTime(
const date = parseDate(value);
return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
}
/** Human-readable turn duration (wall-clock), locale-aware via ``Intl`` (seconds/minutes). */
export function formatTurnLatency(ms: number, locale?: string): string {
const loc = activeLocale(locale);
const msClamped = Math.max(0, ms);
const secTotal = msClamped / 1000;
if (secTotal < 60) {
return new Intl.NumberFormat(loc, {
style: "unit",
unit: "second",
unitDisplay: "narrow",
maximumFractionDigits: secTotal < 10 ? 1 : 0,
minimumFractionDigits: 0,
}).format(secTotal);
}
const wholeMin = Math.floor(secTotal / 60);
const remSec = Math.max(0, Math.round(secTotal - wholeMin * 60));
const minStr = new Intl.NumberFormat(loc, {
style: "unit",
unit: "minute",
unitDisplay: "narrow",
}).format(wholeMin);
const secStr = new Intl.NumberFormat(loc, {
style: "unit",
unit: "second",
unitDisplay: "narrow",
maximumFractionDigits: 0,
}).format(remSec);
return `${minStr}\u00a0${secStr}`;
}
+67 -3
View File
@@ -4,6 +4,7 @@ import type {
Outbound,
OutboundImageGeneration,
OutboundMedia,
GoalStateWsPayload,
} from "./types";
/** WebSocket readyState constants, referenced by value to stay portable
@@ -65,8 +66,15 @@ export class NanobotClient {
private errorHandlers = new Set<ErrorHandler>();
// chat_id -> handlers listening on it
private chatHandlers = new Map<string, Set<EventHandler>>();
/** Inbound frames received while no subscriber is registered (e.g. user switched away). */
private pendingInboundByChat = new Map<string, InboundEvent[]>();
private static readonly PENDING_INBOUND_MAX = 2000;
// chat_ids we've attached to since connect; re-attached after reconnects
private knownChats = new Set<string>();
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
private runStartedAtByChatId = new Map<string, number>();
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
private pendingNewChat: PendingNewChat | null = null;
// Frames queued while the socket is not yet OPEN
private sendQueue: Outbound[] = [];
@@ -133,6 +141,36 @@ export class NanobotClient {
};
}
/** Last ``goal_status`` ``started_at`` (unix sec) for *chatId*, if the turn is running. */
getRunStartedAt(chatId: string): number | null {
const v = this.runStartedAtByChatId.get(chatId);
return v === undefined ? null : v;
}
/** Last ``goal_state`` payload for *chatId*, if any frame has arrived this connection. */
getGoalState(chatId: string): GoalStateWsPayload | undefined {
return this.goalStateByChatId.get(chatId);
}
private recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent): void {
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
this.runStartedAtByChatId.set(chatId, ev.started_at);
} else {
this.runStartedAtByChatId.delete(chatId);
}
}
private recordGoalStateSnapshot(chatId: string, ev: InboundEvent): void {
if (ev.event === "goal_state") {
this.goalStateByChatId.set(chatId, ev.goal_state);
return;
}
if (ev.event === "turn_end" && ev.goal_state != null && typeof ev.goal_state === "object") {
this.goalStateByChatId.set(chatId, ev.goal_state);
}
}
/** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
onChat(chatId: string, handler: EventHandler): Unsubscribe {
let handlers = this.chatHandlers.get(chatId);
@@ -141,6 +179,14 @@ export class NanobotClient {
this.chatHandlers.set(chatId, handlers);
}
handlers.add(handler);
const pending = this.pendingInboundByChat.get(chatId);
if (pending !== undefined && pending.length > 0) {
const flushed = pending.splice(0);
this.pendingInboundByChat.delete(chatId);
for (const ev of flushed) {
handler(ev);
}
}
this.attach(chatId);
return () => {
const current = this.chatHandlers.get(chatId);
@@ -274,7 +320,11 @@ export class NanobotClient {
}
const chatId = (parsed as { chat_id?: string }).chat_id;
if (chatId) this.dispatch(chatId, parsed);
if (chatId) {
this.recordGoalStatusForRunStrip(chatId, parsed);
this.recordGoalStateSnapshot(chatId, parsed);
this.dispatch(chatId, parsed);
}
}
private emitRuntimeModelUpdate(modelName: string | null, modelPreset?: string | null): void {
@@ -291,8 +341,22 @@ export class NanobotClient {
private dispatch(chatId: string, ev: InboundEvent): void {
const handlers = this.chatHandlers.get(chatId);
if (!handlers) return;
for (const h of handlers) h(ev);
if (handlers !== undefined && handlers.size > 0) {
for (const h of handlers) {
h(ev);
}
return;
}
let q = this.pendingInboundByChat.get(chatId);
if (!q) {
q = [];
this.pendingInboundByChat.set(chatId, q);
}
q.push(ev);
const over = q.length - NanobotClient.PENDING_INBOUND_MAX;
if (over > 0) {
q.splice(0, over);
}
}
private handleClose(event?: { code?: number }): void {
+59
View File
@@ -0,0 +1,59 @@
import type { UIMessage } from "@/lib/types";
/** Match websocket/session scrub: keep header + Result body only; trim model tail. */
const SUBAGENT_UI_RESULT_MAX_CHARS = 800;
/** Strip Task assignment + Summarize tail from persisted subagent announce blobs. */
export function scrubSubagentAnnounceBody(
content: string,
maxResultChars: number = SUBAGENT_UI_RESULT_MAX_CHARS,
): string {
const stripped = content.replace(/\r\n/g, "\n").trim();
const lines = stripped.split("\n");
let header = "";
if (lines.length > 0 && lines[0].startsWith("[Subagent")) {
header = lines[0].trim();
}
const lower = stripped.toLowerCase();
let key = "\nresult:\n";
let ri = lower.indexOf(key);
if (ri === -1) {
key = "\nresult:";
ri = lower.indexOf(key);
}
if (ri === -1) {
return header || stripped;
}
let after = stripped.slice(ri + key.length).replace(/^\s+/, "");
const summMarker = "summarize this naturally";
const si = after.toLowerCase().indexOf(summMarker);
if (si !== -1) {
after = after.slice(0, si).trimEnd();
}
let body = after.trim();
if (maxResultChars > 0 && body.length > maxResultChars) {
body = `${body.slice(0, maxResultChars - 1).trimEnd()}`;
}
if (header && body) {
return `${header}\n\n${body}`;
}
return header || body || stripped;
}
/** Apply scrub to assistant rows that look like subagent inject announcements. */
export function scrubSubagentUiMessages(messages: UIMessage[]): UIMessage[] {
return messages.map((m) => {
if (m.role !== "assistant" || typeof m.content !== "string") {
return m;
}
if (!m.content.includes("[Subagent")) {
return m;
}
const content = scrubSubagentAnnounceBody(m.content);
return content === m.content ? m : { ...m, content };
});
}
+22
View File
@@ -0,0 +1,22 @@
import type { UIMessage } from "@/lib/types";
/**
* Older WebUI disk snapshots and historical sessions may still contain
* ``kind: "long_task"`` rows from the retired orchestrator UI. Map them to
* ordinary trace rows so the thread stays readable without bespoke cards.
*/
export function normalizeLegacyLongTaskMessages(messages: UIMessage[]): UIMessage[] {
return messages.map((m) => {
const kind = (m as { kind?: string }).kind;
if (kind !== "long_task") return m;
const text = (m.content ?? "").trim() || "(legacy thread activity)";
return {
id: m.id,
role: "tool",
kind: "trace",
content: text,
traces: [text],
createdAt: m.createdAt,
};
});
}
+21
View File
@@ -1,3 +1,24 @@
/** Drop duplicate tool_call objects (same id or identical formatted trace). */
export function dedupeToolCallsForUi(calls: unknown): unknown[] {
if (!Array.isArray(calls) || calls.length === 0) return [];
const seen = new Set<string>();
const out: unknown[] = [];
for (const c of calls) {
let key: string | null = null;
if (c && typeof c === "object" && "id" in c) {
const id = (c as { id?: unknown }).id;
if (typeof id === "string" && id.length > 0) key = `id:${id}`;
}
if (key == null) {
key = formatToolCallTrace(c) ?? "";
}
if (!key || seen.has(key)) continue;
seen.add(key);
out.push(c);
}
return out;
}
export function formatToolCallTrace(call: unknown): string | null {
if (!call || typeof call !== "object") return null;
const item = call as {
+47 -1
View File
@@ -51,6 +51,21 @@ export interface UIMessage {
/** True while ``reasoning_delta`` frames are still arriving for this turn.
* Drives the shimmer header on ``ReasoningBubble``. */
reasoningStreaming?: boolean;
/** End-to-end wall time for this assistant turn (persisted ``latency_ms`` / ``turn_end``). */
latencyMs?: number;
}
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob {
kind: string;
data?: unknown;
}
/** WebSocket snapshot for sustained goals (`goal_state` events; keyed by ``chat_id``). */
export interface GoalStateWsPayload {
active: boolean;
ui_summary?: string;
objective?: string;
}
export interface ToolProgressEvent {
@@ -162,6 +177,10 @@ export type InboundEvent =
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
* generic progress line) rather than a conversational reply. */
kind?: "tool_hint" | "progress" | "reasoning";
/** Server-measured turn wall time when this frame finishes an assistant reply. */
latency_ms?: number;
/** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob;
}
| {
event: "delta";
@@ -190,7 +209,26 @@ export type InboundEvent =
model_name: string;
model_preset?: string | null;
}
| { event: "turn_end"; chat_id: string }
| {
event: "turn_end";
chat_id: string;
latency_ms?: number;
/** Authoritative sustained-goal snapshot for this chat (same shape as ``goal_state`` events). */
goal_state?: GoalStateWsPayload;
}
| {
event: "goal_status";
chat_id: string;
/** Turn executing (user message through agent loop). */
status: "running" | "idle";
/** Server ``time.time()`` when ``status`` is ``running``. */
started_at?: number;
}
| {
event: "goal_state";
chat_id: string;
goal_state: GoalStateWsPayload;
}
| { event: "session_updated"; chat_id: string }
| { event: "error"; chat_id?: string; detail?: string };
@@ -212,6 +250,14 @@ export interface OutboundImageGeneration {
aspect_ratio?: string | null;
}
/** Response shape for ``GET .../webui-thread`` (server-built transcript replay). */
export interface WebuiThreadPersistedPayload {
schemaVersion: number;
sessionKey?: string;
savedAt?: string;
messages: UIMessage[];
}
export type Outbound =
| { type: "new_chat" }
| { type: "attach"; chat_id: string }
+5 -4
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deleteSession,
fetchSessionMessages,
fetchWebuiThread,
listSessions,
listSlashCommands,
updateProviderSettings,
@@ -21,13 +21,14 @@ describe("webui API helpers", () => {
);
});
it("percent-encodes websocket keys when fetching session history", async () => {
await fetchSessionMessages("tok", "websocket:chat-1");
it("percent-encodes websocket keys when fetching webui-thread snapshot", async () => {
await fetchWebuiThread("tok", "websocket:chat-1");
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/messages",
"/api/sessions/websocket%3Achat-1/webui-thread",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
credentials: "same-origin",
}),
);
});
+19 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { setAppLanguage } from "@/i18n";
import { fmtDateTime, relativeTime } from "@/lib/format";
import { fmtDateTime, formatTurnLatency, relativeTime } from "@/lib/format";
describe("localized format helpers", () => {
beforeEach(() => {
@@ -61,4 +61,22 @@ describe("localized format helpers", () => {
);
expect(english).not.toBe(french);
});
it("formats turn latency with locale-aware units", async () => {
await setAppLanguage("en");
const subMinute = formatTurnLatency(2400, "en");
expect(subMinute).toBe(
new Intl.NumberFormat("en", {
style: "unit",
unit: "second",
unitDisplay: "narrow",
maximumFractionDigits: 1,
minimumFractionDigits: 0,
}).format(2.4),
);
const minutePlus = formatTurnLatency(90_000, "en");
expect(minutePlus).toContain("m");
expect(minutePlus).toContain("s");
});
});
+35 -1
View File
@@ -59,6 +59,19 @@ describe("MessageBubble", () => {
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("does not show copy when showAssistantCopyAction is false", () => {
const message: UIMessage = {
id: "a-mid",
role: "assistant",
content: "Mid-turn snippet.",
createdAt: Date.now(),
};
render(<MessageBubble message={message} showAssistantCopyAction={false} />);
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("renders trace messages as collapsible tool groups", () => {
const message: UIMessage = {
id: "t1",
@@ -118,7 +131,7 @@ describe("MessageBubble", () => {
expect(screen.getByText("Thinking…")).toBeInTheDocument();
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
expect(container.querySelector(".reasoning-shimmer")).toBeInTheDocument();
expect(container.querySelector(".reasoning-sheen-stripe")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
});
@@ -143,6 +156,27 @@ describe("MessageBubble", () => {
expect(screen.getByText("hidden until expanded")).toBeInTheDocument();
});
it("renders reasoning body as markdown so headings are not left as raw ###", async () => {
await import("@/components/MarkdownTextRenderer");
const message: UIMessage = {
id: "a-reasoning-md",
role: "assistant",
content: "",
createdAt: Date.now(),
reasoning: "### Section title\n\nBody line.",
reasoningStreaming: false,
};
const { container } = render(<MessageBubble message={message} />);
fireEvent.click(screen.getByRole("button", { name: /thinking/i }));
await waitFor(() => {
expect(container.querySelector("h3")?.textContent).toBe("Section title");
});
expect(container.textContent).not.toContain("###");
expect(screen.getByText("Body line.")).toBeInTheDocument();
});
it("renders assistant image media as a larger generated result", () => {
const message: UIMessage = {
id: "a-image",
+111
View File
@@ -89,6 +89,117 @@ describe("NanobotClient", () => {
});
});
it("buffers chat events while no chat handler is registered and replays on subscribe", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
// Nobody listening yet — deltas must not be dropped (user switched away).
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-queue", text: "a" });
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-queue", text: "b" });
const handler = vi.fn();
client.onChat("chat-queue", handler);
expect(handler).toHaveBeenCalledTimes(2);
expect(handler.mock.calls[0][0]).toMatchObject({ event: "delta", text: "a" });
expect(handler.mock.calls[1][0]).toMatchObject({ event: "delta", text: "b" });
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-queue", text: "c" });
expect(handler).toHaveBeenCalledTimes(3);
});
it("records goal_status run strip without an onChat subscriber", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-strip",
status: "running",
started_at: 12_345,
});
expect(client.getRunStartedAt("chat-strip")).toBe(12_345);
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-strip",
status: "idle",
});
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
it("records goal_state per chat_id without an onChat subscriber", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_state",
chat_id: "chat-goal-a",
goal_state: { active: true, ui_summary: "Docs" },
});
lastSocket().fakeMessage({
event: "goal_state",
chat_id: "chat-goal-b",
goal_state: { active: true, objective: "Ship API" },
});
expect(client.getGoalState("chat-goal-a")).toEqual({ active: true, ui_summary: "Docs" });
expect(client.getGoalState("chat-goal-b")).toEqual({
active: true,
objective: "Ship API",
});
lastSocket().fakeMessage({
event: "goal_state",
chat_id: "chat-goal-a",
goal_state: { active: false },
});
expect(client.getGoalState("chat-goal-a")).toEqual({ active: false });
});
it("records goal_state from turn_end payload when present", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "turn_end",
chat_id: "chat-te",
goal_state: { active: true, objective: "Long task" },
});
expect(client.getGoalState("chat-te")).toEqual({ active: true, objective: "Long task" });
});
it("buffers after unsubscribe until the chat is subscribed again", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const h1 = vi.fn();
const unsub = client.onChat("chat-rejoin", h1);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-rejoin", text: "live" });
expect(h1).toHaveBeenCalledTimes(1);
unsub();
lastSocket().fakeMessage({ event: "delta", chat_id: "chat-rejoin", text: "queued" });
expect(h1).toHaveBeenCalledTimes(1);
const h2 = vi.fn();
client.onChat("chat-rejoin", h2);
expect(h2).toHaveBeenCalledTimes(1);
expect(h2.mock.calls[0][0]).toMatchObject({ event: "delta", text: "queued" });
});
it("dispatches runtime model updates globally", () => {
const client = new NanobotClient({
url: "ws://test",
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { scrubSubagentAnnounceBody, scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import type { UIMessage } from "@/lib/types";
describe("subagent-channel-display", () => {
it("strips Task and Summarize tail", () => {
const raw = `[Subagent 'A' failed]
Task: do thing
Result:
oops
Summarize this naturally for the user.`;
expect(scrubSubagentAnnounceBody(raw)).toBe("[Subagent 'A' failed]\n\noops");
});
it("handles CRLF", () => {
const raw =
"[Subagent 'B' failed]\r\n\r\nTask: t\r\n\r\nResult:\r\nok\r\n\r\nSummarize this naturally";
expect(scrubSubagentAnnounceBody(raw)).toContain("ok");
expect(scrubSubagentAnnounceBody(raw)).not.toContain("Task:");
});
it("scrubs matching assistant rows", () => {
const messages: UIMessage[] = [
{ id: "1", role: "user", content: "hi", createdAt: 1 },
{
id: "2",
role: "assistant",
content:
"[Subagent 'C' failed]\n\nTask: long\n\nResult:\nshort\n\nSummarize this naturally",
createdAt: 2,
},
];
const out = scrubSubagentUiMessages(messages);
expect(out[0]).toBe(messages[0]);
expect(out[1].content).toBe("[Subagent 'C' failed]\n\nshort");
});
});
+44
View File
@@ -88,6 +88,50 @@ describe("ThreadComposer", () => {
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
});
it("shows turn run timer when runStartedAt is set", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date((1_000 + 125) * 1000));
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
runStartedAt={1000}
/>,
);
const status = screen.getByRole("status");
expect(status).toHaveTextContent(/Running/);
expect(status).toHaveTextContent(/2:05/);
vi.useRealTimers();
});
it("opens a bottom sheet with full thread goal when expand is clicked", async () => {
const longObjective =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz0123456789GoalTail";
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
goalState={{
active: true,
objective: longObjective,
ui_summary: "Short summary for strip",
}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Show full goal" }));
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveTextContent("Short summary for strip");
expect(dialog).toHaveTextContent(longObjective);
expect(dialog).toHaveTextContent("Summary");
expect(dialog).toHaveTextContent("Objective");
});
it("opens a slash command palette and inserts the selected command", () => {
const onSend = vi.fn();
render(
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import type { UIMessage } from "@/lib/types";
describe("normalizeLegacyLongTaskMessages", () => {
it("maps legacy long_task rows to trace lines", () => {
const legacy = {
id: "x",
role: "assistant",
kind: "long_task",
content: "long_task · done",
createdAt: 1,
} as unknown as UIMessage;
const out = normalizeLegacyLongTaskMessages([legacy]);
expect(out[0]!.kind).toBe("trace");
expect(out[0]!.role).toBe("tool");
expect(out[0]!.traces).toEqual(["long_task · done"]);
});
});
+47 -7
View File
@@ -1,11 +1,11 @@
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import type { UIMessage } from "@/lib/types";
describe("ThreadMessages", () => {
it("uses compact spacing between consecutive auxiliary rows", () => {
it("groups consecutive reasoning and tool rows into one cluster before the answer", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -41,12 +41,52 @@ describe("ThreadMessages", () => {
},
];
const { container } = render(<ThreadMessages messages={messages} />);
const { container } = render(
<ThreadMessages messages={messages} isStreaming={false} />,
);
const rows = Array.from(container.firstElementChild?.children ?? []);
expect(rows[0]).not.toHaveClass("mt-2", "mt-5");
expect(rows[1]).toHaveClass("mt-2");
expect(rows[2]).toHaveClass("mt-2");
expect(rows[3]).toHaveClass("mt-5");
expect(rows).toHaveLength(2);
expect(rows[0]).not.toHaveClass("mt-2", "mt-4", "mt-5");
expect(rows[1]).toHaveClass("mt-4");
});
it("shows copy only on the last assistant slice before the next user turn", () => {
const messages: UIMessage[] = [
{
id: "early",
role: "assistant",
content: "starting…",
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "search()",
traces: ["search()"],
createdAt: 2,
},
{
id: "late",
role: "assistant",
content: "final reply",
createdAt: 3,
},
];
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
expect(screen.getByText("final reply")).toBeInTheDocument();
});
it("shows copy only on the second assistant when two text slices appear before user", () => {
const messages: UIMessage[] = [
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 },
];
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
});
});
+46 -48
View File
@@ -4,16 +4,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { ThreadShell } from "@/components/thread/ThreadShell";
import { ClientProvider } from "@/providers/ClientProvider";
import type { UIMessage } from "@/lib/types";
function makeClient() {
const errorHandlers = new Set<(err: { kind: string }) => void>();
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
return {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onRuntimeModelUpdate: () => () => {},
getRunStartedAt: () => null,
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
let handlers = chatHandlers.get(chatId);
if (!handlers) {
@@ -41,6 +44,9 @@ function makeClient() {
for (const h of errorHandlers) h(err);
},
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
if (ev.event === "goal_state") {
goalStateByChatId.set(chatId, ev.goal_state);
}
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
},
_emitSessionUpdate(chatId: string) {
@@ -77,6 +83,20 @@ function session(chatId: string) {
};
}
function transcriptFromSimpleMessages(
rows: Array<{ role: "user" | "assistant"; content: string }>,
): { schemaVersion: number; messages: UIMessage[] } {
return {
schemaVersion: 3,
messages: rows.map((m, i) => ({
id: `m-${i}`,
role: m.role,
content: m.content,
createdAt: 1000 + i,
})),
};
}
function httpJson(body: unknown) {
return {
ok: true,
@@ -358,16 +378,13 @@ describe("ThreadShell", () => {
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
return httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
messages: [
if (url.includes("websocket%3Achat-a/webui-thread")) {
return httpJson(
transcriptFromSimpleMessages([
{ role: "user", content: "old question" },
{ role: "assistant", content: "old answer" },
],
});
]),
);
}
return {
ok: false,
@@ -509,15 +526,8 @@ describe("ThreadShell", () => {
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
return httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
// Simulate a stale history response that has not persisted the
// just-received assistant reply yet.
messages: [{ role: "user", content: "hello" }],
});
if (url.includes("websocket%3Achat-a/webui-thread")) {
return httpJson(transcriptFromSimpleMessages([{ role: "user", content: "hello" }]));
}
return {
ok: false,
@@ -590,19 +600,18 @@ describe("ThreadShell", () => {
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
if (url.includes("websocket%3Achat-a/webui-thread")) {
historyCalls += 1;
return httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
messages: historyCalls === 1
return httpJson(
transcriptFromSimpleMessages(
historyCalls === 1
? [{ role: "user", content: "question" }]
: [
{ role: "user", content: "question" },
{ role: "assistant", content: "canonical markdown answer" },
],
});
),
);
}
return {
ok: false,
@@ -650,16 +659,13 @@ describe("ThreadShell", () => {
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
return httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
messages: [
if (url.includes("websocket%3Achat-a/webui-thread")) {
return httpJson(
transcriptFromSimpleMessages([
{ role: "user", content: "question" },
{ role: "assistant", content: "loaded answer" },
],
});
]),
);
}
return {
ok: false,
@@ -703,7 +709,7 @@ describe("ThreadShell", () => {
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "smooth",
behavior: "auto",
}),
);
} finally {
@@ -879,17 +885,14 @@ describe("ThreadShell", () => {
"fetch",
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-a/messages")) {
if (url.includes("websocket%3Achat-a/webui-thread")) {
return Promise.resolve(
httpJson({
key: "websocket:chat-a",
created_at: null,
updated_at: null,
messages: [{ role: "assistant", content: "from chat a" }],
}),
httpJson(
transcriptFromSimpleMessages([{ role: "assistant", content: "from chat a" }]),
),
);
}
if (url.includes("websocket%3Achat-b/messages")) {
if (url.includes("websocket%3Achat-b/webui-thread")) {
return new Promise((resolve) => {
resolveChatB = resolve;
});
@@ -937,12 +940,7 @@ describe("ThreadShell", () => {
await act(async () => {
resolveChatB?.(
httpJson({
key: "websocket:chat-b",
created_at: null,
updated_at: null,
messages: [{ role: "assistant", content: "from chat b" }],
}),
httpJson(transcriptFromSimpleMessages([{ role: "assistant", content: "from chat b" }])),
);
});
+1 -1
View File
@@ -154,7 +154,7 @@ describe("ThreadViewport", () => {
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "smooth",
behavior: "auto",
}),
);
} finally {
+187 -1
View File
@@ -3,19 +3,48 @@ import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import type { InboundEvent } from "@/lib/types";
import type { InboundEvent, GoalStateWsPayload } from "@/lib/types";
import { ClientProvider } from "@/providers/ClientProvider";
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
function fakeClient() {
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
const runStartedAtByChatId = new Map<string, number>();
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
if (ev.event !== "goal_status") return;
if (ev.status === "running" && typeof ev.started_at === "number") {
runStartedAtByChatId.set(chatId, ev.started_at);
} else {
runStartedAtByChatId.delete(chatId);
}
}
function recordGoalStateSnapshot(chatId: string, ev: InboundEvent) {
if (ev.event === "goal_state") {
goalStateByChatId.set(chatId, ev.goal_state);
return;
}
if (ev.event === "turn_end" && ev.goal_state != null && typeof ev.goal_state === "object") {
goalStateByChatId.set(chatId, ev.goal_state);
}
}
return {
client: {
status: "open" as const,
defaultChatId: null as string | null,
onStatus: () => () => {},
onError: () => () => {},
getRunStartedAt(chatId: string) {
const v = runStartedAtByChatId.get(chatId);
return v === undefined ? null : v;
},
getGoalState(chatId: string) {
return goalStateByChatId.get(chatId);
},
onChat(chatId: string, h: (ev: InboundEvent) => void) {
let set = handlers.get(chatId);
if (!set) {
@@ -33,6 +62,8 @@ function fakeClient() {
updateUrl: vi.fn(),
},
emit(chatId: string, ev: InboundEvent) {
recordGoalStatusForRunStrip(chatId, ev);
recordGoalStateSnapshot(chatId, ev);
const set = handlers.get(chatId);
set?.forEach((h) => h(ev));
},
@@ -113,6 +144,28 @@ describe("useNanobotStream", () => {
expect(result.current.messages[1].kind).toBeUndefined();
});
it("treats progress with arbitrary agent_ui like ordinary trace text", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-au", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-au", {
event: "message",
chat_id: "chat-au",
text: "progress · panel tick",
kind: "progress",
agent_ui: {
kind: "panel",
data: { version: 1, event: "tick", id: "x1" },
},
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].kind).toBe("trace");
expect(result.current.messages[0].content).toContain("panel tick");
});
it("renders live tool traces from structured tool events", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-tool-events", EMPTY_MESSAGES), {
@@ -656,4 +709,137 @@ describe("useNanobotStream", () => {
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
it("stamps latency on the last assistant bubble from turn_end", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-lat", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-lat", {
event: "delta",
chat_id: "chat-lat",
text: "Hi",
});
});
act(() => {
fake.emit("chat-lat", {
event: "turn_end",
chat_id: "chat-lat",
latency_ms: 2400,
});
});
const lastAssistant = [...result.current.messages].reverse().find((m) => m.role === "assistant");
expect(lastAssistant?.latencyMs).toBe(2400);
});
it("tracks goal_status running and clears on idle", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-g", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
expect(result.current.runStartedAt).toBeNull();
act(() => {
fake.emit("chat-g", {
event: "goal_status",
chat_id: "chat-g",
status: "running",
started_at: 1700,
});
});
expect(result.current.runStartedAt).toBe(1700);
act(() => {
fake.emit("chat-g", {
event: "goal_status",
chat_id: "chat-g",
status: "idle",
});
});
expect(result.current.runStartedAt).toBeNull();
});
it("restores runStartedAt after switching away and back when goal_status was recorded without a subscriber", () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES),
{
wrapper: wrap(fake.client),
initialProps: { chatId: "chat-a" },
},
);
act(() => {
fake.emit("chat-a", {
event: "goal_status",
chat_id: "chat-a",
status: "running",
started_at: 4242,
});
});
expect(result.current.runStartedAt).toBe(4242);
rerender({ chatId: "chat-b" });
expect(result.current.runStartedAt).toBeNull();
act(() => {
fake.emit("chat-a", {
event: "goal_status",
chat_id: "chat-a",
status: "running",
started_at: 9001,
});
});
rerender({ chatId: "chat-a" });
expect(result.current.runStartedAt).toBe(9001);
});
it("tracks goal_state per chat and restores after switching sessions", () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES),
{
wrapper: wrap(fake.client),
initialProps: { chatId: "chat-a" },
},
);
act(() => {
fake.emit("chat-a", {
event: "goal_state",
chat_id: "chat-a",
goal_state: { active: true, ui_summary: "Alpha" },
});
});
expect(result.current.goalState).toEqual({ active: true, ui_summary: "Alpha" });
act(() => {
fake.emit("chat-b", {
event: "goal_state",
chat_id: "chat-b",
goal_state: { active: true, objective: "Beta task" },
});
});
rerender({ chatId: "chat-b" });
expect(result.current.goalState).toEqual({ active: true, objective: "Beta task" });
rerender({ chatId: "chat-a" });
expect(result.current.goalState).toEqual({ active: true, ui_summary: "Alpha" });
act(() => {
fake.emit("chat-a", {
event: "goal_state",
chat_id: "chat-a",
goal_state: { active: false },
});
});
expect(result.current.goalState).toEqual({ active: false });
});
});
+70 -155
View File
@@ -12,7 +12,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
...actual,
listSessions: vi.fn(),
deleteSession: vi.fn(),
fetchSessionMessages: vi.fn(),
fetchWebuiThread: vi.fn(),
};
});
@@ -24,6 +24,7 @@ function fakeClient() {
onStatus: () => () => {},
onError: () => () => {},
onChat: () => () => {},
getRunStartedAt: () => null,
onSessionUpdate: (handler: (chatId: string) => void) => {
sessionUpdateHandlers.add(handler);
return () => sessionUpdateHandlers.delete(handler);
@@ -57,7 +58,7 @@ describe("useSessions", () => {
beforeEach(() => {
vi.mocked(api.listSessions).mockReset();
vi.mocked(api.deleteSession).mockReset();
vi.mocked(api.fetchSessionMessages).mockReset();
vi.mocked(api.fetchWebuiThread).mockReset();
});
it("removes a session from the local list after delete succeeds", async () => {
@@ -134,35 +135,26 @@ describe("useSessions", () => {
expect(api.listSessions).toHaveBeenCalledTimes(2);
});
it("hydrates media_urls from historical user turns into UIMessage.images", async () => {
// Round-trip check for the signed-media replay: the backend emits
// ``media_urls`` on a historical user row and the hook must surface them
// as ``images`` so the bubble can render the preview. Assistant turns
// carry no media_urls and should not sprout an ``images`` field.
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-media",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
it("passes through WebUI transcript user media as images and media", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
messages: [
{
id: "u1",
role: "user",
content: "what's this?",
timestamp: "2026-04-20T10:00:00Z",
media_urls: [
createdAt: 1,
images: [
{ url: "/api/media/sig-1/payload-1", name: "snap.png" },
{ url: "/api/media/sig-2/payload-2", name: "diag.jpg" },
],
media: [
{ kind: "image", url: "/api/media/sig-1/payload-1", name: "snap.png" },
{ kind: "image", url: "/api/media/sig-2/payload-2", name: "diag.jpg" },
],
},
{
role: "assistant",
content: "it's a cat",
timestamp: "2026-04-20T10:00:01Z",
},
{
role: "user",
content: "follow-up without images",
timestamp: "2026-04-20T10:01:00Z",
},
{ id: "a1", role: "assistant", content: "it's a cat", createdAt: 2 },
{ id: "u2", role: "user", content: "follow-up without images", createdAt: 3 },
],
});
@@ -187,19 +179,16 @@ describe("useSessions", () => {
expect(third.images).toBeUndefined();
});
it("hydrates historical assistant video media_urls into media attachments", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-video",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
it("passes through assistant video media from transcript replay", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
messages: [
{
id: "a1",
role: "assistant",
content: "clip ready",
timestamp: "2026-04-20T10:00:01Z",
media_urls: [
{ url: "/api/media/sig-v/payload-v", name: "clip.mp4" },
],
createdAt: 1,
media: [{ kind: "video", url: "/api/media/sig-v/payload-v", name: "clip.mp4" }],
},
],
});
@@ -210,24 +199,23 @@ describe("useSessions", () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages[0].role).toBe("assistant");
expect(result.current.messages[0].images).toBeUndefined();
expect(result.current.messages[0].media).toEqual([
expect(result.current.messages[0]!.role).toBe("assistant");
expect(result.current.messages[0]!.images).toBeUndefined();
expect(result.current.messages[0]!.media).toEqual([
{ kind: "video", url: "/api/media/sig-v/payload-v", name: "clip.mp4" },
]);
});
it("hydrates persisted assistant reasoning into the replayed message", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-reasoning",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
it("passes through assistant reasoning from transcript replay", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
messages: [
{
id: "a1",
role: "assistant",
content: "final answer",
timestamp: "2026-04-20T10:00:01Z",
reasoning_content: "hidden but persisted reasoning",
createdAt: 1,
reasoning: "hidden but persisted reasoning",
},
],
});
@@ -239,75 +227,25 @@ describe("useSessions", () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe("assistant");
expect(result.current.messages[0].content).toBe("final answer");
expect(result.current.messages[0].reasoning).toBe("hidden but persisted reasoning");
expect(result.current.messages[0].reasoningStreaming).toBe(false);
expect(result.current.messages[0]!.role).toBe("assistant");
expect(result.current.messages[0]!.content).toBe("final answer");
expect(result.current.messages[0]!.reasoning).toBe("hidden but persisted reasoning");
});
it("drops replayed assistant turns that only contain reasoning", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-empty-reasoning",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
it("accepts transcript rows produced by the server replay reducer", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
messages: [
{ id: "u1", role: "user", content: "research this", createdAt: 1 },
{
role: "assistant",
content: "",
timestamp: "2026-04-20T10:00:01Z",
reasoning_content: "orphan reasoning",
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-empty-reasoning"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toHaveLength(0);
});
it("hydrates historical assistant tool calls into a replay trace row", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-tools",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
messages: [
{
role: "user",
content: "research this",
timestamp: "2026-04-20T10:00:00Z",
},
{
role: "assistant",
content: "",
timestamp: "2026-04-20T10:00:01Z",
tool_calls: [
{
id: "call-1",
type: "function",
function: { name: "web_search", arguments: "{\"query\":\"agents\"}" },
},
{
id: "call-2",
type: "function",
function: { name: "web_fetch", arguments: "{\"url\":\"https://example.com\"}" },
},
],
},
{
id: "t1",
role: "tool",
content: "tool output that should not render directly",
timestamp: "2026-04-20T10:00:02Z",
tool_call_id: "call-1",
},
{
role: "assistant",
content: "summary",
timestamp: "2026-04-20T10:00:03Z",
kind: "trace",
content: "web_fetch({})",
traces: ["web_search({\"query\":\"agents\"})", "web_fetch({\"url\":\"https://example.com\"})"],
createdAt: 2,
},
{ id: "a1", role: "assistant", content: "summary", createdAt: 3 },
],
});
@@ -318,26 +256,26 @@ describe("useSessions", () => {
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages.map((m) => m.role)).toEqual(["user", "tool", "assistant"]);
const trace = result.current.messages[1];
const trace = result.current.messages[1]!;
expect(trace.kind).toBe("trace");
expect(trace.traces).toEqual([
"web_search({\"query\":\"agents\"})",
"web_fetch({\"url\":\"https://example.com\"})",
]);
expect(result.current.messages[2].content).toBe("summary");
expect(result.current.messages[2]!.content).toBe("summary");
});
it("flags history with trailing assistant tool calls as still pending", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-pending",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
it("flags transcript ending with a trace row as pending", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
messages: [
{
role: "assistant",
id: "t1",
role: "tool",
kind: "trace",
content: "Using 2 tools",
timestamp: "2026-04-20T10:00:01Z",
tool_calls: [{ id: "call-1" }],
traces: ["Using 2 tools"],
createdAt: 1,
},
],
});
@@ -351,47 +289,11 @@ describe("useSessions", () => {
expect(result.current.hasPendingToolCalls).toBe(true);
});
it("keeps pending when tool result rows trail assistant tool calls", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-pending-tool-result",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
it("does not flag transcript as pending when last row is not a trace", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
messages: [
{
role: "assistant",
content: "Using 1 tool",
timestamp: "2026-04-20T10:00:01Z",
tool_calls: [{ id: "call-1" }],
},
{
role: "tool",
content: "tool output",
timestamp: "2026-04-20T10:00:02Z",
tool_call_id: "call-1",
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-pending-tool-result"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasPendingToolCalls).toBe(true);
});
it("does not flag history as pending once the assistant turn has no tool calls", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-done",
created_at: "2026-04-20T10:00:00Z",
updated_at: "2026-04-20T10:05:00Z",
messages: [
{
role: "assistant",
content: "All done",
timestamp: "2026-04-20T10:00:01Z",
},
{ id: "a1", role: "assistant", content: "All done", createdAt: 1 },
],
});
@@ -404,6 +306,19 @@ describe("useSessions", () => {
expect(result.current.hasPendingToolCalls).toBe(false);
});
it("treats missing transcript (404) as empty history", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue(null);
const { result } = renderHook(() => useSessionHistory("websocket:new-chat"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([]);
expect(result.current.hasPendingToolCalls).toBe(false);
});
it("keeps the session in the list when delete fails", async () => {
vi.mocked(api.listSessions).mockResolvedValue([
{