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:
co-authored by
Cursor
chengyongru
chengyongru
parent
2d17a095dc
commit
1c2ea1aad2
+17
-11
@@ -6,10 +6,11 @@ import platform
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from importlib.resources import files as pkg_files
|
from importlib.resources import files as pkg_files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from nanobot.agent.memory import MemoryStore
|
from nanobot.agent.memory import MemoryStore
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
from nanobot.session.goal_state import goal_state_runtime_lines
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
current_time_str,
|
current_time_str,
|
||||||
detect_image_mime,
|
detect_image_mime,
|
||||||
@@ -90,8 +91,11 @@ class ContextBuilder:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_runtime_context(
|
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,
|
sender_id: str | None = None,
|
||||||
|
supplemental_lines: Sequence[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build untrusted runtime metadata block appended after user content."""
|
"""Build untrusted runtime metadata block appended after user content."""
|
||||||
lines = [f"Current Time: {current_time_str(timezone)}"]
|
lines = [f"Current Time: {current_time_str(timezone)}"]
|
||||||
@@ -99,6 +103,8 @@ class ContextBuilder:
|
|||||||
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
|
||||||
if sender_id:
|
if sender_id:
|
||||||
lines += [f"Sender ID: {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
|
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -147,9 +153,17 @@ class ContextBuilder:
|
|||||||
current_role: str = "user",
|
current_role: str = "user",
|
||||||
sender_id: str | None = None,
|
sender_id: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
|
session_metadata: Mapping[str, Any] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""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)
|
user_content = self._build_user_content(current_message, media)
|
||||||
|
|
||||||
# Merge runtime context and user content into a single user message
|
# Merge runtime context and user content into a single user message
|
||||||
@@ -197,11 +211,3 @@ class ContextBuilder:
|
|||||||
return text
|
return text
|
||||||
return images + [{"type": "text", "text": 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
@@ -32,6 +32,7 @@ from nanobot.command import CommandContext, CommandRouter, register_builtin_comm
|
|||||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
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.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.artifacts import generated_image_paths_from_messages
|
from nanobot.utils.artifacts import generated_image_paths_from_messages
|
||||||
from nanobot.utils.document import extract_documents
|
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.helpers import truncate_text as truncate_text_fn
|
||||||
from nanobot.utils.image_generation_intent import image_generation_prompt
|
from nanobot.utils.image_generation_intent import image_generation_prompt
|
||||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
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_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:
|
if TYPE_CHECKING:
|
||||||
from nanobot.config.schema import (
|
from nanobot.config.schema import (
|
||||||
@@ -104,6 +107,9 @@ class TurnContext:
|
|||||||
pending_queue: asyncio.Queue | None = None
|
pending_queue: asyncio.Queue | None = None
|
||||||
pending_summary: str | 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)
|
trace: list[StateTraceEntry] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -223,6 +229,7 @@ class AgentLoop:
|
|||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self._start_time = time.time()
|
self._start_time = time.time()
|
||||||
self._last_usage: dict[str, int] = {}
|
self._last_usage: dict[str, int] = {}
|
||||||
|
self._pending_turn_latency_ms: dict[str, int] = {}
|
||||||
self._extra_hooks: list[AgentHook] = hooks or []
|
self._extra_hooks: list[AgentHook] = hooks or []
|
||||||
|
|
||||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||||
@@ -437,6 +444,7 @@ class AgentLoop:
|
|||||||
bus=self.bus,
|
bus=self.bus,
|
||||||
subagent_manager=self.subagents,
|
subagent_manager=self.subagents,
|
||||||
cron_service=self.cron_service,
|
cron_service=self.cron_service,
|
||||||
|
sessions=self.sessions,
|
||||||
provider_snapshot_loader=self._provider_snapshot_loader,
|
provider_snapshot_loader=self._provider_snapshot_loader,
|
||||||
image_generation_provider_configs=self._image_generation_provider_configs,
|
image_generation_provider_configs=self._image_generation_provider_configs,
|
||||||
timezone=self.context.timezone or "UTC",
|
timezone=self.context.timezone or "UTC",
|
||||||
@@ -598,6 +606,7 @@ class AgentLoop:
|
|||||||
chat_id=self._runtime_chat_id(msg),
|
chat_id=self._runtime_chat_id(msg),
|
||||||
sender_id=msg.sender_id,
|
sender_id=msg.sender_id,
|
||||||
session_summary=pending_summary,
|
session_summary=pending_summary,
|
||||||
|
session_metadata=session.metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _dispatch_command_inline(
|
async def _dispatch_command_inline(
|
||||||
@@ -714,10 +723,13 @@ class AgentLoop:
|
|||||||
content, media = extract_documents(content, media)
|
content, media = extract_documents(content, media)
|
||||||
media = media or None
|
media = media or None
|
||||||
user_content = self.context._build_user_content(content, media)
|
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(
|
runtime_ctx = self.context._build_runtime_context(
|
||||||
pending_msg.channel,
|
pending_msg.channel,
|
||||||
self._runtime_chat_id(pending_msg),
|
self._runtime_chat_id(pending_msg),
|
||||||
self.context.timezone,
|
self.context.timezone,
|
||||||
|
sender_id=pending_msg.sender_id,
|
||||||
|
supplemental_lines=extra or None,
|
||||||
)
|
)
|
||||||
if isinstance(user_content, str):
|
if isinstance(user_content, str):
|
||||||
merged: str | list[dict[str, Any]] = f"{user_content}\n\n{runtime_ctx}"
|
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,
|
# Signal that the turn is fully complete (all tools executed,
|
||||||
# final text streamed). This lets WS clients know when to
|
# final text streamed). This lets WS clients know when to
|
||||||
# definitively stop the loading indicator.
|
# 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(
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
channel=msg.channel, chat_id=msg.chat_id,
|
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:
|
if msg.metadata.get("webui") is True:
|
||||||
async def _generate_title_and_notify() -> None:
|
async def _generate_title_and_notify() -> None:
|
||||||
@@ -1004,6 +1022,8 @@ class AgentLoop:
|
|||||||
"Re-published {} leftover message(s) to bus for session {}",
|
"Re-published {} leftover message(s) to bus for session {}",
|
||||||
leftover, session_key,
|
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:
|
async def close_mcp(self) -> None:
|
||||||
"""Drain pending background archives, then close MCP connections."""
|
"""Drain pending background archives, then close MCP connections."""
|
||||||
@@ -1081,7 +1101,9 @@ class AgentLoop:
|
|||||||
current_role=current_role,
|
current_role=current_role,
|
||||||
sender_id=msg.sender_id,
|
sender_id=msg.sender_id,
|
||||||
session_summary=pending,
|
session_summary=pending,
|
||||||
|
session_metadata=session.metadata,
|
||||||
)
|
)
|
||||||
|
t_wall = time.time()
|
||||||
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
|
||||||
messages, session=session, channel=channel, chat_id=chat_id,
|
messages, session=session, channel=channel, chat_id=chat_id,
|
||||||
message_id=msg.metadata.get("message_id"),
|
message_id=msg.metadata.get("message_id"),
|
||||||
@@ -1089,7 +1111,11 @@ class AgentLoop:
|
|||||||
session_key=key,
|
session_key=key,
|
||||||
pending_queue=pending_queue,
|
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)
|
session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
|
||||||
self._clear_runtime_checkpoint(session)
|
self._clear_runtime_checkpoint(session)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
@@ -1210,6 +1236,8 @@ class AgentLoop:
|
|||||||
had_injections: bool,
|
had_injections: bool,
|
||||||
generated_media: list[str],
|
generated_media: list[str],
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None,
|
on_stream: Callable[[str], Awaitable[None]] | None,
|
||||||
|
*,
|
||||||
|
turn_latency_ms: int | None = None,
|
||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
"""Assemble the final outbound message from turn results."""
|
"""Assemble the final outbound message from turn results."""
|
||||||
# MessageTool suppression
|
# MessageTool suppression
|
||||||
@@ -1223,6 +1251,8 @@ class AgentLoop:
|
|||||||
meta = dict(msg.metadata or {})
|
meta = dict(msg.metadata or {})
|
||||||
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
|
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
|
||||||
meta["_streamed"] = True
|
meta["_streamed"] = True
|
||||||
|
if turn_latency_ms is not None:
|
||||||
|
meta["latency_ms"] = int(turn_latency_ms)
|
||||||
|
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
@@ -1325,6 +1355,7 @@ class AgentLoop:
|
|||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
async def _state_run(self, ctx: TurnContext) -> str:
|
async def _state_run(self, ctx: TurnContext) -> str:
|
||||||
|
await publish_turn_run_status(self.bus, ctx.msg, "running")
|
||||||
result = await self._run_agent_loop(
|
result = await self._run_agent_loop(
|
||||||
ctx.initial_messages,
|
ctx.initial_messages,
|
||||||
on_progress=ctx.on_progress,
|
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)
|
ctx.save_skip = 1 + len(ctx.history) + (1 if ctx.user_persisted_early else 0)
|
||||||
skip_msgs = ctx.all_messages[ctx.save_skip:]
|
skip_msgs = ctx.all_messages[ctx.save_skip:]
|
||||||
ctx.generated_media = generated_image_paths_from_messages(skip_msgs)
|
ctx.generated_media = generated_image_paths_from_messages(skip_msgs)
|
||||||
last_msg = ctx.all_messages[-1] if ctx.all_messages else None
|
mt = self.tools.get("message")
|
||||||
if ctx.generated_media and last_msg and last_msg.get("role") == "assistant":
|
extra = getattr(mt, "turn_delivered_media_paths", lambda: [])() if mt else []
|
||||||
existing_media = last_msg.get("media")
|
merge_turn_media_into_last_assistant(ctx.all_messages, ctx.generated_media, extra)
|
||||||
media = existing_media if isinstance(existing_media, list) else []
|
|
||||||
last_msg["media"] = list(dict.fromkeys([*media, *ctx.generated_media]))
|
|
||||||
|
|
||||||
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)
|
ctx.session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
|
||||||
self._clear_pending_user_turn(ctx.session)
|
self._clear_pending_user_turn(ctx.session)
|
||||||
self._clear_runtime_checkpoint(ctx.session)
|
self._clear_runtime_checkpoint(ctx.session)
|
||||||
@@ -1382,6 +1417,7 @@ class AgentLoop:
|
|||||||
ctx.had_injections,
|
ctx.had_injections,
|
||||||
ctx.generated_media,
|
ctx.generated_media,
|
||||||
ctx.on_stream,
|
ctx.on_stream,
|
||||||
|
turn_latency_ms=ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
@@ -1425,10 +1461,18 @@ class AgentLoop:
|
|||||||
|
|
||||||
return filtered
|
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."""
|
"""Save new-turn messages into session, truncating large tool results."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
last_assistant_idx: int | None = None
|
||||||
for m in messages[skip:]:
|
for m in messages[skip:]:
|
||||||
entry = dict(m)
|
entry = dict(m)
|
||||||
role, content = entry.get("role"), entry.get("content")
|
role, content = entry.get("role"), entry.get("content")
|
||||||
@@ -1458,6 +1502,10 @@ class AgentLoop:
|
|||||||
entry["content"] = filtered
|
entry["content"] = filtered
|
||||||
entry.setdefault("timestamp", datetime.now().isoformat())
|
entry.setdefault("timestamp", datetime.now().isoformat())
|
||||||
session.messages.append(entry)
|
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()
|
session.updated_at = datetime.now()
|
||||||
|
|
||||||
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
|
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
|
||||||
|
|||||||
@@ -604,6 +604,7 @@ class Consolidator:
|
|||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
sender_id=None,
|
sender_id=None,
|
||||||
session_summary=summary,
|
session_summary=summary,
|
||||||
|
session_metadata=session.metadata,
|
||||||
)
|
)
|
||||||
return estimate_prompt_tokens_chain(
|
return estimate_prompt_tokens_chain(
|
||||||
self.provider,
|
self.provider,
|
||||||
|
|||||||
@@ -626,9 +626,16 @@ class AgentRunner:
|
|||||||
context.streamed_content = True
|
context.streamed_content = True
|
||||||
await hook.on_stream(context, delta)
|
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(
|
coro = self.provider.chat_stream_with_retry(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
on_content_delta=_stream,
|
on_content_delta=_stream,
|
||||||
|
on_thinking_delta=_thinking,
|
||||||
)
|
)
|
||||||
elif wants_progress_streaming:
|
elif wants_progress_streaming:
|
||||||
stream_buf = ""
|
stream_buf = ""
|
||||||
|
|||||||
@@ -108,12 +108,18 @@ class SubagentManager:
|
|||||||
restrict_to_workspace=self.restrict_to_workspace,
|
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."""
|
"""Build an isolated subagent tool registry via ToolLoader."""
|
||||||
|
root = self.workspace if workspace is None else workspace
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
|
cfg = tools_config if tools_config is not None else self._subagent_tools_config()
|
||||||
ctx = ToolContext(
|
ctx = ToolContext(
|
||||||
config=self._subagent_tools_config(),
|
config=cfg,
|
||||||
workspace=str(self.workspace),
|
workspace=str(root.resolve()),
|
||||||
file_state_store=FileStates(),
|
file_state_store=FileStates(),
|
||||||
)
|
)
|
||||||
ToolLoader().load(ctx, registry, scope="subagent")
|
ToolLoader().load(ctx, registry, scope="subagent")
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class ToolContext:
|
|||||||
bus: Any | None = None
|
bus: Any | None = None
|
||||||
subagent_manager: Any | None = None
|
subagent_manager: Any | None = None
|
||||||
cron_service: Any | None = None
|
cron_service: Any | None = None
|
||||||
|
sessions: Any | None = None
|
||||||
file_state_store: Any = field(default=None)
|
file_state_store: Any = field(default=None)
|
||||||
provider_snapshot_loader: Callable[[], Any] | None = None
|
provider_snapshot_loader: Callable[[], Any] | None = None
|
||||||
image_generation_provider_configs: dict[str, Any] | None = None
|
image_generation_provider_configs: dict[str, Any] | None = None
|
||||||
|
|||||||
@@ -594,11 +594,6 @@ def _find_matches(content: str, old_text: str) -> list[_MatchSpan]:
|
|||||||
return []
|
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:
|
def _collapse_internal_whitespace(text: str) -> str:
|
||||||
return "\n".join(" ".join(line.split()) for line in text.splitlines())
|
return "\n".join(" ".join(line.split()) for line in text.splitlines())
|
||||||
|
|
||||||
|
|||||||
@@ -112,5 +112,5 @@ class ToolLoader:
|
|||||||
if not is_plugin_source:
|
if not is_plugin_source:
|
||||||
builtin_names.add(tool.name)
|
builtin_names.add(tool.name)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error("Failed to register tool: %s", cls_label)
|
logger.exception("Failed to register tool: %s", cls_label)
|
||||||
return registered
|
return registered
|
||||||
|
|||||||
@@ -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 / redirected—in 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})."
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ from nanobot.config.paths import get_workspace_path
|
|||||||
),
|
),
|
||||||
chat_id=StringSchema(
|
chat_id=StringSchema(
|
||||||
"Optional target chat/user ID for cross-channel/proactive delivery. "
|
"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."
|
"Do not set this to the current runtime chat for a normal reply."
|
||||||
),
|
),
|
||||||
media=ArraySchema(
|
media=ArraySchema(
|
||||||
@@ -72,6 +74,10 @@ class MessageTool(Tool, ContextAware):
|
|||||||
default={},
|
default={},
|
||||||
)
|
)
|
||||||
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
|
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(
|
self._record_channel_delivery_var: ContextVar[bool] = ContextVar(
|
||||||
"message_record_channel_delivery",
|
"message_record_channel_delivery",
|
||||||
default=False,
|
default=False,
|
||||||
@@ -100,6 +106,11 @@ class MessageTool(Tool, ContextAware):
|
|||||||
def start_turn(self) -> None:
|
def start_turn(self) -> None:
|
||||||
"""Reset per-turn send tracking."""
|
"""Reset per-turn send tracking."""
|
||||||
self._sent_in_turn = False
|
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):
|
def set_record_channel_delivery(self, active: bool):
|
||||||
"""Mark tool-sent messages as proactive channel deliveries."""
|
"""Mark tool-sent messages as proactive channel deliveries."""
|
||||||
@@ -172,6 +183,20 @@ class MessageTool(Tool, ContextAware):
|
|||||||
default_channel = self._default_channel.get()
|
default_channel = self._default_channel.get()
|
||||||
default_chat_id = self._default_chat_id.get()
|
default_chat_id = self._default_chat_id.get()
|
||||||
channel = channel or default_channel
|
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
|
chat_id = chat_id or default_chat_id
|
||||||
# Only inherit default message_id when targeting the same channel+chat.
|
# Only inherit default message_id when targeting the same channel+chat.
|
||||||
# Cross-chat sends must not carry the original message_id, because
|
# Cross-chat sends must not carry the original message_id, because
|
||||||
@@ -215,6 +240,9 @@ class MessageTool(Tool, ContextAware):
|
|||||||
await self._send_callback(msg)
|
await self._send_callback(msg)
|
||||||
if channel == default_channel and chat_id == default_chat_id:
|
if channel == default_channel and chat_id == default_chat_id:
|
||||||
self._sent_in_turn = True
|
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 ""
|
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 ""
|
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}"
|
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
||||||
|
|||||||
+11
-1
@@ -4,6 +4,11 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
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
|
@dataclass
|
||||||
class InboundMessage:
|
class InboundMessage:
|
||||||
@@ -26,7 +31,12 @@ class InboundMessage:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OutboundMessage:
|
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
|
channel: str
|
||||||
chat_id: str
|
chat_id: str
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -55,10 +56,12 @@ class ChannelManager:
|
|||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
*,
|
*,
|
||||||
session_manager: "SessionManager | None" = None,
|
session_manager: "SessionManager | None" = None,
|
||||||
|
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
||||||
):
|
):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
|
self._webui_runtime_model_name = webui_runtime_model_name
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._dispatch_task: asyncio.Task | None = None
|
self._dispatch_task: asyncio.Task | None = None
|
||||||
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
|
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
|
||||||
@@ -89,11 +92,14 @@ class ChannelManager:
|
|||||||
kwargs: dict[str, Any] = {}
|
kwargs: dict[str, Any] = {}
|
||||||
# Only the WebSocket channel currently hosts the embedded webui
|
# Only the WebSocket channel currently hosts the embedded webui
|
||||||
# surface; other channels stay oblivious to these knobs.
|
# surface; other channels stay oblivious to these knobs.
|
||||||
if cls.name == "websocket" and self._session_manager is not None:
|
if cls.name == "websocket":
|
||||||
kwargs["session_manager"] = self._session_manager
|
if self._session_manager is not None:
|
||||||
static_path = _default_webui_dist()
|
kwargs["session_manager"] = self._session_manager
|
||||||
if static_path is not None:
|
static_path = _default_webui_dist()
|
||||||
kwargs["static_dist_path"] = static_path
|
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 = cls(section, self.bus, **kwargs)
|
||||||
channel.transcription_provider = transcription_provider
|
channel.transcription_provider = transcription_provider
|
||||||
channel.transcription_api_key = transcription_key
|
channel.transcription_api_key = transcription_key
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class SlackConfig(Base):
|
|||||||
|
|
||||||
SLACK_MAX_MESSAGE_LEN = 39_000 # Slack API allows ~40k; leave margin
|
SLACK_MAX_MESSAGE_LEN = 39_000 # Slack API allows ~40k; leave margin
|
||||||
SLACK_DOWNLOAD_TIMEOUT = 30.0
|
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")
|
_HTML_DOWNLOAD_PREFIXES = (b"<!doctype html", b"<html")
|
||||||
|
|
||||||
|
|
||||||
@@ -109,7 +113,23 @@ class SlackChannel(BaseChannel):
|
|||||||
self.logger.warning("auth_test failed: {}", e)
|
self.logger.warning("auth_test failed: {}", e)
|
||||||
|
|
||||||
self.logger.info("Starting Socket Mode client...")
|
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:
|
while self._running:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|||||||
+235
-31
@@ -17,6 +17,7 @@ import shutil
|
|||||||
import ssl
|
import ssl
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Self
|
from typing import TYPE_CHECKING, Any, Self
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
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 Request as WsRequest
|
||||||
from websockets.http11 import Response
|
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.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.command.builtin import builtin_command_palette
|
from nanobot.command.builtin import builtin_command_palette
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config.schema import Base
|
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.helpers import safe_filename
|
||||||
from nanobot.utils.media_decode import (
|
from nanobot.utils.media_decode import (
|
||||||
FileSizeExceeded,
|
FileSizeExceeded,
|
||||||
save_base64_data_url,
|
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:
|
if TYPE_CHECKING:
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@@ -152,7 +158,7 @@ def publish_runtime_model_update(
|
|||||||
model: str,
|
model: str,
|
||||||
model_preset: str | None,
|
model_preset: str | None,
|
||||||
) -> 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(
|
bus.outbound.put_nowait(OutboundMessage(
|
||||||
channel="websocket",
|
channel="websocket",
|
||||||
chat_id="*",
|
chat_id="*",
|
||||||
@@ -165,18 +171,35 @@ def publish_runtime_model_update(
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
def _read_webui_model_name() -> str | None:
|
def _default_model_name_from_config() -> str | None:
|
||||||
"""Return the resolved startup model for readonly WebUI display."""
|
"""Resolved model string from on-disk config (bootstrap fallback)."""
|
||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
|
|
||||||
model = load_config().resolve_preset().model.strip()
|
model = load_config().resolve_preset().model.strip()
|
||||||
return model or None
|
return model or None
|
||||||
except Exception as e:
|
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
|
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]]]:
|
def _parse_request_path(path_with_query: str) -> tuple[str, dict[str, list[str]]]:
|
||||||
"""Parse normalized path and query parameters in one pass."""
|
"""Parse normalized path and query parameters in one pass."""
|
||||||
parsed = urlparse("ws://x" + path_with_query)
|
parsed = urlparse("ws://x" + path_with_query)
|
||||||
@@ -436,6 +459,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
*,
|
*,
|
||||||
session_manager: "SessionManager | None" = None,
|
session_manager: "SessionManager | None" = None,
|
||||||
static_dist_path: Path | None = None,
|
static_dist_path: Path | None = None,
|
||||||
|
runtime_model_name: Callable[[], str | None] | None = None,
|
||||||
):
|
):
|
||||||
if isinstance(config, dict):
|
if isinstance(config, dict):
|
||||||
config = WebSocketConfig.model_validate(config)
|
config = WebSocketConfig.model_validate(config)
|
||||||
@@ -449,7 +473,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_default: dict[Any, str] = {}
|
self._conn_default: dict[Any, str] = {}
|
||||||
# Single-use tokens consumed at WebSocket handshake.
|
# Single-use tokens consumed at WebSocket handshake.
|
||||||
self._issued_tokens: dict[str, float] = {}
|
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._api_tokens: dict[str, float] = {}
|
||||||
self._stop_event: asyncio.Event | None = None
|
self._stop_event: asyncio.Event | None = None
|
||||||
self._server_task: asyncio.Task[None] | None = None
|
self._server_task: asyncio.Task[None] | None = None
|
||||||
@@ -457,6 +481,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._static_dist_path: Path | None = (
|
self._static_dist_path: Path | None = (
|
||||||
static_dist_path.resolve() if static_dist_path is not None else 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
|
# 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
|
# the capability — anyone who holds a valid URL can fetch that one
|
||||||
# file, nothing else. The secret regenerates on restart so links
|
# file, nothing else. The secret regenerates on restart so links
|
||||||
@@ -482,6 +507,36 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._subs.pop(cid, None)
|
self._subs.pop(cid, None)
|
||||||
self._conn_default.pop(connection, 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:
|
async def _send_event(self, connection: Any, event: str, **fields: Any) -> None:
|
||||||
"""Send a control event (attached, error, ...) to a single connection."""
|
"""Send a control event (attached, error, ...) to a single connection."""
|
||||||
payload: dict[str, Any] = {"event": event}
|
payload: dict[str, Any] = {"event": event}
|
||||||
@@ -575,11 +630,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if got == issue_expected:
|
if got == issue_expected:
|
||||||
return self._handle_token_issue_http(connection, request)
|
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":
|
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":
|
if got == "/api/sessions":
|
||||||
return self._handle_sessions_list(request)
|
return self._handle_sessions_list(request)
|
||||||
|
|
||||||
@@ -602,6 +657,10 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if m:
|
if m:
|
||||||
return self._handle_session_messages(request, m.group(1))
|
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
|
# NOTE: websockets' HTTP parser only accepts GET, so we cannot expose a
|
||||||
# true ``DELETE`` verb. The action is folded into the path instead.
|
# true ``DELETE`` verb. The action is folded into the path instead.
|
||||||
m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
|
m = re.match(r"^/api/sessions/([^/]+)/delete$", got)
|
||||||
@@ -659,7 +718,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if now > expiry:
|
if now > expiry:
|
||||||
self._api_tokens.pop(token_key, None)
|
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),
|
# When a secret is configured (token_issue_secret or static token),
|
||||||
# validate it regardless of source IP. This secures deployments
|
# validate it regardless of source IP. This secures deployments
|
||||||
# behind a reverse proxy where all connections appear as localhost.
|
# behind a reverse proxy where all connections appear as localhost.
|
||||||
@@ -669,7 +728,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
elif not _is_localhost(connection):
|
elif not _is_localhost(connection):
|
||||||
# No secret configured: only allow localhost (local dev mode).
|
# 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.
|
# Cap outstanding tokens to avoid runaway growth from a misbehaving client.
|
||||||
self._purge_expired_issued_tokens()
|
self._purge_expired_issued_tokens()
|
||||||
self._purge_expired_api_tokens()
|
self._purge_expired_api_tokens()
|
||||||
@@ -693,7 +752,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"token": token,
|
"token": token,
|
||||||
"ws_path": self._expected_path(),
|
"ws_path": self._expected_path(),
|
||||||
"expires_in": self.config.token_ttl_s,
|
"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:
|
if self._session_manager is None:
|
||||||
return _http_error(503, "session manager unavailable")
|
return _http_error(503, "session manager unavailable")
|
||||||
sessions = self._session_manager.list_sessions()
|
sessions = self._session_manager.list_sessions()
|
||||||
# The webui is only meaningful for websocket-channel chats — CLI /
|
# Sidebar/chat listing for WS-backed sessions only — CLI / Slack / etc.
|
||||||
# Slack / Lark / Discord sessions can't be resumed from the browser,
|
# keys are not intended for resume over this HTTP surface.
|
||||||
# so leaking them into the sidebar is just noise. Filter to the
|
|
||||||
# ``websocket:`` prefix and strip absolute paths on the way out.
|
|
||||||
cleaned = [
|
cleaned = [
|
||||||
{k: v for k, v in s.items() if k != "path"}
|
{k: v for k, v in s.items() if k != "path"}
|
||||||
for s in sessions
|
for s in sessions
|
||||||
@@ -918,8 +975,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return _http_json_response(self._settings_payload(requires_restart=False))
|
return _http_json_response(self._settings_payload(requires_restart=False))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_webui_session_key(key: str) -> bool:
|
def _is_websocket_channel_session_key(key: str) -> bool:
|
||||||
"""Return True when *key* belongs to the webui's websocket-only surface."""
|
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
|
||||||
return key.startswith("websocket:")
|
return key.startswith("websocket:")
|
||||||
|
|
||||||
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
|
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
|
||||||
@@ -930,14 +987,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
decoded_key = _decode_api_key(key)
|
decoded_key = _decode_api_key(key)
|
||||||
if decoded_key is None:
|
if decoded_key is None:
|
||||||
return _http_error(400, "invalid session key")
|
return _http_error(400, "invalid session key")
|
||||||
# The embedded webui only understands websocket-channel sessions. Keep
|
# Only ``websocket:…`` sessions are listed/served here — same boundary as
|
||||||
# its read surface aligned with ``/api/sessions`` instead of letting a
|
# ``/api/sessions``. Block handcrafted URLs from probing CLI / Slack / etc.
|
||||||
# caller probe arbitrary CLI / Slack / Lark history by handcrafted URL.
|
if not self._is_websocket_channel_session_key(decoded_key):
|
||||||
if not self._is_webui_session_key(decoded_key):
|
|
||||||
return _http_error(404, "session not found")
|
return _http_error(404, "session not found")
|
||||||
data = self._session_manager.read_session_file(decoded_key)
|
data = self._session_manager.read_session_file(decoded_key)
|
||||||
if data is None:
|
if data is None:
|
||||||
return _http_error(404, "session not found")
|
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
|
# Decorate persisted user messages with signed media URLs so the
|
||||||
# client can render previews. The raw on-disk ``media`` paths are
|
# client can render previews. The raw on-disk ``media`` paths are
|
||||||
# stripped on the way out — they leak server filesystem layout and
|
# stripped on the way out — they leak server filesystem layout and
|
||||||
@@ -945,6 +1004,74 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._augment_media_urls(data)
|
self._augment_media_urls(data)
|
||||||
return _http_json_response(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:
|
def _augment_media_urls(self, payload: dict[str, Any]) -> None:
|
||||||
"""Mutate *payload* in place: each message's ``media`` path list is
|
"""Mutate *payload* in place: each message's ``media`` path list is
|
||||||
replaced by a parallel ``media_urls`` list of signed fetch URLs.
|
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
|
The URL is self-authenticating: the signature binds the payload to
|
||||||
this process's ``_media_secret``, so only paths we chose to sign can
|
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
|
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:
|
try:
|
||||||
media_root = get_media_dir().resolve()
|
media_root = get_media_dir().resolve()
|
||||||
@@ -1079,12 +1206,12 @@ class WebSocketChannel(BaseChannel):
|
|||||||
decoded_key = _decode_api_key(key)
|
decoded_key = _decode_api_key(key)
|
||||||
if decoded_key is None:
|
if decoded_key is None:
|
||||||
return _http_error(400, "invalid session key")
|
return _http_error(400, "invalid session key")
|
||||||
# Same boundary as ``_handle_session_messages``: the webui may only
|
# Same boundary as ``_handle_session_messages``: mutations apply only to
|
||||||
# mutate websocket sessions, and deletion really does unlink the local
|
# websocket-channel sessions; deletion unlinks local JSONL — keep scope narrow.
|
||||||
# JSONL, so keep the blast radius narrow and explicit.
|
if not self._is_websocket_channel_session_key(decoded_key):
|
||||||
if not self._is_webui_session_key(decoded_key):
|
|
||||||
return _http_error(404, "session not found")
|
return _http_error(404, "session not found")
|
||||||
deleted = self._session_manager.delete_session(decoded_key)
|
deleted = self._session_manager.delete_session(decoded_key)
|
||||||
|
delete_webui_thread(decoded_key)
|
||||||
return _http_json_response({"deleted": bool(deleted)})
|
return _http_json_response({"deleted": bool(deleted)})
|
||||||
|
|
||||||
def _serve_static(self, request_path: str) -> Response | None:
|
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
|
# Register only after ready is successfully sent to avoid out-of-order sends
|
||||||
self._conn_default[connection] = default_chat_id
|
self._conn_default[connection] = default_chat_id
|
||||||
self._attach(connection, default_chat_id)
|
self._attach(connection, default_chat_id)
|
||||||
|
await self._hydrate_after_subscribe(default_chat_id)
|
||||||
|
|
||||||
async for raw in connection:
|
async for raw in connection:
|
||||||
if isinstance(raw, bytes):
|
if isinstance(raw, bytes):
|
||||||
@@ -1344,6 +1472,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
self._attach(connection, new_id)
|
self._attach(connection, new_id)
|
||||||
await self._send_event(connection, "attached", chat_id=new_id)
|
await self._send_event(connection, "attached", chat_id=new_id)
|
||||||
|
await self._hydrate_after_subscribe(new_id)
|
||||||
return
|
return
|
||||||
if t == "attach":
|
if t == "attach":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
@@ -1352,6 +1481,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
self._attach(connection, cid)
|
self._attach(connection, cid)
|
||||||
await self._send_event(connection, "attached", chat_id=cid)
|
await self._send_event(connection, "attached", chat_id=cid)
|
||||||
|
await self._hydrate_after_subscribe(cid)
|
||||||
return
|
return
|
||||||
if t == "message":
|
if t == "message":
|
||||||
cid = envelope.get("chat_id")
|
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.
|
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||||
self._attach(connection, cid)
|
self._attach(connection, cid)
|
||||||
|
await self._hydrate_after_subscribe(cid)
|
||||||
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
|
||||||
if envelope.get("webui") is True:
|
if envelope.get("webui") is True:
|
||||||
metadata["webui"] = True
|
metadata["webui"] = True
|
||||||
@@ -1452,14 +1583,34 @@ class WebSocketChannel(BaseChannel):
|
|||||||
msg.metadata.get("_progress")
|
msg.metadata.get("_progress")
|
||||||
or msg.metadata.get("_turn_end")
|
or msg.metadata.get("_turn_end")
|
||||||
or msg.metadata.get("_session_updated")
|
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)
|
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
|
||||||
else:
|
else:
|
||||||
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
||||||
return
|
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.
|
# Signal that the agent has fully finished processing the current turn.
|
||||||
if msg.metadata.get("_turn_end"):
|
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
|
return
|
||||||
if msg.metadata.get("_session_updated"):
|
if msg.metadata.get("_session_updated"):
|
||||||
await self.send_session_updated(msg.chat_id)
|
await self.send_session_updated(msg.chat_id)
|
||||||
@@ -1481,8 +1632,14 @@ class WebSocketChannel(BaseChannel):
|
|||||||
payload["media_urls"] = urls
|
payload["media_urls"] = urls
|
||||||
if msg.reply_to:
|
if msg.reply_to:
|
||||||
payload["reply_to"] = 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"):
|
if msg.metadata.get("_tool_events"):
|
||||||
payload["tool_events"] = msg.metadata["_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
|
# Mark intermediate agent breadcrumbs (tool-call hints, generic
|
||||||
# progress strings) so WS clients can render them as subordinate
|
# progress strings) so WS clients can render them as subordinate
|
||||||
# trace rows rather than conversational replies.
|
# trace rows rather than conversational replies.
|
||||||
@@ -1490,6 +1647,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
payload["kind"] = "tool_hint"
|
payload["kind"] = "tool_hint"
|
||||||
elif msg.metadata.get("_progress"):
|
elif msg.metadata.get("_progress"):
|
||||||
payload["kind"] = "progress"
|
payload["kind"] = "progress"
|
||||||
|
self._try_append_webui_transcript(msg.chat_id, payload)
|
||||||
raw = json.dumps(payload, ensure_ascii=False)
|
raw = json.dumps(payload, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" ")
|
await self._safe_send_to(connection, raw, label=" ")
|
||||||
@@ -1501,7 +1659,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
|
"""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
|
rendered above the active assistant bubble with a shimmer header
|
||||||
until the matching ``reasoning_end`` arrives.
|
until the matching ``reasoning_end`` arrives.
|
||||||
"""
|
"""
|
||||||
@@ -1517,6 +1675,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
stream_id = meta.get("_stream_id")
|
stream_id = meta.get("_stream_id")
|
||||||
if stream_id is not None:
|
if stream_id is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = stream_id
|
||||||
|
self._try_append_webui_transcript(chat_id, body)
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" reasoning ")
|
await self._safe_send_to(connection, raw, label=" reasoning ")
|
||||||
@@ -1538,6 +1697,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
stream_id = meta.get("_stream_id")
|
stream_id = meta.get("_stream_id")
|
||||||
if stream_id is not None:
|
if stream_id is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = stream_id
|
||||||
|
self._try_append_webui_transcript(chat_id, body)
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" reasoning_end ")
|
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:
|
if meta.get("_stream_id") is not None:
|
||||||
body["stream_id"] = meta["_stream_id"]
|
body["stream_id"] = meta["_stream_id"]
|
||||||
|
self._try_append_webui_transcript(chat_id, body)
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" stream ")
|
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."""
|
"""Signal that the agent has fully finished processing the current turn."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
if not conns:
|
if not conns:
|
||||||
return
|
return
|
||||||
body: dict[str, Any] = {"event": "turn_end", "chat_id": chat_id}
|
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)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
for connection in conns:
|
for connection in conns:
|
||||||
await self._safe_send_to(connection, raw, label=" turn_end ")
|
await self._safe_send_to(connection, raw, label=" turn_end ")
|
||||||
|
|
||||||
|
async def send_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:
|
async def send_session_updated(self, chat_id: str) -> None:
|
||||||
"""Notify clients that session metadata changed outside the main turn."""
|
"""Notify clients that session metadata changed outside the main turn."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
@@ -1592,7 +1796,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
model_name: Any,
|
model_name: Any,
|
||||||
model_preset: Any = None,
|
model_preset: Any = None,
|
||||||
) -> 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)
|
conns = list(self._conn_chats)
|
||||||
if not conns or not isinstance(model_name, str) or not model_name.strip():
|
if not conns or not isinstance(model_name, str) or not model_name.strip():
|
||||||
return
|
return
|
||||||
|
|||||||
+13
-1
@@ -829,9 +829,21 @@ def _run_gateway(
|
|||||||
|
|
||||||
cron.on_job = on_cron_job
|
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
|
# Create channel manager (forwards SessionManager so the WebSocket channel
|
||||||
# can serve the embedded webui's REST surface).
|
# 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]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def get_model_context_limit(model: str, provider: str = "auto") -> int | None:
|
|||||||
return 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 []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ def _input_model_with_autocomplete(
|
|||||||
def __init__(self, provider_name: str):
|
def __init__(self, provider_name: str):
|
||||||
self.provider = provider_name
|
self.provider = provider_name
|
||||||
|
|
||||||
def get_completions(self, document, complete_event):
|
def get_completions(self, document, _complete_event):
|
||||||
text = document.text_before_cursor
|
text = document.text_before_cursor
|
||||||
suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
|
suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
|
||||||
for model in suggestions:
|
for model in suggestions:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -72,6 +73,13 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
|
|||||||
"history",
|
"history",
|
||||||
"[n]",
|
"[n]",
|
||||||
),
|
),
|
||||||
|
BuiltinCommandSpec(
|
||||||
|
"/goal",
|
||||||
|
"Start long-running goal",
|
||||||
|
"Tell the agent to treat the request as a long-running goal.",
|
||||||
|
"activity",
|
||||||
|
"<goal>",
|
||||||
|
),
|
||||||
BuiltinCommandSpec(
|
BuiltinCommandSpec(
|
||||||
"/dream",
|
"/dream",
|
||||||
"Run 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:
|
async def cmd_pairing(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""List, approve, deny or revoke pairing requests."""
|
"""List, approve, deny or revoke pairing requests."""
|
||||||
from nanobot.pairing import PAIRING_COMMAND_META_KEY, handle_pairing_command
|
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.prefix("/model ", cmd_model)
|
||||||
router.exact("/history", cmd_history)
|
router.exact("/history", cmd_history)
|
||||||
router.prefix("/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", cmd_dream)
|
||||||
router.exact("/dream-log", cmd_dream_log)
|
router.exact("/dream-log", cmd_dream_log)
|
||||||
router.prefix("/dream-log ", cmd_dream_log)
|
router.prefix("/dream-log ", cmd_dream_log)
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ class CommandRouter:
|
|||||||
(e.g. /stop, /restart).
|
(e.g. /stop, /restart).
|
||||||
2. *exact* — exact-match commands handled inside the dispatch lock.
|
2. *exact* — exact-match commands handled inside the dispatch lock.
|
||||||
3. *prefix* — longest-prefix-first match (e.g. "/team ").
|
3. *prefix* — longest-prefix-first match (e.g. "/team ").
|
||||||
4. *interceptors* — fallback predicates (e.g. team-mode active check).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._priority: dict[str, Handler] = {}
|
self._priority: dict[str, Handler] = {}
|
||||||
self._exact: dict[str, Handler] = {}
|
self._exact: dict[str, Handler] = {}
|
||||||
self._prefix: list[tuple[str, Handler]] = []
|
self._prefix: list[tuple[str, Handler]] = []
|
||||||
self._interceptors: list[Handler] = []
|
|
||||||
|
|
||||||
def priority(self, cmd: str, handler: Handler) -> None:
|
def priority(self, cmd: str, handler: Handler) -> None:
|
||||||
self._priority[cmd] = handler
|
self._priority[cmd] = handler
|
||||||
@@ -51,16 +49,13 @@ class CommandRouter:
|
|||||||
self._prefix.append((pfx, handler))
|
self._prefix.append((pfx, handler))
|
||||||
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
|
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:
|
def is_priority(self, text: str) -> bool:
|
||||||
return text.strip().lower() in self._priority
|
return text.strip().lower() in self._priority
|
||||||
|
|
||||||
def is_dispatchable_command(self, text: str) -> bool:
|
def is_dispatchable_command(self, text: str) -> bool:
|
||||||
"""Check whether *text* matches any non-priority command tier (exact or prefix).
|
"""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.
|
If this returns True, ``dispatch()`` is guaranteed to match a handler.
|
||||||
"""
|
"""
|
||||||
cmd = text.strip().lower()
|
cmd = text.strip().lower()
|
||||||
@@ -79,7 +74,7 @@ class CommandRouter:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def dispatch(self, ctx: CommandContext) -> OutboundMessage | 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()
|
cmd = ctx.raw.lower()
|
||||||
|
|
||||||
if handler := self._exact.get(cmd):
|
if handler := self._exact.get(cmd):
|
||||||
@@ -90,9 +85,4 @@ class CommandRouter:
|
|||||||
ctx.args = ctx.raw[len(pfx):]
|
ctx.args = ctx.raw[len(pfx):]
|
||||||
return await handler(ctx)
|
return await handler(ctx)
|
||||||
|
|
||||||
for interceptor in self._interceptors:
|
|
||||||
result = await interceptor(ctx)
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from nanobot.config.paths import (
|
|||||||
get_logs_dir,
|
get_logs_dir,
|
||||||
get_media_dir,
|
get_media_dir,
|
||||||
get_runtime_subdir,
|
get_runtime_subdir,
|
||||||
|
get_webui_dir,
|
||||||
get_workspace_path,
|
get_workspace_path,
|
||||||
)
|
)
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
@@ -24,6 +25,7 @@ __all__ = [
|
|||||||
"get_media_dir",
|
"get_media_dir",
|
||||||
"get_cron_dir",
|
"get_cron_dir",
|
||||||
"get_logs_dir",
|
"get_logs_dir",
|
||||||
|
"get_webui_dir",
|
||||||
"get_workspace_path",
|
"get_workspace_path",
|
||||||
"is_default_workspace",
|
"is_default_workspace",
|
||||||
"get_cli_history_path",
|
"get_cli_history_path",
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ def get_logs_dir() -> Path:
|
|||||||
return get_runtime_subdir("logs")
|
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:
|
def get_workspace_path(workspace: str | None = None) -> Path:
|
||||||
"""Resolve and ensure the agent workspace path."""
|
"""Resolve and ensure the agent workspace path."""
|
||||||
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ class ModelPresetConfig(Base):
|
|||||||
|
|
||||||
model: str
|
model: str
|
||||||
provider: str = "auto"
|
provider: str = "auto"
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 32_000
|
||||||
context_window_tokens: int = 65_536
|
context_window_tokens: int = 262_144
|
||||||
temperature: float = 0.1
|
temperature: float = 0.1
|
||||||
reasoning_effort: str | None = None
|
reasoning_effort: str | None = None
|
||||||
|
|
||||||
@@ -116,8 +116,8 @@ class AgentDefaults(Base):
|
|||||||
provider: str = (
|
provider: str = (
|
||||||
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
||||||
)
|
)
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 32_000
|
||||||
context_window_tokens: int = 65_536
|
context_window_tokens: int = 262_144
|
||||||
context_block_limit: int | None = None
|
context_block_limit: int | None = None
|
||||||
temperature: float = 0.1
|
temperature: float = 0.1
|
||||||
fallback_models: list[FallbackCandidate] = Field(default_factory=list)
|
fallback_models: list[FallbackCandidate] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -589,6 +589,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
kwargs = self._build_kwargs(
|
kwargs = self._build_kwargs(
|
||||||
messages, tools, model, max_tokens, temperature,
|
messages, tools, model, max_tokens, temperature,
|
||||||
@@ -597,17 +598,33 @@ class AnthropicProvider(LLMProvider):
|
|||||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||||
try:
|
try:
|
||||||
async with self._client.messages.stream(**kwargs) as stream:
|
async with self._client.messages.stream(**kwargs) as stream:
|
||||||
if on_content_delta:
|
if on_content_delta or on_thinking_delta:
|
||||||
stream_iter = stream.text_stream.__aiter__()
|
# 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:
|
while True:
|
||||||
try:
|
try:
|
||||||
text = await asyncio.wait_for(
|
chunk = await asyncio.wait_for(
|
||||||
stream_iter.__anext__(),
|
stream.__anext__(),
|
||||||
timeout=idle_timeout_s,
|
timeout=idle_timeout_s,
|
||||||
)
|
)
|
||||||
except StopAsyncIteration:
|
except StopAsyncIteration:
|
||||||
break
|
break
|
||||||
await on_content_delta(text)
|
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(
|
response = await asyncio.wait_for(
|
||||||
stream.get_final_message(),
|
stream.get_final_message(),
|
||||||
timeout=idle_timeout_s,
|
timeout=idle_timeout_s,
|
||||||
|
|||||||
@@ -157,7 +157,9 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
_ = on_thinking_delta
|
||||||
body = self._build_body(
|
body = self._build_body(
|
||||||
messages, tools, model, max_tokens, temperature,
|
messages, tools, model, max_tokens, temperature,
|
||||||
reasoning_effort, tool_choice,
|
reasoning_effort, tool_choice,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from contextlib import suppress
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email.utils import parsedate_to_datetime
|
from email.utils import parsedate_to_datetime
|
||||||
@@ -499,14 +499,21 @@ class LLMProvider(ABC):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Stream a chat completion, calling *on_content_delta* for each text chunk.
|
"""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
|
Returns the same ``LLMResponse`` as :meth:`chat`. The default
|
||||||
implementation falls back to a non-streaming call and delivers the
|
implementation falls back to a non-streaming call and delivers the
|
||||||
full content as a single delta. Providers that support native
|
full content as a single delta. Providers that support native
|
||||||
streaming should override this method.
|
streaming should override this method.
|
||||||
"""
|
"""
|
||||||
|
_ = on_thinking_delta
|
||||||
response = await self.chat(
|
response = await self.chat(
|
||||||
messages=messages, tools=tools, model=model,
|
messages=messages, tools=tools, model=model,
|
||||||
max_tokens=max_tokens, temperature=temperature,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
@@ -535,6 +542,7 @@ class LLMProvider(ABC):
|
|||||||
reasoning_effort: object = _SENTINEL,
|
reasoning_effort: object = _SENTINEL,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
retry_mode: str = "standard",
|
retry_mode: str = "standard",
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
@@ -551,6 +559,7 @@ class LLMProvider(ABC):
|
|||||||
max_tokens=max_tokens, temperature=temperature,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
|
on_thinking_delta=on_thinking_delta,
|
||||||
)
|
)
|
||||||
return await self._run_with_retry(
|
return await self._run_with_retry(
|
||||||
self._safe_chat_stream,
|
self._safe_chat_stream,
|
||||||
|
|||||||
@@ -703,7 +703,9 @@ class BedrockProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
_ = on_thinking_delta
|
||||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
reasoning_parts: list[str] = []
|
reasoning_parts: list[str] = []
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from collections.abc import Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -242,6 +242,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, object] | None = None,
|
tool_choice: str | dict[str, object] | None = None,
|
||||||
on_content_delta: Callable[[str], None] | 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()
|
await self._refresh_client_api_key()
|
||||||
return await super().chat_stream(
|
return await super().chat_stream(
|
||||||
@@ -253,4 +254,5 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
tool_choice=tool_choice,
|
tool_choice=tool_choice,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
|
on_thinking_delta=on_thinking_delta,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,7 +99,9 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
|
_ = on_thinking_delta
|
||||||
return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta)
|
return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice, on_content_delta)
|
||||||
|
|
||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
|
|||||||
@@ -1160,6 +1160,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
|
||||||
try:
|
try:
|
||||||
@@ -1223,10 +1224,19 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
except StopAsyncIteration:
|
except StopAsyncIteration:
|
||||||
break
|
break
|
||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
if on_content_delta and chunk.choices:
|
if chunk.choices:
|
||||||
text = getattr(chunk.choices[0].delta, "content", None)
|
delta_obj = chunk.choices[0].delta
|
||||||
if text:
|
if on_content_delta:
|
||||||
await on_content_delta(text)
|
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)
|
return self._parse_chunks(chunks)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -20,6 +20,7 @@ from nanobot.utils.helpers import (
|
|||||||
image_placeholder_text,
|
image_placeholder_text,
|
||||||
safe_filename,
|
safe_filename,
|
||||||
)
|
)
|
||||||
|
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||||
|
|
||||||
FILE_MAX_MESSAGES = 2000
|
FILE_MAX_MESSAGES = 2000
|
||||||
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
||||||
@@ -65,6 +66,14 @@ def _text_preview(content: Any) -> str:
|
|||||||
return text
|
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
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
"""A conversation session."""
|
"""A conversation session."""
|
||||||
@@ -601,7 +610,7 @@ class SessionManager:
|
|||||||
item = json.loads(line)
|
item = json.loads(line)
|
||||||
if item.get("_type") == "metadata":
|
if item.get("_type") == "metadata":
|
||||||
continue
|
continue
|
||||||
text = _text_preview(item.get("content"))
|
text = _message_preview_text(item)
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
if item.get("role") == "user":
|
if item.get("role") == "user":
|
||||||
@@ -634,7 +643,7 @@ class SessionManager:
|
|||||||
(
|
(
|
||||||
text
|
text
|
||||||
for msg in repaired.messages
|
for msg in repaired.messages
|
||||||
if (text := _text_preview(msg.get("content")))
|
if (text := _message_preview_text(msg))
|
||||||
),
|
),
|
||||||
"",
|
"",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,4 +28,5 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai
|
|||||||
| `summarize` | Summarize URLs, files, and YouTube videos |
|
| `summarize` | Summarize URLs, files, and YouTube videos |
|
||||||
| `tmux` | Remote-control tmux sessions |
|
| `tmux` | Remote-control tmux sessions |
|
||||||
| `clawhub` | Search and install skills from ClawHub registry |
|
| `clawhub` | Search and install skills from ClawHub registry |
|
||||||
| `skill-creator` | Create new skills |
|
| `skill-creator` | Create new skills |
|
||||||
|
| `long-goal` | Sustained objectives: `long_task`, `complete_goal`, idempotent goal wording |
|
||||||
@@ -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 A–B”, “only files under `src/`”). Reduces drift when the model re-enters the goal cold.
|
||||||
|
|
||||||
|
5. **Explicit done-ness** — State how you will know you’re 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.
|
||||||
@@ -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]))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
"""Tests for ContextBuilder — system prompt and message assembly."""
|
"""Tests for ContextBuilder — system prompt and message assembly."""
|
||||||
|
|
||||||
import base64
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -285,6 +283,22 @@ class TestBuildMessages:
|
|||||||
assert "[Runtime Context" in user_msg
|
assert "[Runtime Context" in user_msg
|
||||||
assert "hello" 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):
|
def test_consecutive_same_role_merged(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
history = [{"role": "user", "content": "previous user message"}]
|
history = [{"role": "user", "content": "previous user message"}]
|
||||||
@@ -308,26 +322,3 @@ class TestBuildMessages:
|
|||||||
user_msg = messages[-1]["content"]
|
user_msg = messages[-1]["content"]
|
||||||
assert isinstance(user_msg, list)
|
assert isinstance(user_msg, list)
|
||||||
assert any(b.get("type") == "image_url" for b in user_msg)
|
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
|
|
||||||
|
|||||||
@@ -204,13 +204,16 @@ class TestToolEventProgress:
|
|||||||
if not m.metadata.get("_stream_delta")
|
if not m.metadata.get("_stream_delta")
|
||||||
and not m.metadata.get("_stream_end")
|
and not m.metadata.get("_stream_end")
|
||||||
and not m.metadata.get("_turn_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 [m.content for m in deltas] == ["Hel", "lo"]
|
||||||
assert len(stream_end) == 1
|
assert len(stream_end) == 1
|
||||||
assert final[-1].content == "Hello"
|
assert final[-1].content == "Hello"
|
||||||
assert final[-1].metadata.get("_streamed") is True
|
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()
|
provider.chat_with_retry.assert_not_awaited()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -286,11 +289,15 @@ class TestToolEventProgress:
|
|||||||
while bus.outbound_size > 0:
|
while bus.outbound_size > 0:
|
||||||
outbound.append(await bus.consume_outbound())
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
assert outbound[-2].content == "Done"
|
done_msgs = [m for m in outbound if m.content == "Done"]
|
||||||
assert (outbound[-2].metadata or {}).get("_turn_end") is not True
|
assert len(done_msgs) == 1
|
||||||
assert outbound[-1].content == ""
|
assert not done_msgs[0].metadata.get("_turn_end")
|
||||||
assert (outbound[-1].metadata or {}).get("_turn_end") is True
|
|
||||||
assert outbound[-1].chat_id == "chat1"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None:
|
async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None:
|
||||||
@@ -323,13 +330,27 @@ class TestToolEventProgress:
|
|||||||
metadata={"webui": True},
|
metadata={"webui": True},
|
||||||
)), timeout=0.5)
|
)), timeout=0.5)
|
||||||
|
|
||||||
outbound = [await bus.consume_outbound(), await bus.consume_outbound()]
|
outbound: list = []
|
||||||
assert outbound[0].content == "Done"
|
for _ in range(12):
|
||||||
assert (outbound[1].metadata or {}).get("_turn_end") is True
|
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)
|
await asyncio.wait_for(title_started.wait(), timeout=0.5)
|
||||||
release_title.set()
|
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 (session_updated.metadata or {}).get("_session_updated") is True
|
||||||
assert provider.chat_with_retry.await_count == 2
|
assert provider.chat_with_retry.await_count == 2
|
||||||
|
|||||||
@@ -177,6 +177,25 @@ def test_save_turn_keeps_tool_results_under_16k() -> None:
|
|||||||
assert session.messages[0]["content"] == content
|
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:
|
def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(
|
session = Session(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.hook import AgentHook
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ class _RecordingHook(AgentHook):
|
|||||||
async def test_runner_preserves_reasoning_fields_in_assistant_history():
|
async def test_runner_preserves_reasoning_fields_in_assistant_history():
|
||||||
"""Reasoning fields ride along on the persisted assistant message so
|
"""Reasoning fields ride along on the persisted assistant message so
|
||||||
follow-up provider calls retain the model's prior thinking context."""
|
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()
|
provider = MagicMock()
|
||||||
captured_second_call: list[dict] = []
|
captured_second_call: list[dict] = []
|
||||||
@@ -86,7 +86,7 @@ async def test_runner_preserves_reasoning_fields_in_assistant_history():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_emits_anthropic_thinking_blocks():
|
async def test_runner_emits_anthropic_thinking_blocks():
|
||||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ async def test_runner_emits_anthropic_thinking_blocks():
|
|||||||
async def test_runner_emits_inline_think_content_as_reasoning():
|
async def test_runner_emits_inline_think_content_as_reasoning():
|
||||||
"""Models embedding reasoning in <think>...</think> blocks should have
|
"""Models embedding reasoning in <think>...</think> blocks should have
|
||||||
that content extracted and emitted, and stripped from the answer."""
|
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()
|
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():
|
async def test_runner_prefers_reasoning_content_over_inline_think():
|
||||||
"""Fallback priority: dedicated reasoning_content wins; inline <think>
|
"""Fallback priority: dedicated reasoning_content wins; inline <think>
|
||||||
is still scrubbed from the answer content."""
|
is still scrubbed from the answer content."""
|
||||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
|
||||||
provider = MagicMock()
|
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
|
"""`reasoning_content` arrives only on the final response; streaming the
|
||||||
answer must not suppress it (the answer stream and the reasoning channel
|
answer must not suppress it (the answer stream and the reasoning channel
|
||||||
are independent — only the reasoning-already-emitted bit matters)."""
|
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 = MagicMock()
|
||||||
provider.supports_progress_deltas = True
|
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():
|
async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
|
||||||
"""Inline `<think>` blocks streamed incrementally during the answer
|
"""Inline `<think>` blocks streamed incrementally during the answer
|
||||||
stream must not be re-emitted from the final response."""
|
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 = MagicMock()
|
||||||
provider.supports_progress_deltas = True
|
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
|
"""A non-streaming response carrying ``reasoning_content`` must emit
|
||||||
both a reasoning delta and an end marker so channels can finalize the
|
both a reasoning delta and an end marker so channels can finalize the
|
||||||
in-place bubble."""
|
in-place bubble."""
|
||||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
|
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
|
|
||||||
@@ -319,3 +319,53 @@ async def test_runner_closes_reasoning_stream_after_one_shot_response():
|
|||||||
assert result.final_content == "answer"
|
assert result.final_content == "answer"
|
||||||
assert hook.emitted == ["hidden thought"]
|
assert hook.emitted == ["hidden thought"]
|
||||||
assert hook.end_calls == 1
|
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"]
|
||||||
|
|||||||
@@ -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())]
|
||||||
@@ -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"
|
||||||
@@ -13,7 +13,7 @@ import websockets
|
|||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed
|
||||||
from websockets.frames import Close
|
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.bus.queue import MessageBus
|
||||||
from nanobot.channels.websocket import (
|
from nanobot.channels.websocket import (
|
||||||
WebSocketChannel,
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_delta_removes_connection_on_connection_closed() -> None:
|
async def test_send_delta_removes_connection_on_connection_closed() -> None:
|
||||||
bus = MagicMock()
|
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"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_session_updated_emits_session_updated_event() -> None:
|
async def test_send_session_updated_emits_session_updated_event() -> None:
|
||||||
bus = MagicMock()
|
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:
|
def test_is_valid_chat_id(value: Any, expected: bool) -> None:
|
||||||
assert _is_valid_chat_id(value) is expected
|
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"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ def _ch(
|
|||||||
session_manager: SessionManager | None = None,
|
session_manager: SessionManager | None = None,
|
||||||
static_dist_path: Path | None = None,
|
static_dist_path: Path | None = None,
|
||||||
port: int = _PORT,
|
port: int = _PORT,
|
||||||
|
runtime_model_name: Any | None = None,
|
||||||
**extra: Any,
|
**extra: Any,
|
||||||
) -> WebSocketChannel:
|
) -> WebSocketChannel:
|
||||||
cfg: dict[str, Any] = {
|
cfg: dict[str, Any] = {
|
||||||
@@ -33,11 +34,16 @@ def _ch(
|
|||||||
"websocketRequiresToken": False,
|
"websocketRequiresToken": False,
|
||||||
}
|
}
|
||||||
cfg.update(extra)
|
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(
|
return WebSocketChannel(
|
||||||
cfg,
|
cfg,
|
||||||
bus,
|
bus,
|
||||||
session_manager=session_manager,
|
**ws_kwargs,
|
||||||
static_dist_path=static_dist_path,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -171,8 +177,14 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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")
|
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)
|
channel = _ch(bus, session_manager=sm, port=29903)
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
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")
|
path = sm._get_session_path("websocket:doomed")
|
||||||
assert path.exists()
|
assert path.exists()
|
||||||
|
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key('websocket:doomed')}.jsonl"
|
||||||
|
assert webui_path.is_file()
|
||||||
resp = await _http_get(
|
resp = await _http_get(
|
||||||
"http://127.0.0.1:29903/api/sessions/websocket:doomed/delete",
|
"http://127.0.0.1:29903/api/sessions/websocket:doomed/delete",
|
||||||
headers=auth,
|
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.status_code == 200
|
||||||
assert resp.json()["deleted"] is True
|
assert resp.json()["deleted"] is True
|
||||||
assert not path.exists()
|
assert not path.exists()
|
||||||
|
assert not webui_path.exists()
|
||||||
finally:
|
finally:
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
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:
|
def test_wildcard_ipv6_with_secret_is_valid(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus, host="::", tokenIssueSecret="s3cret")
|
channel = _ch(bus, host="::", tokenIssueSecret="s3cret")
|
||||||
resp = channel._handle_webui_bootstrap(
|
resp = channel._handle_bootstrap(
|
||||||
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
|
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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:
|
def test_bootstrap_accepts_static_token_as_secret(bus: MagicMock) -> None:
|
||||||
"""When only token (not token_issue_secret) is set, bootstrap accepts it."""
|
"""When only token (not token_issue_secret) is set, bootstrap accepts it."""
|
||||||
channel = _ch(bus, host="0.0.0.0", token="static-tok")
|
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"})
|
_REMOTE, _FakeReq({"Authorization": "Bearer static-tok"})
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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:
|
def test_localhost_without_auth_is_valid(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus, host="127.0.0.1")
|
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
|
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:
|
def test_bootstrap_rejects_wrong_secret(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct")
|
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="correct")
|
||||||
resp = channel._handle_webui_bootstrap(
|
resp = channel._handle_bootstrap(
|
||||||
_REMOTE, _FakeReq({"Authorization": "Bearer wrong"})
|
_REMOTE, _FakeReq({"Authorization": "Bearer wrong"})
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
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:
|
def test_bootstrap_accepts_remote_with_valid_secret(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
|
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
|
||||||
resp = channel._handle_webui_bootstrap(
|
resp = channel._handle_bootstrap(
|
||||||
_REMOTE, _FakeReq({"Authorization": "Bearer s3cret"})
|
_REMOTE, _FakeReq({"Authorization": "Bearer s3cret"})
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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:
|
def test_bootstrap_accepts_x_nanobot_auth_header(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
|
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"})
|
_REMOTE, _FakeReq({"X-Nanobot-Auth": "s3cret"})
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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:
|
def test_bootstrap_secret_also_enforced_on_localhost(bus: MagicMock) -> None:
|
||||||
"""When secret is set, even localhost must provide it (reverse-proxy safety)."""
|
"""When secret is set, even localhost must provide it (reverse-proxy safety)."""
|
||||||
channel = _ch(bus, host="0.0.0.0", tokenIssueSecret="s3cret")
|
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
|
assert resp.status_code == 401
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ class TestRestartCommand:
|
|||||||
assert response is not None
|
assert response is not None
|
||||||
assert "Model: test-model" in response.content
|
assert "Model: test-model" in response.content
|
||||||
assert "Tokens: 0 in / 0 out" 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 "Session: 3 messages" in response.content
|
||||||
assert "Uptime: 2m 5s" in response.content
|
assert "Uptime: 2m 5s" in response.content
|
||||||
assert "Tasks: 0 active" in response.content
|
assert "Tasks: 0 active" in response.content
|
||||||
@@ -240,7 +240,7 @@ class TestRestartCommand:
|
|||||||
|
|
||||||
assert response is not None
|
assert response is not None
|
||||||
assert "Tokens: 1200 in / 34 out" in response.content
|
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
|
assert "Tasks: 0 active" in response.content
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.command.builtin import (
|
from nanobot.command.builtin import (
|
||||||
build_help_text,
|
build_help_text,
|
||||||
builtin_command_palette,
|
builtin_command_palette,
|
||||||
|
cmd_goal,
|
||||||
cmd_model,
|
cmd_model,
|
||||||
register_builtin_commands,
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_model_command_lists_current_and_available_presets(tmp_path) -> None:
|
async def test_model_command_lists_current_and_available_presets(tmp_path) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
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 any(item["command"] == "/model" and item["arg_hint"] == "[preset]" for item in palette)
|
||||||
assert "/model [preset]" in build_help_text()
|
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")
|
||||||
assert router.is_dispatchable_command("/dream-log")
|
assert router.is_dispatchable_command("/dream-log")
|
||||||
assert router.is_dispatchable_command("/dream-restore")
|
assert router.is_dispatchable_command("/dream-restore")
|
||||||
|
assert router.is_dispatchable_command("/goal")
|
||||||
assert router.is_dispatchable_command("/pairing")
|
assert router.is_dispatchable_command("/pairing")
|
||||||
|
|
||||||
def test_prefix_commands_match(self, router: CommandRouter) -> None:
|
def test_prefix_commands_match(self, router: CommandRouter) -> None:
|
||||||
assert router.is_dispatchable_command("/dream-log abc123")
|
assert router.is_dispatchable_command("/dream-log abc123")
|
||||||
assert router.is_dispatchable_command("/dream-restore def456")
|
assert router.is_dispatchable_command("/dream-restore def456")
|
||||||
assert router.is_dispatchable_command("/model fast")
|
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 list")
|
||||||
assert router.is_dispatchable_command("/pairing approve CODE")
|
assert router.is_dispatchable_command("/pairing approve CODE")
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path)
|
|||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
|
|
||||||
assert config.agents.defaults.max_tokens == 1234
|
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")
|
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"]
|
defaults = saved["agents"]["defaults"]
|
||||||
|
|
||||||
assert defaults["maxTokens"] == 2222
|
assert defaults["maxTokens"] == 2222
|
||||||
assert defaults["contextWindowTokens"] == 65_536
|
assert defaults["contextWindowTokens"] == 262_144
|
||||||
assert "memoryWindow" not in defaults
|
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()
|
||||||
@@ -98,6 +98,110 @@ def _fake_chat_stream(text: str = "ok"):
|
|||||||
return _stream()
|
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):
|
class _FakeResponsesError(Exception):
|
||||||
def __init__(self, status_code: int, text: str):
|
def __init__(self, status_code: int, text: str):
|
||||||
super().__init__(text)
|
super().__init__(text)
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
}
|
||||||
@@ -305,3 +305,133 @@ async def test_message_tool_resolves_mixed_media_paths() -> None:
|
|||||||
"https://example.com/url.png",
|
"https://example.com/url.png",
|
||||||
"http://example.com/http.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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 == {}
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ChatSummary } from "@/lib/types";
|
import type { ChatSummary } from "@/lib/types";
|
||||||
|
|
||||||
@@ -20,12 +19,6 @@ interface ChatListProps {
|
|||||||
emptyLabel?: string;
|
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({
|
export function ChatList({
|
||||||
sessions,
|
sessions,
|
||||||
activeKey,
|
activeKey,
|
||||||
@@ -58,8 +51,8 @@ export function ChatList({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
|
||||||
<div className="space-y-3 px-2 py-1.5">
|
<div className="min-w-0 space-y-3 px-2 py-1.5">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<section key={group.label} aria-label={group.label}>
|
<section key={group.label} aria-label={group.label}>
|
||||||
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
<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">
|
<ul className="space-y-0.5">
|
||||||
{group.sessions.map((s) => {
|
{group.sessions.map((s) => {
|
||||||
const active = s.key === activeKey;
|
const active = s.key === activeKey;
|
||||||
const title = titleFor(
|
const fallbackTitle = t("chat.fallbackTitle", {
|
||||||
s,
|
id: s.chatId.slice(0, 6),
|
||||||
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
|
});
|
||||||
);
|
const rawLabel = (s.title || s.preview)?.trim();
|
||||||
|
const title = rawLabel || fallbackTitle;
|
||||||
return (
|
return (
|
||||||
<li key={s.key}>
|
<li key={s.key} className="min-w-0">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
active
|
||||||
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
|
? "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",
|
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||||||
@@ -85,14 +79,15 @@ export function ChatList({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(s.key)}
|
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>
|
<span className="block w-full truncate font-medium leading-5">{title}</span>
|
||||||
</button>
|
</button>
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu modal={false}>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
className={cn(
|
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",
|
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
|
||||||
"focus-visible:opacity-100",
|
"focus-visible:opacity-100",
|
||||||
active && "opacity-100",
|
active && "opacity-100",
|
||||||
@@ -124,7 +119,7 @@ export function ChatList({
|
|||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||||
import { MarkdownText } from "@/components/MarkdownText";
|
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { formatTurnLatency } from "@/lib/format";
|
||||||
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
|
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: UIMessage;
|
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
|
* Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
|
||||||
* collapsible group so intermediate steps never masquerade as replies.
|
* 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 { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const copyResetRef = useRef<number | null>(null);
|
const copyResetRef = useRef<number | null>(null);
|
||||||
@@ -89,6 +102,14 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
|
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
|
||||||
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
|
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
|
||||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
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 (
|
return (
|
||||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||||
{hasReasoning ? (
|
{hasReasoning ? (
|
||||||
@@ -99,27 +120,36 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
) : empty && message.isStreaming ? null : (
|
) : empty && message.isStreaming ? null : (
|
||||||
<>
|
<>
|
||||||
<MarkdownText>{message.content}</MarkdownText>
|
<MarkdownText>{message.content}</MarkdownText>
|
||||||
{message.isStreaming && <StreamCursor />}
|
|
||||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||||
{showAssistantActions ? (
|
{showAssistantFooterRow ? (
|
||||||
<div className="mt-2 flex items-center gap-1 text-muted-foreground">
|
<div className="mt-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
|
||||||
<button
|
{showCopyButton ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={onCopyAssistantReply}
|
type="button"
|
||||||
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
|
onClick={onCopyAssistantReply}
|
||||||
title={copied ? t("message.copiedReply") : t("message.copyReply")}
|
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
|
||||||
className={cn(
|
title={copied ? t("message.copiedReply") : t("message.copyReply")}
|
||||||
"inline-flex h-8 w-8 items-center justify-center rounded-full",
|
className={cn(
|
||||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||||
)}
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
>
|
)}
|
||||||
{copied ? (
|
>
|
||||||
<Check className="h-4 w-4" aria-hidden />
|
{copied ? (
|
||||||
) : (
|
<Check className="h-4 w-4" aria-hidden />
|
||||||
<Copy className="h-4 w-4" aria-hidden />
|
) : (
|
||||||
)}
|
<Copy className="h-4 w-4" aria-hidden />
|
||||||
</button>
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{showLatencyFooter ? (
|
||||||
|
<span
|
||||||
|
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
|
||||||
|
title={t("message.turnLatencyTitle")}
|
||||||
|
>
|
||||||
|
{formatTurnLatency(latencyMs)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
@@ -187,14 +217,34 @@ function MediaCell({ media }: { media: UIMediaAttachment }) {
|
|||||||
: t("message.fileAttachment", { defaultValue: "File attachment" });
|
: t("message.fileAttachment", { defaultValue: "File attachment" });
|
||||||
const Icon = media.kind === "video" ? PlaySquare : FileIcon;
|
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 (
|
return (
|
||||||
<div
|
<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"
|
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}
|
title={media.name ?? undefined}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 flex-none" aria-hidden />
|
{inner}
|
||||||
<span className="truncate">{media.name ?? label}</span>
|
|
||||||
</div>
|
</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. */
|
/** Pre-token-arrival placeholder: three bouncing dots. */
|
||||||
function TypingDots() {
|
function TypingDots() {
|
||||||
const { t } = useTranslation();
|
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 {
|
interface TraceGroupProps {
|
||||||
message: UIMessage;
|
message: UIMessage;
|
||||||
animClass: string;
|
animClass: string;
|
||||||
@@ -389,7 +558,7 @@ interface TraceGroupProps {
|
|||||||
* collapsed because tool traces are supporting evidence, not the answer.
|
* collapsed because tool traces are supporting evidence, not the answer.
|
||||||
* A single click expands the exact calls when the user wants details.
|
* A single click expands the exact calls when the user wants details.
|
||||||
*/
|
*/
|
||||||
function TraceGroup({ message, animClass }: TraceGroupProps) {
|
export function TraceGroup({ message, animClass }: TraceGroupProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lines = message.traces ?? [message.content];
|
const lines = message.traces ?? [message.content];
|
||||||
const count = lines.length;
|
const count = lines.length;
|
||||||
@@ -439,79 +608,3 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label={t("sidebar.navigation")}
|
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">
|
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
|
||||||
<picture className="block min-w-0">
|
<picture className="block min-w-0">
|
||||||
@@ -104,7 +104,7 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
{t("sidebar.newChat")}
|
{t("sidebar.newChat")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<ChatList
|
<ChatList
|
||||||
sessions={filteredSessions}
|
sessions={filteredSessions}
|
||||||
activeKey={props.activeKey}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
History,
|
History,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
|
Target,
|
||||||
Undo2,
|
Undo2,
|
||||||
X,
|
X,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -29,6 +31,12 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
import {
|
import {
|
||||||
useAttachedImages,
|
useAttachedImages,
|
||||||
type AttachedImage,
|
type AttachedImage,
|
||||||
@@ -37,7 +45,7 @@ import {
|
|||||||
} from "@/hooks/useAttachedImages";
|
} from "@/hooks/useAttachedImages";
|
||||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||||
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
|
||||||
@@ -61,6 +69,10 @@ interface ThreadComposerProps {
|
|||||||
imageMode?: boolean;
|
imageMode?: boolean;
|
||||||
onImageModeChange?: (enabled: boolean) => void;
|
onImageModeChange?: (enabled: boolean) => void;
|
||||||
onStop?: () => 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> = {
|
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||||
@@ -126,6 +138,133 @@ function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } {
|
|||||||
return { top, bottom };
|
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({
|
export function ThreadComposer({
|
||||||
onSend,
|
onSend,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -137,6 +276,8 @@ export function ThreadComposer({
|
|||||||
imageMode: controlledImageMode,
|
imageMode: controlledImageMode,
|
||||||
onImageModeChange,
|
onImageModeChange,
|
||||||
onStop,
|
onStop,
|
||||||
|
runStartedAt = null,
|
||||||
|
goalState,
|
||||||
}: ThreadComposerProps) {
|
}: ThreadComposerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
@@ -513,6 +654,8 @@ export function ThreadComposer({
|
|||||||
"focus-within:ring-1 focus-within:ring-foreground/8",
|
"focus-within:ring-1 focus-within:ring-foreground/8",
|
||||||
disabled && "opacity-60",
|
disabled && "opacity-60",
|
||||||
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
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 ? (
|
{images.length > 0 ? (
|
||||||
@@ -543,6 +686,9 @@ export function ThreadComposer({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{runStartedAt != null || goalState?.active ? (
|
||||||
|
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||||
|
) : null}
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -1,23 +1,90 @@
|
|||||||
import { MessageBubble } from "@/components/MessageBubble";
|
import { MessageBubble } from "@/components/MessageBubble";
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
|
AgentActivityCluster,
|
||||||
|
isAgentActivityMember,
|
||||||
|
} from "@/components/thread/AgentActivityCluster";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
interface ThreadMessagesProps {
|
interface ThreadMessagesProps {
|
||||||
messages: UIMessage[];
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
{messages.map((message, index) => {
|
{units.map((unit, index) => {
|
||||||
const prev = messages[index - 1];
|
const prev = units[index - 1];
|
||||||
const compact = isAuxiliaryRow(message) && prev && isAuxiliaryRow(prev);
|
const marginTop =
|
||||||
|
index > 0
|
||||||
|
? marginAfterPrevUnit(prev)
|
||||||
|
: "";
|
||||||
|
const next = units[index + 1];
|
||||||
|
const hasBodyBelow =
|
||||||
|
unit.type === "cluster"
|
||||||
|
&& next?.type === "single"
|
||||||
|
&& next.message.role === "assistant";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={unitKey(unit, index)} className={marginTop}>
|
||||||
key={message.id}
|
{unit.type === "cluster" ? (
|
||||||
className={cn(index > 0 && (compact ? "mt-2" : "mt-5"))}
|
<AgentActivityCluster
|
||||||
>
|
messages={unit.messages}
|
||||||
<MessageBubble message={message} />
|
isTurnStreaming={isStreaming}
|
||||||
|
hasBodyBelow={hasBodyBelow}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MessageBubble
|
||||||
|
message={unit.message}
|
||||||
|
showAssistantCopyAction={
|
||||||
|
unit.message.role === "assistant"
|
||||||
|
? isFinalAssistantSliceBeforeNextUser(units, index)
|
||||||
|
: true
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -25,13 +92,28 @@ export function ThreadMessages({ messages }: ThreadMessagesProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAuxiliaryRow(message: UIMessage): boolean {
|
function unitKey(unit: DisplayUnit, index: number): string {
|
||||||
return (
|
if (unit.type === "cluster") {
|
||||||
message.kind === "trace"
|
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"
|
p.role === "assistant"
|
||||||
&& message.content.trim().length === 0
|
&& p.content.trim().length === 0
|
||||||
&& (!!message.reasoning || !!message.reasoningStreaming)
|
&& (!!p.reasoning || !!p.reasoningStreaming)
|
||||||
)
|
);
|
||||||
);
|
if (denseP) {
|
||||||
|
return "mt-2";
|
||||||
|
}
|
||||||
|
return "mt-5";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,14 @@ import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useN
|
|||||||
import { useSessionHistory } from "@/hooks/useSessions";
|
import { useSessionHistory } from "@/hooks/useSessions";
|
||||||
import { listSlashCommands } from "@/lib/api";
|
import { listSlashCommands } from "@/lib/api";
|
||||||
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
|
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";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
|
function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
|
||||||
|
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
|
||||||
|
}
|
||||||
|
|
||||||
interface ThreadShellProps {
|
interface ThreadShellProps {
|
||||||
session: ChatSummary | null;
|
session: ChatSummary | null;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -95,9 +101,13 @@ export function ThreadShell({
|
|||||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
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 appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||||
|
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||||
|
|
||||||
const initial = useMemo(() => {
|
const initial = useMemo(() => {
|
||||||
if (!chatId) return historical;
|
if (!chatId) return historical;
|
||||||
@@ -111,12 +121,21 @@ export function ThreadShell({
|
|||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
runStartedAt,
|
||||||
|
goalState,
|
||||||
send,
|
send,
|
||||||
stop,
|
stop,
|
||||||
setMessages,
|
setMessages,
|
||||||
streamError,
|
streamError,
|
||||||
dismissStreamError,
|
dismissStreamError,
|
||||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
} = 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;
|
const showHeroComposer = messages.length === 0 && !loading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,13 +153,16 @@ export function ThreadShell({
|
|||||||
if (hasNewCanonicalHistory && historical.length > 0) {
|
if (hasNewCanonicalHistory && historical.length > 0) {
|
||||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||||
messageCacheRef.current.set(chatId, historical);
|
const normalized = projectWebuiThreadMessages(historical);
|
||||||
return historical;
|
messageCacheRef.current.set(chatId, normalized);
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
if (cached && cached.length > 0) return cached;
|
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
|
||||||
if (historical.length === 0 && prev.length > 0) return prev;
|
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
|
||||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [loading, chatId, historical, historyVersion]);
|
}, [loading, chatId, historical, historyVersion]);
|
||||||
@@ -161,26 +183,44 @@ export function ThreadShell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chatId) return;
|
if (chatId) return;
|
||||||
setMessages(historical);
|
setMessages(projectWebuiThreadMessages(historical));
|
||||||
}, [chatId, historical, setMessages]);
|
}, [chatId, historical, setMessages]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!chatId) {
|
if (chatId) {
|
||||||
lastCachedChatIdRef.current = null;
|
const prev = prevChatIdForCacheRef.current;
|
||||||
return;
|
if (prev && prev !== chatId) {
|
||||||
}
|
messageCacheRef.current.set(prev, projectWebuiThreadMessages(messages));
|
||||||
if (loading) return;
|
skipLayoutCacheRef.current = true;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
messageCacheRef.current.set(chatId, messages);
|
if (skipLayoutCacheRef.current) {
|
||||||
|
skipLayoutCacheRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messageCacheRef.current.set(chatId, projectWebuiThreadMessages(messages));
|
||||||
}, [chatId, loading, messages]);
|
}, [chatId, loading, messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -296,6 +336,8 @@ export function ThreadShell({
|
|||||||
imageMode={showHeroComposer ? heroImageMode : undefined}
|
imageMode={showHeroComposer ? heroImageMode : undefined}
|
||||||
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
|
runStartedAt={runStartedAt}
|
||||||
|
goalState={goalState}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
@@ -312,6 +354,8 @@ export function ThreadShell({
|
|||||||
slashCommands={slashCommands}
|
slashCommands={slashCommands}
|
||||||
imageMode={heroImageMode}
|
imageMode={heroImageMode}
|
||||||
onImageModeChange={setHeroImageMode}
|
onImageModeChange={setHeroImageMode}
|
||||||
|
runStartedAt={runStartedAt}
|
||||||
|
goalState={goalState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showHeroComposer ? quickActions : null}
|
{showHeroComposer ? quickActions : null}
|
||||||
@@ -341,7 +385,7 @@ export function ThreadShell({
|
|||||||
minimal={!session && !loading}
|
minimal={!session && !loading}
|
||||||
/>
|
/>
|
||||||
<ThreadViewport
|
<ThreadViewport
|
||||||
messages={messages}
|
messages={displayMessages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composer}
|
composer={composer}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function ThreadViewport({
|
|||||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||||
const pendingConversationScrollRef = useRef(true);
|
const pendingConversationScrollRef = useRef(true);
|
||||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
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 [atBottom, setAtBottom] = useState(true);
|
||||||
const hasMessages = messages.length > 0;
|
const hasMessages = messages.length > 0;
|
||||||
|
|
||||||
@@ -56,31 +57,44 @@ export function ThreadViewport({
|
|||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = false, frames = 1) => {
|
const scrollToBottom = useCallback(
|
||||||
cancelScheduledBottomScroll();
|
(smooth = false, frames = 1, options?: { force?: boolean }) => {
|
||||||
scrollToBottomNow(smooth);
|
const force = options?.force ?? false;
|
||||||
for (let i = 1; i < frames; i += 1) {
|
cancelScheduledBottomScroll();
|
||||||
const id = window.requestAnimationFrame(() => scrollToBottomNow(smooth));
|
const run = () => {
|
||||||
scrollFrameIdsRef.current.push(id);
|
if (!force && userReadingHistoryRef.current) return;
|
||||||
}
|
scrollToBottomNow(smooth);
|
||||||
}, [cancelScheduledBottomScroll, scrollToBottomNow]);
|
};
|
||||||
|
run();
|
||||||
|
for (let i = 1; i < frames; i += 1) {
|
||||||
|
const id = window.requestAnimationFrame(() => {
|
||||||
|
if (!force && userReadingHistoryRef.current) return;
|
||||||
|
scrollToBottomNow(smooth);
|
||||||
|
});
|
||||||
|
scrollFrameIdsRef.current.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[cancelScheduledBottomScroll, scrollToBottomNow],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!atBottom) return;
|
if (!atBottom) return;
|
||||||
scrollToBottom(!isStreaming);
|
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||||
}, [messages, isStreaming, atBottom, scrollToBottom]);
|
// browsers; session switches and history hydration should never slide from top.
|
||||||
|
scrollToBottom(false);
|
||||||
|
}, [messages, atBottom, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollToBottomSignal <= 0) return;
|
if (scrollToBottomSignal <= 0) return;
|
||||||
forceBottomUntilRef.current = Date.now() + 2_000;
|
userReadingHistoryRef.current = false;
|
||||||
scrollToBottom(true, 8);
|
scrollToBottom(false, 8);
|
||||||
}, [scrollToBottomSignal, scrollToBottom]);
|
}, [scrollToBottomSignal, scrollToBottom]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (lastConversationKeyRef.current === conversationKey) return;
|
if (lastConversationKeyRef.current === conversationKey) return;
|
||||||
lastConversationKeyRef.current = conversationKey;
|
lastConversationKeyRef.current = conversationKey;
|
||||||
pendingConversationScrollRef.current = true;
|
pendingConversationScrollRef.current = true;
|
||||||
forceBottomUntilRef.current = Date.now() + 2_000;
|
userReadingHistoryRef.current = false;
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
}, [conversationKey]);
|
}, [conversationKey]);
|
||||||
|
|
||||||
@@ -102,12 +116,12 @@ export function ThreadViewport({
|
|||||||
const target = contentRef.current;
|
const target = contentRef.current;
|
||||||
if (!target || typeof ResizeObserver === "undefined") return;
|
if (!target || typeof ResizeObserver === "undefined") return;
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
if (!atBottom && Date.now() > forceBottomUntilRef.current) return;
|
if (userReadingHistoryRef.current) return;
|
||||||
scrollToBottom(false, 4);
|
scrollToBottom(false, 4);
|
||||||
});
|
});
|
||||||
observer.observe(target);
|
observer.observe(target);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [atBottom, hasMessages, scrollToBottom]);
|
}, [hasMessages, scrollToBottom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -115,7 +129,9 @@ export function ThreadViewport({
|
|||||||
|
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
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();
|
onScroll();
|
||||||
@@ -128,7 +144,7 @@ export function ThreadViewport({
|
|||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={cn(
|
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]:w-1.5",
|
||||||
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
"[&::-webkit-scrollbar-thumb]:rounded-full",
|
||||||
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
"[&::-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 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="flex-1 px-4 pb-20 pt-4">
|
||||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||||
<ThreadMessages messages={messages} />
|
<ThreadMessages messages={messages} isStreaming={isStreaming} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,9 +187,10 @@ export function ThreadViewport({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => scrollToBottom(true)}
|
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||||
className={cn(
|
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",
|
"bg-background/90 backdrop-blur",
|
||||||
"animate-in fade-in-0 zoom-in-95",
|
"animate-in fade-in-0 zoom-in-95",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const ScrollArea = React.forwardRef<
|
|||||||
className={cn("relative overflow-hidden", className)}
|
className={cn("relative overflow-hidden", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
<ScrollAreaPrimitive.Viewport className="h-full w-full min-w-0 rounded-[inherit]">
|
||||||
{children}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
<ScrollBar />
|
<ScrollBar />
|
||||||
|
|||||||
+80
-13
@@ -117,31 +117,92 @@
|
|||||||
--cjk-line-height: 1.625;
|
--cjk-line-height: 1.625;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shimmer band sweeping across the reasoning header while
|
/* L→R sheen over solid label text (overlay stripe). Avoids ``background-clip:
|
||||||
``reasoning_delta`` frames are arriving. Pure CSS, no JS animation,
|
text`` loop seams that read as RTL “erase” or one-frame transparent glyphs. */
|
||||||
respects ``prefers-reduced-motion``. */
|
@keyframes reasoning-sheen-ltr {
|
||||||
@keyframes reasoning-shimmer-sweep {
|
|
||||||
0% {
|
0% {
|
||||||
background-position: -200% 0;
|
left: -44%;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background-position: 200% 0;
|
left: 118%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.reasoning-shimmer {
|
.reasoning-sheen-track {
|
||||||
background-image: linear-gradient(
|
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,
|
90deg,
|
||||||
transparent 0%,
|
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%
|
transparent 100%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
mix-blend-mode: soft-light;
|
||||||
background-repeat: no-repeat;
|
opacity: 0.95;
|
||||||
animation: reasoning-shimmer-sweep 2.2s linear infinite;
|
animation: reasoning-sheen-ltr 5.2s linear infinite;
|
||||||
|
}
|
||||||
|
.dark .reasoning-sheen-stripe {
|
||||||
|
mix-blend-mode: overlay;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.reasoning-shimmer {
|
.reasoning-sheen-stripe {
|
||||||
animation: none;
|
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);
|
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
.scrollbar-track-transparent {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
.scrollbar-track-transparent::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
InboundEvent,
|
InboundEvent,
|
||||||
OutboundImageGeneration,
|
OutboundImageGeneration,
|
||||||
OutboundMedia,
|
OutboundMedia,
|
||||||
|
GoalStateWsPayload,
|
||||||
UIImage,
|
UIImage,
|
||||||
UIMessage,
|
UIMessage,
|
||||||
} from "@/lib/types";
|
} 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(
|
function absorbCompleteAssistantMessage(
|
||||||
prev: UIMessage[],
|
prev: UIMessage[],
|
||||||
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
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,
|
* 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
|
* 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.
|
* live events.
|
||||||
*/
|
*/
|
||||||
/** Payload passed to ``send`` when the user attaches one or more images.
|
/** Payload passed to ``send`` when the user attaches one or more images.
|
||||||
@@ -190,6 +202,10 @@ export function useNanobotStream(
|
|||||||
): {
|
): {
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
isStreaming: boolean;
|
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;
|
send: (content: string, images?: SendImage[], options?: SendOptions) => void;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
setMessages: React.Dispatch<React.SetStateAction<UIMessage[]>>;
|
||||||
@@ -209,6 +225,9 @@ export function useNanobotStream(
|
|||||||
? initialMessages[initialMessages.length - 1].kind === "trace"
|
? initialMessages[initialMessages.length - 1].kind === "trace"
|
||||||
: false;
|
: false;
|
||||||
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
|
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 [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||||
const buffer = useRef<StreamBuffer | null>(null);
|
const buffer = useRef<StreamBuffer | null>(null);
|
||||||
const suppressStreamUntilTurnEndRef = useRef(false);
|
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||||
@@ -238,6 +257,8 @@ export function useNanobotStream(
|
|||||||
: false) || hasPendingToolCalls,
|
: false) || hasPendingToolCalls,
|
||||||
);
|
);
|
||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
|
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||||
|
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
if (streamEndTimerRef.current !== null) {
|
if (streamEndTimerRef.current !== null) {
|
||||||
@@ -245,7 +266,7 @@ export function useNanobotStream(
|
|||||||
streamEndTimerRef.current = null;
|
streamEndTimerRef.current = null;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chatId]);
|
}, [chatId, client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPendingToolCalls) setIsStreaming(true);
|
if (hasPendingToolCalls) setIsStreaming(true);
|
||||||
@@ -332,7 +353,24 @@ export function useNanobotStream(
|
|||||||
return;
|
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 (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
|
// Definitive signal that the turn is fully complete. Cancel any
|
||||||
// pending debounce timer and stop the loading indicator immediately.
|
// pending debounce timer and stop the loading indicator immediately.
|
||||||
if (streamEndTimerRef.current !== null) {
|
if (streamEndTimerRef.current !== null) {
|
||||||
@@ -341,8 +379,12 @@ export function useNanobotStream(
|
|||||||
}
|
}
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
let finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||||
return pruneReasoningOnlyPlaceholders(finalized);
|
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;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
onTurnEnd?.();
|
onTurnEnd?.();
|
||||||
@@ -415,9 +457,14 @@ export function useNanobotStream(
|
|||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||||
const content = ev.text;
|
const content = ev.text;
|
||||||
|
const lat =
|
||||||
|
typeof ev.latency_ms === "number" && ev.latency_ms >= 0
|
||||||
|
? Math.round(ev.latency_ms)
|
||||||
|
: undefined;
|
||||||
return absorbCompleteAssistantMessage(filtered, {
|
return absorbCompleteAssistantMessage(filtered, {
|
||||||
content,
|
content,
|
||||||
...(hasMedia ? { media } : {}),
|
...(hasMedia ? { media } : {}),
|
||||||
|
...(lat !== undefined ? { latencyMs: lat } : {}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (hasMedia) {
|
if (hasMedia) {
|
||||||
@@ -485,6 +532,8 @@ export function useNanobotStream(
|
|||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
runStartedAt,
|
||||||
|
goalState,
|
||||||
send,
|
send,
|
||||||
stop,
|
stop,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
|||||||
@@ -5,40 +5,14 @@ import i18n from "@/i18n";
|
|||||||
import {
|
import {
|
||||||
ApiError,
|
ApiError,
|
||||||
deleteSession as apiDeleteSession,
|
deleteSession as apiDeleteSession,
|
||||||
fetchSessionMessages,
|
fetchWebuiThread,
|
||||||
listSessions,
|
listSessions,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { deriveTitle } from "@/lib/format";
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { toMediaAttachment } from "@/lib/media";
|
|
||||||
import { formatToolCallTrace } from "@/lib/tool-traces";
|
|
||||||
import type { ChatSummary, UIMessage } from "@/lib/types";
|
import type { ChatSummary, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
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. */
|
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
|
||||||
export function useSessions(): {
|
export function useSessions(): {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
@@ -118,8 +92,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
version: number;
|
version: number;
|
||||||
/** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
|
/** ``true`` when the replayed transcript ends with a trace row (turn still in flight). */
|
||||||
* final text yet — the model was still processing when the page loaded. */
|
|
||||||
hasPendingToolCalls: boolean;
|
hasPendingToolCalls: boolean;
|
||||||
} {
|
} {
|
||||||
const { token } = useClient();
|
const { token } = useClient();
|
||||||
@@ -170,58 +143,26 @@ export function useSessionHistory(key: string | null): {
|
|||||||
});
|
});
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const body = await fetchSessionMessages(token, key);
|
const body = await fetchWebuiThread(token, key);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const ui: UIMessage[] = body.messages.flatMap((m, idx) => {
|
if (!body?.messages?.length) {
|
||||||
if (m.role !== "user" && m.role !== "assistant") return [];
|
setState((prev) => ({
|
||||||
if (typeof m.content !== "string") return [];
|
key,
|
||||||
// Hydrate signed media URLs into generic UI attachments. Image-only
|
messages: [],
|
||||||
// user turns still populate the legacy ``images`` slot so the
|
loading: false,
|
||||||
// existing optimistic-send and lightbox paths remain unchanged.
|
error: null,
|
||||||
const media =
|
hasPendingToolCalls: false,
|
||||||
Array.isArray(m.media_urls) && m.media_urls.length > 0
|
version: prev.key === key ? prev.version + 1 : 1,
|
||||||
? m.media_urls.map((mu) => toMediaAttachment(mu))
|
}));
|
||||||
: undefined;
|
return;
|
||||||
const images =
|
}
|
||||||
m.role === "user" && media?.every((item) => item.kind === "image")
|
const ui: UIMessage[] = body.messages.map((m, idx) => ({
|
||||||
? media.map((item) => ({ url: item.url, name: item.name }))
|
...m,
|
||||||
: undefined;
|
id: m.id ?? `hist-${idx}`,
|
||||||
const row: UIMessage = {
|
createdAt: typeof m.createdAt === "number" ? m.createdAt : Date.now(),
|
||||||
id: `hist-${idx}`,
|
}));
|
||||||
role: m.role,
|
const last = ui[ui.length - 1];
|
||||||
content: m.content,
|
const hasPending = last?.kind === "trace";
|
||||||
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] : [];
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
key,
|
key,
|
||||||
messages: ui,
|
messages: ui,
|
||||||
@@ -232,8 +173,6 @@ export function useSessionHistory(key: string | null): {
|
|||||||
}));
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
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) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
key,
|
key,
|
||||||
|
|||||||
@@ -244,6 +244,13 @@
|
|||||||
"placeholderStreaming": "Model is responding…",
|
"placeholderStreaming": "Model is responding…",
|
||||||
"inputAria": "Message input",
|
"inputAria": "Message input",
|
||||||
"sendHint": "Enter to send · Shift+Enter for newline",
|
"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",
|
"send": "Send message",
|
||||||
"stop": "Stop response",
|
"stop": "Stop response",
|
||||||
"attachImage": "Attach image",
|
"attachImage": "Attach image",
|
||||||
@@ -307,6 +314,10 @@
|
|||||||
"title": "Restore memory",
|
"title": "Restore memory",
|
||||||
"description": "Revert memory to a previous Dream snapshot."
|
"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": {
|
"help": {
|
||||||
"title": "Show help",
|
"title": "Show help",
|
||||||
"description": "List available slash commands."
|
"description": "List available slash commands."
|
||||||
@@ -332,11 +343,21 @@
|
|||||||
"assistantTyping": "Assistant is typing",
|
"assistantTyping": "Assistant is typing",
|
||||||
"toolSingle": "Using a tool",
|
"toolSingle": "Using a tool",
|
||||||
"toolMany": "Used {{count}} tools",
|
"toolMany": "Used {{count}} tools",
|
||||||
|
"toolSummary": "{{count}} tool",
|
||||||
|
"toolSummaryMany": "{{count}} tools",
|
||||||
|
"reasoningTools": "Reasoning · {{count}} tools",
|
||||||
|
"reasoningToolsSingular": "Reasoning · 1 tool",
|
||||||
"reasoning": "Thinking",
|
"reasoning": "Thinking",
|
||||||
"reasoningStreaming": "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",
|
"imageAttachment": "Image attachment",
|
||||||
"copyReply": "Copy reply",
|
"copyReply": "Copy reply",
|
||||||
"copiedReply": "Copied reply"
|
"copiedReply": "Copied reply",
|
||||||
|
"turnLatencyTitle": "Response time (end-to-end)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Image preview",
|
"title": "Image preview",
|
||||||
|
|||||||
@@ -218,6 +218,13 @@
|
|||||||
"placeholderStreaming": "El modelo está respondiendo…",
|
"placeholderStreaming": "El modelo está respondiendo…",
|
||||||
"inputAria": "Entrada de mensaje",
|
"inputAria": "Entrada de mensaje",
|
||||||
"sendHint": "Enter para enviar · Shift+Enter para nueva línea",
|
"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",
|
"send": "Enviar mensaje",
|
||||||
"stop": "Detener respuesta",
|
"stop": "Detener respuesta",
|
||||||
"attachImage": "Adjuntar imagen",
|
"attachImage": "Adjuntar imagen",
|
||||||
@@ -286,6 +293,10 @@
|
|||||||
"title": "Restaurar memoria",
|
"title": "Restaurar memoria",
|
||||||
"description": "Revierte la memoria a una instantánea Dream anterior."
|
"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": {
|
"help": {
|
||||||
"title": "Mostrar ayuda",
|
"title": "Mostrar ayuda",
|
||||||
"description": "Lista los comandos slash disponibles."
|
"description": "Lista los comandos slash disponibles."
|
||||||
@@ -300,7 +311,19 @@
|
|||||||
"assistantTyping": "El asistente está escribiendo",
|
"assistantTyping": "El asistente está escribiendo",
|
||||||
"toolSingle": "Usando una herramienta",
|
"toolSingle": "Usando una herramienta",
|
||||||
"toolMany": "Se usaron {{count}} herramientas",
|
"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": {
|
"lightbox": {
|
||||||
"title": "Vista previa de imagen",
|
"title": "Vista previa de imagen",
|
||||||
|
|||||||
@@ -218,6 +218,13 @@
|
|||||||
"placeholderStreaming": "Le modèle est en train de répondre…",
|
"placeholderStreaming": "Le modèle est en train de répondre…",
|
||||||
"inputAria": "Champ de message",
|
"inputAria": "Champ de message",
|
||||||
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
|
"sendHint": "Entrée pour envoyer · Maj+Entrée pour un retour à la ligne",
|
||||||
|
"runRuntimeTitle": "Exécution · {{elapsed}}",
|
||||||
|
"goalStateStrip": "Objectif · {{label}}",
|
||||||
|
"goalStateFallback": "Objectif",
|
||||||
|
"goalStateExpandAria": "Afficher l’objectif complet",
|
||||||
|
"goalStateSheetTitle": "Objectif du fil",
|
||||||
|
"goalStateSummaryHeading": "Résumé",
|
||||||
|
"goalStateObjectiveHeading": "Objectif",
|
||||||
"send": "Envoyer le message",
|
"send": "Envoyer le message",
|
||||||
"stop": "Arrêter la réponse",
|
"stop": "Arrêter la réponse",
|
||||||
"attachImage": "Joindre une image",
|
"attachImage": "Joindre une image",
|
||||||
@@ -286,6 +293,10 @@
|
|||||||
"title": "Restaurer la mémoire",
|
"title": "Restaurer la mémoire",
|
||||||
"description": "Revenir à un instantané Dream précédent."
|
"description": "Revenir à un instantané Dream précédent."
|
||||||
},
|
},
|
||||||
|
"goal": {
|
||||||
|
"title": "Objectif long terme",
|
||||||
|
"description": "Demandez à l’agent de traiter ceci comme un objectif multi‑étapes durable."
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Afficher l’aide",
|
"title": "Afficher l’aide",
|
||||||
"description": "Lister les commandes slash disponibles."
|
"description": "Lister les commandes slash disponibles."
|
||||||
@@ -300,7 +311,19 @@
|
|||||||
"assistantTyping": "L’assistant est en train d’écrire",
|
"assistantTyping": "L’assistant est en train d’écrire",
|
||||||
"toolSingle": "Utilisation d’un outil",
|
"toolSingle": "Utilisation d’un outil",
|
||||||
"toolMany": "{{count}} outils utilisés",
|
"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 d’outils",
|
||||||
|
"agentActivityToolsOnly": "{{tools}} appels d’outils",
|
||||||
|
"agentActivityLiveSummary": "En cours… · {{reasoning}} étapes · {{tools}} appels d’outils",
|
||||||
|
"agentActivityLiveToolsOnly": "En cours… · {{tools}} appels d’outils",
|
||||||
|
"imageAttachment": "Pièce jointe image",
|
||||||
|
"turnLatencyTitle": "Temps de réponse (de bout en bout)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "Aperçu de l’image",
|
"title": "Aperçu de l’image",
|
||||||
|
|||||||
@@ -218,6 +218,13 @@
|
|||||||
"placeholderStreaming": "Model sedang merespons…",
|
"placeholderStreaming": "Model sedang merespons…",
|
||||||
"inputAria": "Input pesan",
|
"inputAria": "Input pesan",
|
||||||
"sendHint": "Enter untuk kirim · Shift+Enter untuk baris baru",
|
"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",
|
"send": "Kirim pesan",
|
||||||
"stop": "Hentikan respons",
|
"stop": "Hentikan respons",
|
||||||
"attachImage": "Lampirkan gambar",
|
"attachImage": "Lampirkan gambar",
|
||||||
@@ -286,6 +293,10 @@
|
|||||||
"title": "Pulihkan memori",
|
"title": "Pulihkan memori",
|
||||||
"description": "Kembalikan memori ke snapshot Dream sebelumnya."
|
"description": "Kembalikan memori ke snapshot Dream sebelumnya."
|
||||||
},
|
},
|
||||||
|
"goal": {
|
||||||
|
"title": "Tujuan jangka panjang",
|
||||||
|
"description": "Instruksikan agen memperlakukan ini sebagai tujuan multi-langkah yang berkelanjutan."
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Tampilkan bantuan",
|
"title": "Tampilkan bantuan",
|
||||||
"description": "Daftar perintah slash yang tersedia."
|
"description": "Daftar perintah slash yang tersedia."
|
||||||
@@ -300,7 +311,19 @@
|
|||||||
"assistantTyping": "Asisten sedang mengetik",
|
"assistantTyping": "Asisten sedang mengetik",
|
||||||
"toolSingle": "Menggunakan sebuah alat",
|
"toolSingle": "Menggunakan sebuah alat",
|
||||||
"toolMany": "Menggunakan {{count}} 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": {
|
"lightbox": {
|
||||||
"title": "Pratinjau gambar",
|
"title": "Pratinjau gambar",
|
||||||
|
|||||||
@@ -218,6 +218,13 @@
|
|||||||
"placeholderStreaming": "モデルが応答しています…",
|
"placeholderStreaming": "モデルが応答しています…",
|
||||||
"inputAria": "メッセージ入力欄",
|
"inputAria": "メッセージ入力欄",
|
||||||
"sendHint": "Enter で送信 · Shift+Enter で改行",
|
"sendHint": "Enter で送信 · Shift+Enter で改行",
|
||||||
|
"runRuntimeTitle": "実行中 · {{elapsed}}",
|
||||||
|
"goalStateStrip": "目標 · {{label}}",
|
||||||
|
"goalStateFallback": "目標",
|
||||||
|
"goalStateExpandAria": "目標の全文を表示",
|
||||||
|
"goalStateSheetTitle": "スレッドの目標",
|
||||||
|
"goalStateSummaryHeading": "要約",
|
||||||
|
"goalStateObjectiveHeading": "目的",
|
||||||
"send": "メッセージを送信",
|
"send": "メッセージを送信",
|
||||||
"stop": "応答を停止",
|
"stop": "応答を停止",
|
||||||
"attachImage": "画像を添付",
|
"attachImage": "画像を添付",
|
||||||
@@ -286,6 +293,10 @@
|
|||||||
"title": "メモリを復元",
|
"title": "メモリを復元",
|
||||||
"description": "以前の Dream スナップショットへメモリを戻します。"
|
"description": "以前の Dream スナップショットへメモリを戻します。"
|
||||||
},
|
},
|
||||||
|
"goal": {
|
||||||
|
"title": "長期目標",
|
||||||
|
"description": "持続的な複数ステップの目標として扱うようエージェントに伝えます。"
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "ヘルプを表示",
|
"title": "ヘルプを表示",
|
||||||
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
"description": "利用可能なスラッシュコマンドを一覧表示します。"
|
||||||
@@ -300,7 +311,19 @@
|
|||||||
"assistantTyping": "アシスタントが入力中",
|
"assistantTyping": "アシスタントが入力中",
|
||||||
"toolSingle": "ツールを使用中",
|
"toolSingle": "ツールを使用中",
|
||||||
"toolMany": "{{count}} 個のツールを使用",
|
"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": {
|
"lightbox": {
|
||||||
"title": "画像プレビュー",
|
"title": "画像プレビュー",
|
||||||
|
|||||||
@@ -218,6 +218,13 @@
|
|||||||
"placeholderStreaming": "모델이 응답 중입니다…",
|
"placeholderStreaming": "모델이 응답 중입니다…",
|
||||||
"inputAria": "메시지 입력",
|
"inputAria": "메시지 입력",
|
||||||
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
|
"sendHint": "Enter로 전송 · Shift+Enter로 줄바꿈",
|
||||||
|
"runRuntimeTitle": "실행 중 · {{elapsed}}",
|
||||||
|
"goalStateStrip": "목표 · {{label}}",
|
||||||
|
"goalStateFallback": "목표",
|
||||||
|
"goalStateExpandAria": "전체 목표 보기",
|
||||||
|
"goalStateSheetTitle": "스레드 목표",
|
||||||
|
"goalStateSummaryHeading": "요약",
|
||||||
|
"goalStateObjectiveHeading": "목표 설명",
|
||||||
"send": "메시지 보내기",
|
"send": "메시지 보내기",
|
||||||
"stop": "응답 중지",
|
"stop": "응답 중지",
|
||||||
"attachImage": "이미지 첨부",
|
"attachImage": "이미지 첨부",
|
||||||
@@ -286,6 +293,10 @@
|
|||||||
"title": "메모리 복원",
|
"title": "메모리 복원",
|
||||||
"description": "이전 Dream 스냅샷으로 메모리를 되돌립니다."
|
"description": "이전 Dream 스냅샷으로 메모리를 되돌립니다."
|
||||||
},
|
},
|
||||||
|
"goal": {
|
||||||
|
"title": "장기 목표",
|
||||||
|
"description": "에이전트에게 지속적인 다단계 목표로 처리하도록 지시합니다."
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "도움말 보기",
|
"title": "도움말 보기",
|
||||||
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
"description": "사용 가능한 슬래시 명령을 나열합니다."
|
||||||
@@ -300,7 +311,19 @@
|
|||||||
"assistantTyping": "도우미가 입력 중",
|
"assistantTyping": "도우미가 입력 중",
|
||||||
"toolSingle": "도구 사용 중",
|
"toolSingle": "도구 사용 중",
|
||||||
"toolMany": "도구 {{count}}개 사용됨",
|
"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": {
|
"lightbox": {
|
||||||
"title": "이미지 미리보기",
|
"title": "이미지 미리보기",
|
||||||
|
|||||||
@@ -218,6 +218,13 @@
|
|||||||
"placeholderStreaming": "Mô hình đang trả lời…",
|
"placeholderStreaming": "Mô hình đang trả lời…",
|
||||||
"inputAria": "Ô nhập tin nhắn",
|
"inputAria": "Ô nhập tin nhắn",
|
||||||
"sendHint": "Enter để gửi · Shift+Enter để xuống dòng",
|
"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",
|
"send": "Gửi tin nhắn",
|
||||||
"stop": "Dừng phản hồi",
|
"stop": "Dừng phản hồi",
|
||||||
"attachImage": "Đính kèm ảnh",
|
"attachImage": "Đính kèm ảnh",
|
||||||
@@ -286,6 +293,10 @@
|
|||||||
"title": "Khôi phục bộ nhớ",
|
"title": "Khôi phục bộ nhớ",
|
||||||
"description": "Đưa bộ nhớ về một snapshot Dream trước đó."
|
"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": {
|
"help": {
|
||||||
"title": "Hiển thị trợ giúp",
|
"title": "Hiển thị trợ giúp",
|
||||||
"description": "Liệt kê các lệnh slash có sẵn."
|
"description": "Liệt kê các lệnh slash có sẵn."
|
||||||
@@ -300,7 +311,19 @@
|
|||||||
"assistantTyping": "Trợ lý đang nhập",
|
"assistantTyping": "Trợ lý đang nhập",
|
||||||
"toolSingle": "Đang dùng một công cụ",
|
"toolSingle": "Đang dùng một công cụ",
|
||||||
"toolMany": "Đã dùng {{count}} 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": {
|
"lightbox": {
|
||||||
"title": "Xem trước ảnh",
|
"title": "Xem trước ảnh",
|
||||||
|
|||||||
@@ -232,6 +232,13 @@
|
|||||||
"placeholderStreaming": "模型正在回复…",
|
"placeholderStreaming": "模型正在回复…",
|
||||||
"inputAria": "消息输入框",
|
"inputAria": "消息输入框",
|
||||||
"sendHint": "Enter 发送 · Shift+Enter 换行",
|
"sendHint": "Enter 发送 · Shift+Enter 换行",
|
||||||
|
"runRuntimeTitle": "运行中 · {{elapsed}}",
|
||||||
|
"goalStateStrip": "目标 · {{label}}",
|
||||||
|
"goalStateFallback": "目标",
|
||||||
|
"goalStateExpandAria": "查看完整目标",
|
||||||
|
"goalStateSheetTitle": "会话目标",
|
||||||
|
"goalStateSummaryHeading": "摘要",
|
||||||
|
"goalStateObjectiveHeading": "目标描述",
|
||||||
"send": "发送消息",
|
"send": "发送消息",
|
||||||
"stop": "停止响应",
|
"stop": "停止响应",
|
||||||
"attachImage": "添加图片",
|
"attachImage": "添加图片",
|
||||||
@@ -295,6 +302,10 @@
|
|||||||
"title": "恢复记忆",
|
"title": "恢复记忆",
|
||||||
"description": "将记忆恢复到之前的 Dream 快照。"
|
"description": "将记忆恢复到之前的 Dream 快照。"
|
||||||
},
|
},
|
||||||
|
"goal": {
|
||||||
|
"title": "长期目标",
|
||||||
|
"description": "让助手把当前请求当作需要多步骤持续推进的目标。"
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "查看帮助",
|
"title": "查看帮助",
|
||||||
"description": "列出可用的斜杠命令。"
|
"description": "列出可用的斜杠命令。"
|
||||||
@@ -320,11 +331,21 @@
|
|||||||
"assistantTyping": "助手正在输入",
|
"assistantTyping": "助手正在输入",
|
||||||
"toolSingle": "正在使用工具",
|
"toolSingle": "正在使用工具",
|
||||||
"toolMany": "已使用 {{count}} 个工具",
|
"toolMany": "已使用 {{count}} 个工具",
|
||||||
|
"toolSummary": "{{count}} 个工具",
|
||||||
|
"toolSummaryMany": "{{count}} 个工具",
|
||||||
|
"reasoningTools": "推理 · {{count}} 个工具",
|
||||||
|
"reasoningToolsSingular": "推理 · 1 个工具",
|
||||||
"reasoning": "思考过程",
|
"reasoning": "思考过程",
|
||||||
"reasoningStreaming": "正在思考…",
|
"reasoningStreaming": "正在思考…",
|
||||||
|
"reasoningSummary": "推理",
|
||||||
|
"agentActivitySummary": "{{reasoning}} 步 · {{tools}} 次工具调用",
|
||||||
|
"agentActivityToolsOnly": "{{tools}} 次工具调用",
|
||||||
|
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
|
||||||
|
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
|
||||||
"imageAttachment": "图片附件",
|
"imageAttachment": "图片附件",
|
||||||
"copyReply": "复制回复",
|
"copyReply": "复制回复",
|
||||||
"copiedReply": "已复制回复"
|
"copiedReply": "已复制回复",
|
||||||
|
"turnLatencyTitle": "本轮耗时(端到端)"
|
||||||
},
|
},
|
||||||
"lightbox": {
|
"lightbox": {
|
||||||
"title": "图片预览",
|
"title": "图片预览",
|
||||||
|
|||||||
@@ -218,6 +218,13 @@
|
|||||||
"placeholderStreaming": "模型正在回覆…",
|
"placeholderStreaming": "模型正在回覆…",
|
||||||
"inputAria": "訊息輸入框",
|
"inputAria": "訊息輸入框",
|
||||||
"sendHint": "Enter 送出 · Shift+Enter 換行",
|
"sendHint": "Enter 送出 · Shift+Enter 換行",
|
||||||
|
"runRuntimeTitle": "執行中 · {{elapsed}}",
|
||||||
|
"goalStateStrip": "目標 · {{label}}",
|
||||||
|
"goalStateFallback": "目標",
|
||||||
|
"goalStateExpandAria": "查看完整目標",
|
||||||
|
"goalStateSheetTitle": "對話目標",
|
||||||
|
"goalStateSummaryHeading": "摘要",
|
||||||
|
"goalStateObjectiveHeading": "目標描述",
|
||||||
"send": "送出訊息",
|
"send": "送出訊息",
|
||||||
"stop": "停止回覆",
|
"stop": "停止回覆",
|
||||||
"attachImage": "附加圖片",
|
"attachImage": "附加圖片",
|
||||||
@@ -286,6 +293,10 @@
|
|||||||
"title": "恢復記憶",
|
"title": "恢復記憶",
|
||||||
"description": "將記憶恢復到之前的 Dream 快照。"
|
"description": "將記憶恢復到之前的 Dream 快照。"
|
||||||
},
|
},
|
||||||
|
"goal": {
|
||||||
|
"title": "長期目標",
|
||||||
|
"description": "請助理把這則請求當成需要多步驟持續推進的目標。"
|
||||||
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "查看說明",
|
"title": "查看說明",
|
||||||
"description": "列出可用的斜線命令。"
|
"description": "列出可用的斜線命令。"
|
||||||
@@ -300,7 +311,19 @@
|
|||||||
"assistantTyping": "助理正在輸入",
|
"assistantTyping": "助理正在輸入",
|
||||||
"toolSingle": "正在使用工具",
|
"toolSingle": "正在使用工具",
|
||||||
"toolMany": "已使用 {{count}} 個工具",
|
"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": {
|
"lightbox": {
|
||||||
"title": "圖片預覽",
|
"title": "圖片預覽",
|
||||||
|
|||||||
+12
-33
@@ -5,6 +5,7 @@ import type {
|
|||||||
SettingsUpdate,
|
SettingsUpdate,
|
||||||
SlashCommand,
|
SlashCommand,
|
||||||
WebSearchSettingsUpdate,
|
WebSearchSettingsUpdate,
|
||||||
|
WebuiThreadPersistedPayload,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -66,42 +67,20 @@ export async function listSessions(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Signed image URL attached to a historical user message. The server
|
/** Disk-backed WebUI display thread snapshot (separate from agent session). */
|
||||||
* emits these in place of raw on-disk paths so the client can render
|
export async function fetchWebuiThread(
|
||||||
* 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(
|
|
||||||
token: string,
|
token: string,
|
||||||
key: string,
|
key: string,
|
||||||
base: string = "",
|
base: string = "",
|
||||||
): Promise<{
|
): Promise<WebuiThreadPersistedPayload | null> {
|
||||||
key: string;
|
const url = `${base}/api/sessions/${encodeURIComponent(key)}/webui-thread`;
|
||||||
created_at: string | null;
|
const res = await fetch(url, {
|
||||||
updated_at: string | null;
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
messages: Array<{
|
credentials: "same-origin",
|
||||||
role: string;
|
});
|
||||||
content: string;
|
if (res.status === 404) return null;
|
||||||
timestamp?: string;
|
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
|
||||||
tool_calls?: unknown;
|
return (await res.json()) as WebuiThreadPersistedPayload;
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSession(
|
export async function deleteSession(
|
||||||
|
|||||||
@@ -75,3 +75,33 @@ export function fmtDateTime(
|
|||||||
const date = parseDate(value);
|
const date = parseDate(value);
|
||||||
return date ? dateTimeFormatter(activeLocale(locale)).format(date) : "";
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
Outbound,
|
Outbound,
|
||||||
OutboundImageGeneration,
|
OutboundImageGeneration,
|
||||||
OutboundMedia,
|
OutboundMedia,
|
||||||
|
GoalStateWsPayload,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/** WebSocket readyState constants, referenced by value to stay portable
|
/** WebSocket readyState constants, referenced by value to stay portable
|
||||||
@@ -65,8 +66,15 @@ export class NanobotClient {
|
|||||||
private errorHandlers = new Set<ErrorHandler>();
|
private errorHandlers = new Set<ErrorHandler>();
|
||||||
// chat_id -> handlers listening on it
|
// chat_id -> handlers listening on it
|
||||||
private chatHandlers = new Map<string, Set<EventHandler>>();
|
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
|
// chat_ids we've attached to since connect; re-attached after reconnects
|
||||||
private knownChats = new Set<string>();
|
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;
|
private pendingNewChat: PendingNewChat | null = null;
|
||||||
// Frames queued while the socket is not yet OPEN
|
// Frames queued while the socket is not yet OPEN
|
||||||
private sendQueue: Outbound[] = [];
|
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. */
|
/** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
|
||||||
onChat(chatId: string, handler: EventHandler): Unsubscribe {
|
onChat(chatId: string, handler: EventHandler): Unsubscribe {
|
||||||
let handlers = this.chatHandlers.get(chatId);
|
let handlers = this.chatHandlers.get(chatId);
|
||||||
@@ -141,6 +179,14 @@ export class NanobotClient {
|
|||||||
this.chatHandlers.set(chatId, handlers);
|
this.chatHandlers.set(chatId, handlers);
|
||||||
}
|
}
|
||||||
handlers.add(handler);
|
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);
|
this.attach(chatId);
|
||||||
return () => {
|
return () => {
|
||||||
const current = this.chatHandlers.get(chatId);
|
const current = this.chatHandlers.get(chatId);
|
||||||
@@ -274,7 +320,11 @@ export class NanobotClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chatId = (parsed as { chat_id?: string }).chat_id;
|
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 {
|
private emitRuntimeModelUpdate(modelName: string | null, modelPreset?: string | null): void {
|
||||||
@@ -291,8 +341,22 @@ export class NanobotClient {
|
|||||||
|
|
||||||
private dispatch(chatId: string, ev: InboundEvent): void {
|
private dispatch(chatId: string, ev: InboundEvent): void {
|
||||||
const handlers = this.chatHandlers.get(chatId);
|
const handlers = this.chatHandlers.get(chatId);
|
||||||
if (!handlers) return;
|
if (handlers !== undefined && handlers.size > 0) {
|
||||||
for (const h of handlers) h(ev);
|
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 {
|
private handleClose(event?: { code?: number }): void {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 {
|
export function formatToolCallTrace(call: unknown): string | null {
|
||||||
if (!call || typeof call !== "object") return null;
|
if (!call || typeof call !== "object") return null;
|
||||||
const item = call as {
|
const item = call as {
|
||||||
|
|||||||
+47
-1
@@ -51,6 +51,21 @@ export interface UIMessage {
|
|||||||
/** True while ``reasoning_delta`` frames are still arriving for this turn.
|
/** True while ``reasoning_delta`` frames are still arriving for this turn.
|
||||||
* Drives the shimmer header on ``ReasoningBubble``. */
|
* Drives the shimmer header on ``ReasoningBubble``. */
|
||||||
reasoningStreaming?: boolean;
|
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 {
|
export interface ToolProgressEvent {
|
||||||
@@ -162,6 +177,10 @@ export type InboundEvent =
|
|||||||
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
|
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
|
||||||
* generic progress line) rather than a conversational reply. */
|
* generic progress line) rather than a conversational reply. */
|
||||||
kind?: "tool_hint" | "progress" | "reasoning";
|
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";
|
event: "delta";
|
||||||
@@ -190,7 +209,26 @@ export type InboundEvent =
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
model_preset?: string | null;
|
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: "session_updated"; chat_id: string }
|
||||||
| { event: "error"; chat_id?: string; detail?: string };
|
| { event: "error"; chat_id?: string; detail?: string };
|
||||||
|
|
||||||
@@ -212,6 +250,14 @@ export interface OutboundImageGeneration {
|
|||||||
aspect_ratio?: string | null;
|
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 =
|
export type Outbound =
|
||||||
| { type: "new_chat" }
|
| { type: "new_chat" }
|
||||||
| { type: "attach"; chat_id: string }
|
| { type: "attach"; chat_id: string }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
deleteSession,
|
deleteSession,
|
||||||
fetchSessionMessages,
|
fetchWebuiThread,
|
||||||
listSessions,
|
listSessions,
|
||||||
listSlashCommands,
|
listSlashCommands,
|
||||||
updateProviderSettings,
|
updateProviderSettings,
|
||||||
@@ -21,13 +21,14 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("percent-encodes websocket keys when fetching session history", async () => {
|
it("percent-encodes websocket keys when fetching webui-thread snapshot", async () => {
|
||||||
await fetchSessionMessages("tok", "websocket:chat-1");
|
await fetchWebuiThread("tok", "websocket:chat-1");
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith(
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
"/api/sessions/websocket%3Achat-1/messages",
|
"/api/sessions/websocket%3Achat-1/webui-thread",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
headers: { Authorization: "Bearer tok" },
|
headers: { Authorization: "Bearer tok" },
|
||||||
|
credentials: "same-origin",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { setAppLanguage } from "@/i18n";
|
import { setAppLanguage } from "@/i18n";
|
||||||
import { fmtDateTime, relativeTime } from "@/lib/format";
|
import { fmtDateTime, formatTurnLatency, relativeTime } from "@/lib/format";
|
||||||
|
|
||||||
describe("localized format helpers", () => {
|
describe("localized format helpers", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -61,4 +61,22 @@ describe("localized format helpers", () => {
|
|||||||
);
|
);
|
||||||
expect(english).not.toBe(french);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,19 @@ describe("MessageBubble", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
|
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", () => {
|
it("renders trace messages as collapsible tool groups", () => {
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
id: "t1",
|
id: "t1",
|
||||||
@@ -118,7 +131,7 @@ describe("MessageBubble", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Step 1: parse intent\./)).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");
|
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();
|
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", () => {
|
it("renders assistant image media as a larger generated result", () => {
|
||||||
const message: UIMessage = {
|
const message: UIMessage = {
|
||||||
id: "a-image",
|
id: "a-image",
|
||||||
|
|||||||
@@ -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", () => {
|
it("dispatches runtime model updates globally", () => {
|
||||||
const client = new NanobotClient({
|
const client = new NanobotClient({
|
||||||
url: "ws://test",
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -88,6 +88,50 @@ describe("ThreadComposer", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
|
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", () => {
|
it("opens a slash command palette and inserts the selected command", () => {
|
||||||
const onSend = vi.fn();
|
const onSend = vi.fn();
|
||||||
render(
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { render } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
describe("ThreadMessages", () => {
|
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[] = [
|
const messages: UIMessage[] = [
|
||||||
{
|
{
|
||||||
id: "r1",
|
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 ?? []);
|
const rows = Array.from(container.firstElementChild?.children ?? []);
|
||||||
|
|
||||||
expect(rows[0]).not.toHaveClass("mt-2", "mt-5");
|
expect(rows).toHaveLength(2);
|
||||||
expect(rows[1]).toHaveClass("mt-2");
|
expect(rows[0]).not.toHaveClass("mt-2", "mt-4", "mt-5");
|
||||||
expect(rows[2]).toHaveClass("mt-2");
|
expect(rows[1]).toHaveClass("mt-4");
|
||||||
expect(rows[3]).toHaveClass("mt-5");
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
import type { UIMessage } from "@/lib/types";
|
||||||
function makeClient() {
|
function makeClient() {
|
||||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||||
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
||||||
|
const goalStateByChatId = new Map<string, import("@/lib/types").GoalStateWsPayload>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
defaultChatId: null as string | null,
|
defaultChatId: null as string | null,
|
||||||
onStatus: () => () => {},
|
onStatus: () => () => {},
|
||||||
onRuntimeModelUpdate: () => () => {},
|
onRuntimeModelUpdate: () => () => {},
|
||||||
|
getRunStartedAt: () => null,
|
||||||
|
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
|
||||||
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
||||||
let handlers = chatHandlers.get(chatId);
|
let handlers = chatHandlers.get(chatId);
|
||||||
if (!handlers) {
|
if (!handlers) {
|
||||||
@@ -41,6 +44,9 @@ function makeClient() {
|
|||||||
for (const h of errorHandlers) h(err);
|
for (const h of errorHandlers) h(err);
|
||||||
},
|
},
|
||||||
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
|
_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);
|
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||||
},
|
},
|
||||||
_emitSessionUpdate(chatId: string) {
|
_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) {
|
function httpJson(body: unknown) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -358,16 +378,13 @@ describe("ThreadShell", () => {
|
|||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.includes("websocket%3Achat-a/messages")) {
|
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||||
return httpJson({
|
return httpJson(
|
||||||
key: "websocket:chat-a",
|
transcriptFromSimpleMessages([
|
||||||
created_at: null,
|
|
||||||
updated_at: null,
|
|
||||||
messages: [
|
|
||||||
{ role: "user", content: "old question" },
|
{ role: "user", content: "old question" },
|
||||||
{ role: "assistant", content: "old answer" },
|
{ role: "assistant", content: "old answer" },
|
||||||
],
|
]),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -509,15 +526,8 @@ describe("ThreadShell", () => {
|
|||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.includes("websocket%3Achat-a/messages")) {
|
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||||
return httpJson({
|
return httpJson(transcriptFromSimpleMessages([{ role: "user", content: "hello" }]));
|
||||||
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" }],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -590,19 +600,18 @@ describe("ThreadShell", () => {
|
|||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.includes("websocket%3Achat-a/messages")) {
|
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||||
historyCalls += 1;
|
historyCalls += 1;
|
||||||
return httpJson({
|
return httpJson(
|
||||||
key: "websocket:chat-a",
|
transcriptFromSimpleMessages(
|
||||||
created_at: null,
|
historyCalls === 1
|
||||||
updated_at: null,
|
? [{ role: "user", content: "question" }]
|
||||||
messages: historyCalls === 1
|
: [
|
||||||
? [{ role: "user", content: "question" }]
|
{ role: "user", content: "question" },
|
||||||
: [
|
{ role: "assistant", content: "canonical markdown answer" },
|
||||||
{ role: "user", content: "question" },
|
],
|
||||||
{ role: "assistant", content: "canonical markdown answer" },
|
),
|
||||||
],
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -650,16 +659,13 @@ describe("ThreadShell", () => {
|
|||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (input: RequestInfo | URL) => {
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.includes("websocket%3Achat-a/messages")) {
|
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||||
return httpJson({
|
return httpJson(
|
||||||
key: "websocket:chat-a",
|
transcriptFromSimpleMessages([
|
||||||
created_at: null,
|
|
||||||
updated_at: null,
|
|
||||||
messages: [
|
|
||||||
{ role: "user", content: "question" },
|
{ role: "user", content: "question" },
|
||||||
{ role: "assistant", content: "loaded answer" },
|
{ role: "assistant", content: "loaded answer" },
|
||||||
],
|
]),
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -703,7 +709,7 @@ describe("ThreadShell", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||||
block: "end",
|
block: "end",
|
||||||
behavior: "smooth",
|
behavior: "auto",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -879,17 +885,14 @@ describe("ThreadShell", () => {
|
|||||||
"fetch",
|
"fetch",
|
||||||
vi.fn((input: RequestInfo | URL) => {
|
vi.fn((input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.includes("websocket%3Achat-a/messages")) {
|
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
httpJson({
|
httpJson(
|
||||||
key: "websocket:chat-a",
|
transcriptFromSimpleMessages([{ role: "assistant", content: "from chat a" }]),
|
||||||
created_at: null,
|
),
|
||||||
updated_at: null,
|
|
||||||
messages: [{ 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) => {
|
return new Promise((resolve) => {
|
||||||
resolveChatB = resolve;
|
resolveChatB = resolve;
|
||||||
});
|
});
|
||||||
@@ -937,12 +940,7 @@ describe("ThreadShell", () => {
|
|||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
resolveChatB?.(
|
resolveChatB?.(
|
||||||
httpJson({
|
httpJson(transcriptFromSimpleMessages([{ role: "assistant", content: "from chat b" }])),
|
||||||
key: "websocket:chat-b",
|
|
||||||
created_at: null,
|
|
||||||
updated_at: null,
|
|
||||||
messages: [{ role: "assistant", content: "from chat b" }],
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ describe("ThreadViewport", () => {
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||||
block: "end",
|
block: "end",
|
||||||
behavior: "smooth",
|
behavior: "auto",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,19 +3,48 @@ import type { ReactNode } from "react";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
import { useNanobotStream } from "@/hooks/useNanobotStream";
|
||||||
import type { InboundEvent } from "@/lib/types";
|
import type { InboundEvent, GoalStateWsPayload } from "@/lib/types";
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
|
||||||
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
|
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
|
||||||
|
|
||||||
function fakeClient() {
|
function fakeClient() {
|
||||||
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
|
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 {
|
return {
|
||||||
client: {
|
client: {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
defaultChatId: null as string | null,
|
defaultChatId: null as string | null,
|
||||||
onStatus: () => () => {},
|
onStatus: () => () => {},
|
||||||
onError: () => () => {},
|
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) {
|
onChat(chatId: string, h: (ev: InboundEvent) => void) {
|
||||||
let set = handlers.get(chatId);
|
let set = handlers.get(chatId);
|
||||||
if (!set) {
|
if (!set) {
|
||||||
@@ -33,6 +62,8 @@ function fakeClient() {
|
|||||||
updateUrl: vi.fn(),
|
updateUrl: vi.fn(),
|
||||||
},
|
},
|
||||||
emit(chatId: string, ev: InboundEvent) {
|
emit(chatId: string, ev: InboundEvent) {
|
||||||
|
recordGoalStatusForRunStrip(chatId, ev);
|
||||||
|
recordGoalStateSnapshot(chatId, ev);
|
||||||
const set = handlers.get(chatId);
|
const set = handlers.get(chatId);
|
||||||
set?.forEach((h) => h(ev));
|
set?.forEach((h) => h(ev));
|
||||||
},
|
},
|
||||||
@@ -113,6 +144,28 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[1].kind).toBeUndefined();
|
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", () => {
|
it("renders live tool traces from structured tool events", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-tool-events", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-tool-events", EMPTY_MESSAGES), {
|
||||||
@@ -656,4 +709,137 @@ describe("useNanobotStream", () => {
|
|||||||
expect(onTurnEnd).toHaveBeenCalledTimes(1);
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|||||||
...actual,
|
...actual,
|
||||||
listSessions: vi.fn(),
|
listSessions: vi.fn(),
|
||||||
deleteSession: vi.fn(),
|
deleteSession: vi.fn(),
|
||||||
fetchSessionMessages: vi.fn(),
|
fetchWebuiThread: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ function fakeClient() {
|
|||||||
onStatus: () => () => {},
|
onStatus: () => () => {},
|
||||||
onError: () => () => {},
|
onError: () => () => {},
|
||||||
onChat: () => () => {},
|
onChat: () => () => {},
|
||||||
|
getRunStartedAt: () => null,
|
||||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
onSessionUpdate: (handler: (chatId: string) => void) => {
|
||||||
sessionUpdateHandlers.add(handler);
|
sessionUpdateHandlers.add(handler);
|
||||||
return () => sessionUpdateHandlers.delete(handler);
|
return () => sessionUpdateHandlers.delete(handler);
|
||||||
@@ -57,7 +58,7 @@ describe("useSessions", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(api.listSessions).mockReset();
|
vi.mocked(api.listSessions).mockReset();
|
||||||
vi.mocked(api.deleteSession).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 () => {
|
it("removes a session from the local list after delete succeeds", async () => {
|
||||||
@@ -98,14 +99,14 @@ describe("useSessions", () => {
|
|||||||
it("refreshes sessions when the websocket reports a session update", async () => {
|
it("refreshes sessions when the websocket reports a session update", async () => {
|
||||||
vi.mocked(api.listSessions)
|
vi.mocked(api.listSessions)
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
key: "websocket:chat-a",
|
key: "websocket:chat-a",
|
||||||
channel: "websocket",
|
channel: "websocket",
|
||||||
chatId: "chat-a",
|
chatId: "chat-a",
|
||||||
createdAt: "2026-04-16T10:00:00Z",
|
createdAt: "2026-04-16T10:00:00Z",
|
||||||
updatedAt: "2026-04-16T10:00:00Z",
|
updatedAt: "2026-04-16T10:00:00Z",
|
||||||
preview: "",
|
preview: "",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.mockResolvedValueOnce([
|
.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
@@ -134,35 +135,26 @@ describe("useSessions", () => {
|
|||||||
expect(api.listSessions).toHaveBeenCalledTimes(2);
|
expect(api.listSessions).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates media_urls from historical user turns into UIMessage.images", async () => {
|
it("passes through WebUI transcript user media as images and media", async () => {
|
||||||
// Round-trip check for the signed-media replay: the backend emits
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
// ``media_urls`` on a historical user row and the hook must surface them
|
schemaVersion: 3,
|
||||||
// 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",
|
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
|
id: "u1",
|
||||||
role: "user",
|
role: "user",
|
||||||
content: "what's this?",
|
content: "what's this?",
|
||||||
timestamp: "2026-04-20T10:00:00Z",
|
createdAt: 1,
|
||||||
media_urls: [
|
images: [
|
||||||
{ url: "/api/media/sig-1/payload-1", name: "snap.png" },
|
{ url: "/api/media/sig-1/payload-1", name: "snap.png" },
|
||||||
{ url: "/api/media/sig-2/payload-2", name: "diag.jpg" },
|
{ 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" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{ id: "a1", role: "assistant", content: "it's a cat", createdAt: 2 },
|
||||||
role: "assistant",
|
{ id: "u2", role: "user", content: "follow-up without images", createdAt: 3 },
|
||||||
content: "it's a cat",
|
|
||||||
timestamp: "2026-04-20T10:00:01Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: "follow-up without images",
|
|
||||||
timestamp: "2026-04-20T10:01:00Z",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,19 +179,16 @@ describe("useSessions", () => {
|
|||||||
expect(third.images).toBeUndefined();
|
expect(third.images).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates historical assistant video media_urls into media attachments", async () => {
|
it("passes through assistant video media from transcript replay", async () => {
|
||||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
key: "websocket:chat-video",
|
schemaVersion: 3,
|
||||||
created_at: "2026-04-20T10:00:00Z",
|
|
||||||
updated_at: "2026-04-20T10:05:00Z",
|
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
|
id: "a1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "clip ready",
|
content: "clip ready",
|
||||||
timestamp: "2026-04-20T10:00:01Z",
|
createdAt: 1,
|
||||||
media_urls: [
|
media: [{ kind: "video", url: "/api/media/sig-v/payload-v", name: "clip.mp4" }],
|
||||||
{ url: "/api/media/sig-v/payload-v", name: "clip.mp4" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -210,24 +199,23 @@ describe("useSessions", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
expect(result.current.messages[0].role).toBe("assistant");
|
expect(result.current.messages[0]!.role).toBe("assistant");
|
||||||
expect(result.current.messages[0].images).toBeUndefined();
|
expect(result.current.messages[0]!.images).toBeUndefined();
|
||||||
expect(result.current.messages[0].media).toEqual([
|
expect(result.current.messages[0]!.media).toEqual([
|
||||||
{ kind: "video", url: "/api/media/sig-v/payload-v", name: "clip.mp4" },
|
{ kind: "video", url: "/api/media/sig-v/payload-v", name: "clip.mp4" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates persisted assistant reasoning into the replayed message", async () => {
|
it("passes through assistant reasoning from transcript replay", async () => {
|
||||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
key: "websocket:chat-reasoning",
|
schemaVersion: 3,
|
||||||
created_at: "2026-04-20T10:00:00Z",
|
|
||||||
updated_at: "2026-04-20T10:05:00Z",
|
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
|
id: "a1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "final answer",
|
content: "final answer",
|
||||||
timestamp: "2026-04-20T10:00:01Z",
|
createdAt: 1,
|
||||||
reasoning_content: "hidden but persisted reasoning",
|
reasoning: "hidden but persisted reasoning",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -239,75 +227,25 @@ describe("useSessions", () => {
|
|||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(1);
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0].role).toBe("assistant");
|
expect(result.current.messages[0]!.role).toBe("assistant");
|
||||||
expect(result.current.messages[0].content).toBe("final answer");
|
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]!.reasoning).toBe("hidden but persisted reasoning");
|
||||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops replayed assistant turns that only contain reasoning", async () => {
|
it("accepts transcript rows produced by the server replay reducer", async () => {
|
||||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
key: "websocket:chat-empty-reasoning",
|
schemaVersion: 3,
|
||||||
created_at: "2026-04-20T10:00:00Z",
|
|
||||||
updated_at: "2026-04-20T10:05:00Z",
|
|
||||||
messages: [
|
messages: [
|
||||||
|
{ id: "u1", role: "user", content: "research this", createdAt: 1 },
|
||||||
{
|
{
|
||||||
role: "assistant",
|
id: "t1",
|
||||||
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\"}" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
role: "tool",
|
role: "tool",
|
||||||
content: "tool output that should not render directly",
|
kind: "trace",
|
||||||
timestamp: "2026-04-20T10:00:02Z",
|
content: "web_fetch({})",
|
||||||
tool_call_id: "call-1",
|
traces: ["web_search({\"query\":\"agents\"})", "web_fetch({\"url\":\"https://example.com\"})"],
|
||||||
},
|
createdAt: 2,
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: "summary",
|
|
||||||
timestamp: "2026-04-20T10:00:03Z",
|
|
||||||
},
|
},
|
||||||
|
{ id: "a1", role: "assistant", content: "summary", createdAt: 3 },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,26 +256,26 @@ describe("useSessions", () => {
|
|||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
expect(result.current.messages.map((m) => m.role)).toEqual(["user", "tool", "assistant"]);
|
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.kind).toBe("trace");
|
||||||
expect(trace.traces).toEqual([
|
expect(trace.traces).toEqual([
|
||||||
"web_search({\"query\":\"agents\"})",
|
"web_search({\"query\":\"agents\"})",
|
||||||
"web_fetch({\"url\":\"https://example.com\"})",
|
"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 () => {
|
it("flags transcript ending with a trace row as pending", async () => {
|
||||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
key: "websocket:chat-pending",
|
schemaVersion: 3,
|
||||||
created_at: "2026-04-20T10:00:00Z",
|
|
||||||
updated_at: "2026-04-20T10:05:00Z",
|
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "assistant",
|
id: "t1",
|
||||||
|
role: "tool",
|
||||||
|
kind: "trace",
|
||||||
content: "Using 2 tools",
|
content: "Using 2 tools",
|
||||||
timestamp: "2026-04-20T10:00:01Z",
|
traces: ["Using 2 tools"],
|
||||||
tool_calls: [{ id: "call-1" }],
|
createdAt: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -351,47 +289,11 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.hasPendingToolCalls).toBe(true);
|
expect(result.current.hasPendingToolCalls).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps pending when tool result rows trail assistant tool calls", async () => {
|
it("does not flag transcript as pending when last row is not a trace", async () => {
|
||||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
key: "websocket:chat-pending-tool-result",
|
schemaVersion: 3,
|
||||||
created_at: "2026-04-20T10:00:00Z",
|
|
||||||
updated_at: "2026-04-20T10:05:00Z",
|
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{ id: "a1", role: "assistant", content: "All done", createdAt: 1 },
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -404,6 +306,19 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
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 () => {
|
it("keeps the session in the list when delete fails", async () => {
|
||||||
vi.mocked(api.listSessions).mockResolvedValue([
|
vi.mocked(api.listSessions).mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user