From e209ed975294ca701364c6113e7fdc7a904a6073 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 15:51:55 -0500 Subject: [PATCH 01/39] Define the chat TUI redesign contract --- IMPLEMENTATION_PLAN.md | 27 ++ docs/prd/PRD-004-cli-onboarding-and-config.md | 36 +- ...RD-009-input-adapters-and-unified-input.md | 17 +- ...SPEC-002-session-lifecycle-and-protocol.md | 43 +- docs/spec/SPEC-004-cli-contract.md | 21 + .../SPEC-010-testing-and-smoke-strategy.md | 27 ++ docs/spec/SPEC-011-daemon-architecture.md | 27 +- docs/ui/TUI-001-command-wireframes.md | 214 +++++++--- .../redesign-netclaw-chat-tui/.openspec.yaml | 2 + .../redesign-netclaw-chat-tui/design.md | 393 ++++++++++++++++++ .../redesign-netclaw-chat-tui/proposal.md | 98 +++++ .../specs/netclaw-chat-tui/spec.md | 245 +++++++++++ .../specs/netclaw-cli/spec.md | 53 +++ .../specs/netclaw-session/spec.md | 69 +++ .../specs/netclaw-subagents/spec.md | 59 +++ .../specs/netclaw-testing/spec.md | 79 ++++ .../specs/session-resume/spec.md | 78 ++++ .../redesign-netclaw-chat-tui/tasks.md | 138 ++++++ 18 files changed, 1534 insertions(+), 92 deletions(-) create mode 100644 openspec/changes/redesign-netclaw-chat-tui/.openspec.yaml create mode 100644 openspec/changes/redesign-netclaw-chat-tui/design.md create mode 100644 openspec/changes/redesign-netclaw-chat-tui/proposal.md create mode 100644 openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-chat-tui/spec.md create mode 100644 openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-cli/spec.md create mode 100644 openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-session/spec.md create mode 100644 openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-subagents/spec.md create mode 100644 openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md create mode 100644 openspec/changes/redesign-netclaw-chat-tui/specs/session-resume/spec.md create mode 100644 openspec/changes/redesign-netclaw-chat-tui/tasks.md diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 3960dffd0..aa2a58973 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -110,6 +110,33 @@ the smallest repeatable manual script plus expected output. ## NOW +### Priority: Redesign The Developer Chat TUI + +**PRDs:** `docs/prd/PRD-004-cli-onboarding-and-config.md`, `docs/prd/PRD-009-input-adapters-and-unified-input.md` +**Specs:** `docs/spec/SPEC-002-session-lifecycle-and-protocol.md`, `docs/spec/SPEC-004-cli-contract.md`, `docs/spec/SPEC-010-testing-and-smoke-strategy.md`, `docs/spec/SPEC-011-daemon-architecture.md` +**OpenSpec:** `openspec/changes/redesign-netclaw-chat-tui/` +**Surface area:** session output, SignalR, Termina, chat command, resume, input, approvals, copy +**Verification:** L3 plus the Termina cross-platform matrix + +The user promoted this work into `NOW`. The chat command must expose the +daemon's structured event model through a clear developer interface. + +The design is extend-only. Existing protocol fields, enum values, presentation +modes, and non-chat applications keep their current behavior and defaults. + +Done when: + +- [ ] Termina adds an opt-in inline mode while full-screen mode stays the + default. +- [ ] Netclaw shows thoughts, parallel tools, sub-agents, approvals, file + changes, errors, usage, and compaction as structured events. +- [ ] Structured resume data extends `RecentMessages` without removing it. +- [ ] The composer supports `Shift+Enter`, draft restoration, and double + Escape. +- [ ] Native terminal scrollback, mouse-wheel input, semantic copy, and the + inspector pass their defined proof matrix. +- [ ] Headless tests and native smoke tests prove the critical chat flows. + ### Priority: Keep MCP HTTP Protocol Fallback Deterministic **PRD:** `docs/prd/PRD-006-mcp-tool-integration.md` diff --git a/docs/prd/PRD-004-cli-onboarding-and-config.md b/docs/prd/PRD-004-cli-onboarding-and-config.md index 79569da66..5d2b338a2 100644 --- a/docs/prd/PRD-004-cli-onboarding-and-config.md +++ b/docs/prd/PRD-004-cli-onboarding-and-config.md @@ -11,6 +11,7 @@ offline vs daemon-required command categorization) - Revised: 2026-05-24 (bootstrap-only `init`, domain-oriented `config`, init-owned identity re-entry, explicit reset flow) +- Revised: 2026-08-11 (inline chat, structured output, input, approval, and copy contracts) - Depends on: `PRD-001`, `PRD-002` ## Goal @@ -38,7 +39,7 @@ Netclaw ships as two binaries (see PRD-001 for full architecture): - **Simple arg routing** in `Program.cs` for command selection (Cocona is archived as of Dec 2025 — replaced with direct `args[0]` routing) -- **Termina 0.5.1** for interactive TUI commands (`netclaw init`, `netclaw chat`) +- **Termina** for interactive TUI commands, with an explicit mode for each application - All other commands use plain console output - Commands that need the daemon connect via `Microsoft.AspNetCore.SignalR.Client` - If the daemon isn't running and a command requires it, print an error with @@ -133,7 +134,8 @@ Command ownership stays explicit: daemon over SignalR. Renders `SessionOutput` stream, sends `ChannelInput`. Session entity key: `tui/{uuid}`. If `netclaw.json` is absent, the command SHALL fail before contacting the daemon with - `daemon not configured - please run netclaw init`. See TUI-001 wireframes. + `daemon not configured - please run netclaw init`. Chat SHALL use the primary + terminal buffer after its full-screen session picker exits. See TUI-001. ### TUI-Interactive Commands (Termina, offline) @@ -297,11 +299,13 @@ Results are persisted to the environment inventory file. ### CLI-010 TUI Commands -`netclaw init`, `netclaw config`, and `netclaw chat` SHALL use Termina 0.5.1 -for interactive TUI rendering. Bare `netclaw provider` and `netclaw model` -SHALL also use Termina. All other commands SHALL use plain console output. TUI -commands SHALL launch Termina as a hosted service within the mode-selected host -builder. +`netclaw init`, `netclaw config`, and `netclaw chat` SHALL use the pinned Termina +package. Bare `netclaw provider` and `netclaw model` SHALL also use Termina. All +other commands SHALL use plain console output. + +Each Termina application SHALL select one presentation mode for its lifetime. +Chat SHALL select `Inline` and `NativeTerminal`. Setup, config, provider, model, +and session picker applications SHALL retain `FullScreen`. ### CLI-011 Chat Thin Client @@ -312,12 +316,22 @@ interactive TUI for agent conversations. The TUI SHALL: - Create a session via the hub and receive a session ID - Send `ChannelInput` messages via SignalR - Subscribe to `SessionOutput` stream for rendering -- Render session output as streaming text via StreamingTextNode -- Display tool invocation status inline (completed with duration, in-progress - with spinner) -- Show model name, token usage, and context percentage in status bar +- Reduce each typed output into immutable settled content and a bounded live deck +- Show thought, parallel tool, sub-agent, approval, file, error, usage, + compaction, and turn outcome forms with stable identities +- Keep the settled transcript borderless in native terminal scrollback +- Show model name, token usage, and context percentage in the Session Header +- Use bare `Enter` to submit and `Shift+Enter` to add a newline +- Restore a saved draft after prompt history reaches its newest entry +- Clear prompt text only after two Escape keys inside a `TimeProvider` window +- Give a pending approval input priority and preserve `Ctrl+O` detail expansion +- Provide an Inspector and semantic copy for complete safe event detail +- Report copy, terminal, and unsupported-event failures visibly - Print a clear error if the daemon is not running +The client SHALL preserve `RecentMessages` compatibility. It SHALL prefer the +additive structured transcript when the daemon supplies it. + ### CLI-012 Daemon Management The CLI SHALL provide commands to manage the daemon lifecycle: diff --git a/docs/prd/PRD-009-input-adapters-and-unified-input.md b/docs/prd/PRD-009-input-adapters-and-unified-input.md index 2f540392f..d0a87bca4 100644 --- a/docs/prd/PRD-009-input-adapters-and-unified-input.md +++ b/docs/prd/PRD-009-input-adapters-and-unified-input.md @@ -6,6 +6,7 @@ - Owner: Netclaw engineering - Date: 2026-02-21 - Revised: 2026-02-23 (daemon + thin client split, TUI as SignalR client) +- Revised: 2026-08-11 (correlated activity and structured resume output) - Depends on: `PRD-001`, `PRD-002`, `PRD-008` ## Goal @@ -53,9 +54,11 @@ child session actor. - Receives keyboard input via Termina TextInputNode - Sends `ChannelInput` to daemon over SignalR - Subscribes to `SessionOutput` stream over SignalR for rendering -- Renders responses as streaming text via StreamingTextNode -- Displays tool invocation status inline (name, duration, spinner) -- Shows model name, token usage, and context percentage in status bar +- Reduces typed output into settled transcript blocks and one bounded live deck +- Correlates parallel tools by `CallId` and sub-agents by `RunId` +- Shows thought, approval, file, error, usage, compaction, and turn outcome data +- Restores settled event chronology from the structured resume contract +- Keeps transient tool progress outside model context and persisted history **Slack Socket Mode Adapter** (Phase 1): - Runs in-process within the daemon @@ -167,6 +170,14 @@ The TUI adapter is a pure thin client running in the `Netclaw.Cli` binary — all agent logic lives in the daemon. The TUI adapter SHALL display tool invocation status inline between user message and response. +The output contract SHALL preserve all security-safe typed fields across +SignalR. Tool activity SHALL retain `CallId` and turn identity. Sub-agent +activity SHALL retain `RunId` and parent `CallId`. + +The daemon SHALL emit both the current `RecentMessages` field and an additive +structured settled transcript during the compatibility period. The TUI SHALL +not restore an old settled record as active work. + ## Acceptance Criteria (MVP) 1. TUI adapter receives input, routes through session actor, renders streaming diff --git a/docs/spec/SPEC-002-session-lifecycle-and-protocol.md b/docs/spec/SPEC-002-session-lifecycle-and-protocol.md index 5ee3e762d..c9b607e47 100644 --- a/docs/spec/SPEC-002-session-lifecycle-and-protocol.md +++ b/docs/spec/SPEC-002-session-lifecycle-and-protocol.md @@ -1,6 +1,6 @@ # SPEC-002: Session Lifecycle and Protocol -Source PRDs: `PRD-001` +Source PRDs: `PRD-001`, `PRD-009` Research: `docs/research/context-management-patterns.md` ## Purpose @@ -106,6 +106,19 @@ always delivered regardless of filter. `UsageOutput` includes `ContextWindowTokens` and `UsagePercent` so subscribers can display context consumption without duplicating session config. +`ToolActivityOutput` carries a stable `CallId`, turn identity, safe phase, and +safe summary. A terminal tool result uses the same `CallId`. + +`SubAgentOutput` carries an additive `RunId` and parent `CallId`. Start, +activity, and completion output for one run uses the same identities. + +Tool and sub-agent activity uses `OutputFilter.ToolCalls`. Thought activity +uses `OutputFilter.Thinking`. Transient activity does not enter model context +or the actor journal. + +Every supported output field has an explicit SignalR mapper disposition. A +mapper test fails when a new field lacks mapped or security-omitted handling. + ## Behavior States ``` @@ -184,7 +197,31 @@ are summarized as "Used {tool} for {purpose} → {outcome}". | `SessionTitleSet` | Title generated or updated | | `SessionCompacted` | History compacted with summary + retained messages | +## Structured Resume Timeline + +The session keeps a bounded settled timeline for the recent turn window. The +timeline uses framework-owned records with stable discriminators. + +The timeline can contain these settled entries: + +- user and assistant text +- disclosed thought summary +- tool call and result +- sub-agent result +- file metadata +- error and usage detail +- compaction detail +- approval and turn outcome + +`SessionJoined` keeps `RecentMessages`. It adds a nullable `RecentTranscript`. +The daemon emits both fields during the compatibility period. + +New readers prefer `RecentTranscript` when present. An absent timeline selects +an explicit legacy conversion path. Unsupported legacy detail produces a +diagnostic entry and never creates a false active state. + ## Snapshot -`SessionSnapshot` captures `History`, `TurnCount`, `Title` for fast recovery. -Taken periodically per `SessionConfig.SnapshotInterval` and after compaction. +`SessionSnapshot` captures `History`, `TurnCount`, `Title`, and the additive +settled timeline for fast recovery. New Protobuf tags preserve current tags. +Snapshots occur per `SessionConfig.SnapshotInterval` and after compaction. diff --git a/docs/spec/SPEC-004-cli-contract.md b/docs/spec/SPEC-004-cli-contract.md index 819e04866..d3cbf2b72 100644 --- a/docs/spec/SPEC-004-cli-contract.md +++ b/docs/spec/SPEC-004-cli-contract.md @@ -94,6 +94,26 @@ Behavior: - smoke test command runs optional live integration checks outside CI-required test suite +### 7) Interactive Chat + +- `netclaw chat [--session ]` + +Behavior: + +- starts a Termina application with `Inline` presentation +- selects `NativeTerminal` scroll input +- leaves settled output in the primary terminal buffer +- exits any full-screen session picker before chat starts +- fails visibly when inline mode cannot start +- never selects full-screen chat as a silent fallback + +Setup, config, provider, model, and session picker applications retain +`FullScreen` presentation. + +The chat composer uses bare `Enter` for submit and `Shift+Enter` for a newline. +A pending approval owns input before the composer. `Ctrl+O` changes approval +detail without a decision. + ## Output and Exit Codes - default output: human readable text @@ -110,6 +130,7 @@ Behavior: - read-only default for all inspection commands - mutating commands require explicit confirmation or `--yes` - no command may silently broaden exposure policy +- no TUI command may silently change its terminal presentation mode ## Onboarding State Persistence diff --git a/docs/spec/SPEC-010-testing-and-smoke-strategy.md b/docs/spec/SPEC-010-testing-and-smoke-strategy.md index 2b90b4bc2..8e5531dc3 100644 --- a/docs/spec/SPEC-010-testing-and-smoke-strategy.md +++ b/docs/spec/SPEC-010-testing-and-smoke-strategy.md @@ -29,6 +29,32 @@ tests can validate real provider integrations. - explicit opt-in tests using real endpoints (for example, local Ollama) - intended for developer or pre-release validation +## Chat TUI Proof Matrix + +The chat redesign requires deterministic headless proof and native terminal +proof. Unit tests alone cannot satisfy this contract. + +Headless tests SHALL cover: + +- every supported `SessionOutput` disposition +- parallel tools that finish out of order +- same-name sub-agents with distinct `RunId` values +- structured resume and old payload conversion +- layouts at 40, 60, 80, and 120 columns +- `Shift+Enter`, history draft restore, and double Escape with virtual time +- approval priority, `Ctrl+O`, detail scroll, paste, and semantic copy + +The native Netclaw tape SHALL use the published CLI. It SHALL cover startup, +typed input, paste, resize, approval detail, prompt recall, interruption, and +clean exit. Each action SHALL use a semantic anchor and no fixed delay. + +The Termina package SHALL provide separate primary-buffer proof for Linux, +macOS, Windows Terminal, and tmux. The proof SHALL cover resize, paste, +selection, scrollback, and exit recovery. + +The full-screen smoke suite SHALL prove that init, config, provider, model, and +picker applications retain their current terminal lifecycle. + ## Critical Producer/Consumer Contract Inventory The contracts below are the minimum cross-boundary producer/consumer pairs that @@ -45,6 +71,7 @@ proof is not complete yet, the gap is assigned to an explicit `NOW` task in | Scheduler -> delivery gateway | `SetReminderTool` and reminder persistence write `ReminderDefinition.Delivery` and later emit trusted delivery messages | Reminder execution actor and provider session binding actors that deliver without re-running inbound ACL | `Delivery.Kind` is `Channel` for channel delivery, `Delivery.Transport` is the lowercase provider key such as `slack`, and `Delivery.Address` is a canonical provider channel/user ID resolved before persistence. Runtime trusted delivery uses the stored target rather than a display name. | `src/Netclaw.Daemon.Tests/Reminder/ReminderTargetResolutionPathTests.cs` proves display target resolution to canonical channel/user IDs and unresolved target rejection. `src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs` proves delivery success/failure reporting. Full gateway-chain and no-inbound-ACL re-entry coverage remains an explicit gap in Task 5.3. | | Tool schemas -> model/tool dispatcher | Built-in tool registrations and MCP tool adapters expose tool declarations and schemas | Provider serializers, `SessionToolExecutionPipeline`, `McpToolAdapter`, and MCP client manager | Model-facing tools serialize as OpenAI-compatible function tools with stable names, descriptions, JSON Schema parameters, and required fields. MCP tool names use `server/tool`. Dispatcher arguments preserve schema-declared string values and reconstruct structured JSON values only when the schema requires them. | `src/Netclaw.Daemon.Tests/Configuration/OpenAiCompatibleChatClientTests.cs` proves OpenAI function-tool serialization and tool-call history shape. `src/Netclaw.Daemon.Tests/Mcp/SmokeMcpServerArgumentCoercionTests.cs` proves schema-driven MCP argument reconstruction over the real stdio JSON-RPC path. Approval allow/deny/prompt and malformed metadata coverage remains an explicit gap in Task 4.2. | | Memory persistence -> prompt assembly | Memory curation, SQLite memory store, session events, and compaction events persist memory and conversation state | `SQLiteMemoryRecallCoordinator`, `SessionMessageAssembler`, and system prompt/session state assembly | Persisted memory uses framework-owned SQLite records and wire enum strings such as trust audience wire values. Session history uses `SerializableChatMessage` records, not provider SDK chat types. Recall appears as volatile context/nudges and does not mutate the stable system prompt prefix. | `src/Netclaw.Actors.Tests/Memory/SQLiteMemoryStoreTests.cs` proves memory persistence/search filtering and audience boundaries. `src/Netclaw.Actors.Tests/Memory/MemoryRedesignedEvalSuiteTests.cs` proves formation -> persistence -> recall. `src/Netclaw.Actors.Tests/Sessions/SessionMessageAssemblerTests.cs`, `SessionStateTests.cs`, and `src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs` prove prompt assembly placement and serialization-safe session records. Restart/recovery and corrupt/missing state coverage remains an explicit gap in Task 5.2. | +| Session output -> SignalR -> chat reducer | Session actor output relay and `SessionOutputMapper` | `DaemonClient`, chat presentation reducer, and inline output owner | Typed output keeps all security-safe fields. Tools use `CallId`. Sub-agents use `RunId` and parent `CallId`. Resume uses settled framework-owned transcript entries. | The `redesign-netclaw-chat-tui` change requires output parity, legacy payload, reducer, and native chat tests before completion. | ## CI Rules diff --git a/docs/spec/SPEC-011-daemon-architecture.md b/docs/spec/SPEC-011-daemon-architecture.md index 4218d7773..5e6668595 100644 --- a/docs/spec/SPEC-011-daemon-architecture.md +++ b/docs/spec/SPEC-011-daemon-architecture.md @@ -1,6 +1,6 @@ # SPEC-011: Daemon Architecture and Process Model -Source PRDs: `PRD-001`, `PRD-002`, `PRD-004` +Source PRDs: `PRD-001`, `PRD-002`, `PRD-004`, `PRD-009` ## Purpose @@ -135,6 +135,10 @@ ReceiveOutput(output: SessionOutputDto) → void `SessionOutputDto` is a wire-safe mapping of the `SessionOutput` discriminated union. The mapper handles union → flat DTO conversion for SignalR serialization. +It preserves every security-safe output field and all correlation identities. + +The DTO keeps `RecentMessages`. It adds nullable structured transcript and +activity fields. Old clients ignore the additive properties. ### Connection Lifecycle @@ -300,13 +304,22 @@ SignalR Client Adapter Daemon SignalR Hub (/hub/session) ``` -The `ChatViewModel` interface remains the same as the current in-process -implementation — it exposes `IObservable` and accepts -`SubmitAsync(text)`. The only change is the backend: SignalR client instead of -direct `SessionPipeline`. +`ChatViewModel` exposes the SignalR output stream and accepts prompt submission. +It also owns immutable presentation state for stable event identities. + +`ChatPage` uses these semantic regions: Session Header, Transcript, Activity +Rail, Decision Gate, Composer, and Status Line. It does not flatten typed output +into one mutable text value. + +The chat process uses Termina inline presentation and native terminal scroll. +The session picker remains a separate full-screen Termina application. The +picker exits before the inline application starts. + +All chat output uses the inline output owner. Diagnostic logs use the configured +file or structured sink while the live region is active. -`ChatPage` does not change at all. Same rendering, same paste debounce, same -status bar, same tool call spinners. +The daemon keeps transient tool and sub-agent activity outside persistence. It +persists only framework-owned settled transcript records with additive tags. ## Tool Execution Model diff --git a/docs/ui/TUI-001-command-wireframes.md b/docs/ui/TUI-001-command-wireframes.md index 7f52668da..5285979eb 100644 --- a/docs/ui/TUI-001-command-wireframes.md +++ b/docs/ui/TUI-001-command-wireframes.md @@ -115,84 +115,162 @@ PanelNode (outer: "Netclaw Setup") --- -## `netclaw chat` — Interactive Agent Prompt (TUI) +## `netclaw chat` — Inline Developer Chat -Full interactive chat session with the Netclaw agent. Hosts the actor system -in-process. Session entity key: `tui/{uuid}`. +Chat is a thin SignalR client. The daemon owns the session, tools, and +persistence. Chat uses Termina `Inline` presentation in the primary buffer. -### Wireframe +The settled Transcript becomes normal terminal scrollback. Termina owns only +the current live region. + +### Region Grammar + +| Region | Purpose | Lifetime | +|--------|---------|----------| +| Session Header | Shows session, model, context, and connection state | Printed when context changes | +| Transcript | Holds immutable settled Turns | Terminal scrollback | +| Turn | Groups one prompt with its settled events and reply | Immutable after settlement | +| Live Deck | Replaces the Composer during active work | Mutable live region | +| Activity Group | Groups parallel tools and sub-agents for one turn | Live until every row settles | +| Event Row | Shows one event identity, phase, summary, and detail state | Live or settled | +| Decision Gate | Replaces the Composer for a pending approval | Live until a decision | +| Composer | Accepts the next prompt | Live while idle | +| Hint Line | Shows only valid actions for the current mode | Live | +| Inspector | Shows complete safe detail for one event or Turn | Temporary full-screen view | + +The Transcript has no outer border. Settled rows use indentation, symbols, +space, and color for hierarchy. The Composer can use one small border. + +### Idle State at 120 Columns ``` -╭─ Netclaw Chat ─────────────────── session: tui/a1b2c3 ──────╮ -│ │ -│ System: Personality loaded. 5 tools. Memorizer connected. │ -│ │ -│ You: Check the CI status on netclaw and summarize │ -│ │ -│ ╭─ Tool Activity ──────────────────────────────────────╮ │ -│ │ ✓ shell: gh run list --limit 3 (2.1s) │ │ -│ │ ● web_fetch: github.com/...actions (...) │ │ -│ ╰──────────────────────────────────────────────────────╯ │ -│ │ -│ Netclaw: Here's your CI status: │ -│ │ -│ | Run | Branch | Status | Duration | │ -│ |------|--------|---------|----------| │ -│ | #42 | dev | ✓ pass | 3m 12s | │ -│ | #41 | dev | ✓ pass | 2m 58s | │ -│ | #40 | feat/x | ✗ fail | 1m 04s | │ -│ │ -│ Run #40 failed on feat/x. Want me to investigate? │ -│ ● │ -╰──────────────────────────────────────────────────────────────╯ -╭─ Input ──────────────────────────────────────────────────────╮ -│ Yes, show me the failure logs and │ -│ see if it's a flaky test or a real issue. │ -│ █ │ -╰──────────────────────────────────────────────────────────────╯ - [Enter] Send [PgUp/PgDn] Scroll [Ctrl+Q] Quit ✓ MCP (2/2) +netclaw session tui/a1b2c3 model gpt-5.6 context 38% daemon connected + +YOU Check the CI status and inspect any failed run. + +NETCLAW +Run 2481 passed. Run 2480 failed in the Linux test job. + + ✓ tool gh run list 2.1s #call-a + ✗ tool gh run view 2480 --log-failed 1.4s #call-b detail available + +The failure is a deterministic path assertion. I can prepare a fix. + +┌ prompt ───────────────────────────────────────────────────────────────────────────────────────────┐ +│ Ask Netclaw… │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +Enter send Shift+Enter newline ↑↓ history Esc Esc clear Ctrl+Q quit ``` -### Layout Structure +### Active Turn at 80 Columns + +``` +netclaw tui/a1b2c3 gpt-5.6 context 38% + +YOU Check the CI status and inspect any failed run. + +THOUGHT ● analyzing repository and workflow state 3s + +ACTIVITY 2 active · 1 complete + ✓ tool gh run list 2.1s #call-a + ● tool gh run view 2480 1.4s #call-b + └─ ● agent test-diagnostics 2 tools #run-7 + ● tool read failure log 0.8s #call-c + +Working… Ctrl+C interrupt Ctrl+O detail Ctrl+Q quit +``` + +The Live Deck replaces the Composer during an active turn. The first release +does not accept prompt type-ahead. + +### Decision Gate at 80 Columns + +``` +APPROVAL shell wants permission + +Target dotnet test +Effect starts a local process +Scope this exact command in /work/netclaw + + Allow once Always allow Deny + +Enter decide Esc deny Ctrl+O full detail ←→ choice Ctrl+Q quit +``` + +The expanded state keeps the selected decision. Page Up and Page Down move a +bounded detail viewport. Approval content displays control bytes as safe text. + +### Narrow State at 40 Columns + +``` +netclaw tui/a1b2c3 38% + +YOU Check CI and inspect failures. + +ACTIVITY 2 active + ✓ gh run list #call-a + ● gh run view #call-b + └─ ● test-diagnostics #run-7 + +Working… ^C stop ^O detail ^Q quit +``` + +At 40 columns, optional duration, model, and count detail leaves first. Event +identity, lifecycle, error state, input text, and detail availability remain. + +### Responsive Rules + +| Width | Session Header | Event Row | Hint Line | +|-------|----------------|-----------|-----------| +| 120+ | session, model, context, daemon, usage | phase, kind, full summary, duration, short ID | complete action labels | +| 80-119 | session, model, context | phase, kind, summary, duration, short ID | common action labels | +| 60-79 | session, context | phase, short kind, clipped summary, short ID | compact action labels | +| 40-59 | session suffix, context | phase, clipped name, short ID | control-key labels | + +No responsive rule merges unrelated events onto one line. Long content remains +available through the Inspector and semantic copy. + +### Event Forms + +- User and assistant text use quiet labels and no side rail. +- Thought uses one active row and a settled duration or token summary. +- Tool rows use `CallId` as their stable key. +- Sub-agent rows use `RunId` and show their parent `CallId` relation. +- File rows show path, change kind, and available metadata. +- Error rows show category, message, and short correlation ID. +- Usage rows retain input, output, cached, and reasoning token classes. +- Compaction rows retain cleared-result and summary counts. +- Unknown output creates a visible diagnostic row. + +### Flow Control ``` -PanelNode (outer: "Netclaw Chat", subtitle: session ID) -├── StreamingTextNode (scrollable chat history, fills available space) -│ ├── System messages (personality, tool count, MCP status) -│ ├── User messages (prefixed "You:") -│ ├── Tool Activity PanelNode (inline, collapsible) -│ │ ├── TextNode (✓ completed: tool name + duration, green) -│ │ └── SpinnerNode (● in-progress: tool name, yellow) -│ └── Assistant messages (prefixed "Netclaw:", streamed via SpinnerSegment) -│ -PanelNode (input: "Input") -├── TextInputNode (multi-line, 3 rows, fixed at bottom) -│ -TextNode (status bar: key bindings + MCP indicator) +Composer --Enter--> Live Deck --settled events--> Transcript --ready--> Composer + | + +--approval--> Decision Gate --decision--> Live Deck + | + +--inspect--> Inspector --close--> queued output commit ``` -### Key Behaviors - -- **StreamingTextNode** fills most of the screen, scrollable with PgUp/PgDn -- **TextInputNode** is multi-line (3 rows), fixed at bottom in its own PanelNode -- **Tool Activity** panel appears inline between user message and response: - - ✓ completed tools with name + duration (green) - - ● in-progress tools with SpinnerNode (yellow) - - Panel collapses when no tools are active -- **MCP status indicator** (bottom-right of status bar, reactive): - - `✓ MCP (2/2)` = green — all servers connected - - `⚠ MCP (1/2)` = yellow — degraded (auth required or warning) - - `✗ MCP (0/2)` = red — server(s) unreachable -- **SpinnerSegment** shows while LLM is thinking; tokens stream in real-time -- **Session entity key**: `tui/{uuid}`, full actor system hosted in-process -- **MCP**: per-agent, not gateway-level - -### Input Handling - -- [Enter] sends the current input buffer as a user message -- Multi-line input supported (Shift+Enter or paste) -- Input buffer clears after send -- History scrollback not implemented in MVP +Settled events print once in chronological order. A settled event never returns +to the Live Deck. Parallel completion updates only the matching stable identity. + +### Input and Copy + +- Bare `Enter` submits the prompt. +- `Shift+Enter` adds one newline. +- Up and Down traverse history at text boundaries. +- Down past the newest prompt restores the saved draft. +- Two Escape keys inside the defined virtual-time window clear prompt text. +- One Escape keeps prompt text. +- A pending approval owns Escape and paste before the Composer. +- `Ctrl+O` changes compact and expanded detail. +- Semantic copy can copy one complete event or one complete Turn. +- Semantic copy excludes ANSI bytes, borders, rails, spinners, and hints. +- A copy failure keeps the selected data and shows a visible error. + +Terminal-native selection cannot exclude selected glyphs. The borderless +Transcript prevents border and corner glyphs from entering ordinary selection. --- diff --git a/openspec/changes/redesign-netclaw-chat-tui/.openspec.yaml b/openspec/changes/redesign-netclaw-chat-tui/.openspec.yaml new file mode 100644 index 000000000..a8821c74d --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/redesign-netclaw-chat-tui/design.md b/openspec/changes/redesign-netclaw-chat-tui/design.md new file mode 100644 index 000000000..51f5afba4 --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/design.md @@ -0,0 +1,393 @@ +## Context + +Netclaw chat uses one mutable text node inside a full-screen Termina application. +This model hides typed events and makes parallel activity difficult to correlate. +The alternate terminal buffer also removes native scrollback, search, and selection. + +Netclaw and Termina are public .NET libraries with released API contracts. +This change must extend those contracts without changes to current signatures or defaults. +Termina full-screen mode must remain the default for all current applications. + +The session actor publishes a typed `SessionOutput` union through filtered subscriptions. +SignalR converts that union to the flat `SessionOutputDto` wire contract. +The TUI currently converts these events to text before it presents them. + +The session journal already stores tool batches, tool results, approvals, and completed turns. +`SerializableChatMessage` retains tool call IDs, tool names, arguments, results, and message roles. +The current resume DTO only exposes role and text content through `RecentMessages`. + +This design affects the Netclaw CLI, the daemon wire contract, session persistence, and Termina. +It also affects users who depend on terminal scrollback, keyboard input, approvals, and copy behavior. + +## Goals / Non-Goals + +**Goals:** + +- Give chat a clear visual grammar with named semantic regions. +- Use the primary terminal buffer for an explicit Netclaw chat mode. +- Preserve complete structured output and stable correlation identities. +- Keep settled transcript content immutable and easy to select. +- Preserve approval context and the current `Ctrl+O` detail control. +- Use `Shift+Enter` for a newline and `Enter` for prompt submission. +- Add safe prompt history cancellation and semantic copy. +- Restore structured settled history after a session resume. +- Preserve released API, wire, persistence, and full-screen behavior. +- Prove the terminal contract with deterministic and native tests. + +**Non-Goals:** + +- Replace Termina full-screen mode. +- Change the default mode for a current Termina application. +- Provide terminal-native search or scrollback inside full-screen mode. +- Persist transient tool progress or raw thought text in model context. +- Support prompt type-ahead during an active turn in the first release. +- Make terminal-native selection omit arbitrary characters in a selected row. +- Change the daemon execution engine or its actor ownership model. + +## Decisions + +### D1: Preserve all released contracts through additive changes + +The implementation will follow an extend-only rule for public and persisted contracts. + +- It will not remove or rename a public type or member. +- It will not change a public member signature. +- It will not add an optional parameter to a current method. +- It will not add a required member to a current public interface. +- It will preserve current enum names, values, and numeric assignments. +- It will append new enum values with explicit numeric assignments. +- It will add new DTO fields as nullable properties. +- It will reserve new Protobuf tags and preserve all current tags. +- It will keep old read paths before it enables new write paths. +- It will keep `RecentMessages` until a separate removal policy permits removal. + +Termina will add new types and services for new behavior. +Netclaw will add new output records and DTO fields for new event data. +Compatibility tests will approve the public API and serialized fixtures. + +An alternative would change current interfaces and constructors. +That choice would reduce new type count, but it would break external implementations and compiled applications. + +### D2: Select one presentation mode for each application instance + +Termina will add `TerminalPresentationMode` with these values: + +- `FullScreen = 0` +- `Inline = 1` + +`TerminaRuntimeOptions.PresentationMode` will default to `FullScreen`. +The property will not use `required`. +The current `AnsiTerminal(bool)` constructor will remain unchanged. + +Termina will append `NativeTerminal = 2` to `ScrollInputMode`. +Netclaw chat will select `Inline` and `NativeTerminal` explicitly. +The init wizard and the session picker will retain `FullScreen`. + +The session picker will exit its Termina application before it starts chat. +Chat will start as a new inline application after a successful selection. +This boundary prevents one terminal host from changing modes during one application lifetime. + +Per-route presentation modes were considered. +They would make terminal ownership and exit recovery dependent on navigation state. + +### D3: Keep the full-screen render path and add an inline coordinator + +The current `DiffingTerminal` path will remain the full-screen implementation. +The inline path will use a new internal coordinator on the primary buffer. +The coordinator will own one bounded live region below the settled transcript. + +Termina will add an interface such as `IInlineTerminalControl`. +The interface will contain the relative cursor and erase operations that inline mode needs. +`AnsiTerminal` and `VirtualTerminal` will implement the new interface. +The change will not add members to `IAnsiTerminal`. + +Termina dependency injection will construct `AnsiTerminal(false)` for application ownership. +`TerminaApplication` will enter the alternate buffer only for `FullScreen`. +Direct users of `AnsiTerminal(bool)` will retain the current constructor behavior. + +The inline coordinator will track the live region row count and terminal width. +It will erase only rows that it owns. +It will calculate the next live layout before it changes terminal output. + +The coordinator will use this commit sequence: + +1. Erase the tracked live region. +2. Write the new stable block to the primary buffer. +3. End the stable block with a normal line break. +4. Draw the current live region again. +5. Record the new live row count and width. + +The prototype must prove resize behavior before this design ships. +An unsupported terminal or an invalid mode will cause a visible startup error. +The runtime will not fall back to full-screen mode without an explicit user choice. + +A complete primary-buffer diff engine was considered. +That design would recreate terminal scrollback and would risk changes to stable rows. + +### D4: Give one service ownership of inline output + +Termina will add an additive service such as `IInlineOutput`. +The service will commit stable `ILayoutNode` blocks through the inline coordinator. +Each asynchronous method will require a `CancellationToken` parameter. + +Netclaw will route all chat output through this service or the live root update path. +Background diagnostics will enter the same ordered output queue. +Direct `Console.Out` writes during inline chat will be a contract violation. + +The queue will preserve event order from each producer. +Stable correlation IDs will resolve order differences between parallel producers. +An output failure will stop chat and show a visible terminal recovery error. + +A global console redirection was considered. +It cannot retain semantic event data and can hide output ownership defects. + +### D5: Use a pure Netclaw presentation reducer + +`ChatPage` will not append formatted text directly to one mutable transcript node. +A pure reducer will map each `SessionOutput` to immutable presentation state and effects. + +The state will use these stable keys: + +- `ToolCallId` for each tool activity. +- `RunId` for each sub-agent run. +- The parent `ToolCallId` for each sub-agent group. +- A turn identity for assistant text, thought state, and usage. +- The request `ToolCallId` for each approval. + +The reducer will produce these results: + +- A stable block that the coordinator must commit. +- A live-region snapshot. +- An input or approval mode transition. +- A diagnostic for an unsupported or invalid event. + +The event lifecycle will remain separate from its display state. +For example, a tool can run while its detail stays collapsed. +An expand action will not change the tool phase. + +The named regions will be `Session Header`, `Transcript`, `Activity Rail`, `Decision Gate`, `Composer`, and `Status Line`. +The reducer will apply the responsive rules at 40, 60, 80, and 120 columns. + +A set of event-specific UI mutations was considered. +That model would repeat state rules and would make event-order tests difficult. + +### D6: Extend session output with correlated activity records + +Netclaw will add `ToolActivityOutput` as a new `SessionOutput` subtype. +It will include `CallId`, the turn identity, a safe phase, and a safe summary. +The session pipeline will publish current tool progress instead of discarding it. + +`SubAgentOutput` will gain nullable or defaulted additive properties for `RunId` and parent `CallId`. +New producers will populate both fields. +Old producers and old DTO payloads will remain readable. + +`SessionOutputTypes` will add a new discriminator for tool activity. +`SessionOutputDto` will add nullable fields for each new value. +The mapper will define both directions for every supported output type. + +The current `OutputFilter.ToolCalls` flag will cover the new activity record. +This choice preserves current filter bit values and subscriber policy. +The TUI will request the complete applicable filter set. +Slack and other channels will retain their current filters. + +Tool progress and raw thought deltas will remain transient. +They will not enter model context or the actor journal. + +A new filter flag was considered. +It would change subscriber configuration without a separate security or volume requirement. + +### D7: Preserve structured resume data through an additive timeline + +`SessionJoined.RecentMessages` and `SessionOutputDto.RecentMessages` will remain unchanged. +The contracts will add a nullable `RecentTranscript` collection. +Each item will use a new framework-owned domain DTO with a stable discriminator. + +The timeline will contain settled user, assistant, tool, sub-agent, file, error, usage, and compaction entries. +It will not contain active states or transient progress. +It will not use TUI node types or style values. + +The session state will keep a bounded settled timeline for the configured recent turn window. +`TurnRecorded` will gain an additive transcript collection with new Protobuf tags. +Current tool and approval journal events will retain their schemas. +The snapshot will gain the same domain timeline with new tags. + +New code will read an absent timeline as a legacy record. +It will derive supported entries from `SerializableChatMessage` data. +Unsupported legacy detail will produce an explicit diagnostic entry. +It will not invent an active state. + +During the migration, the daemon will emit both `RecentMessages` and `RecentTranscript`. +A new client will prefer `RecentTranscript` when it is present. +An old client will ignore the new JSON property and use `RecentMessages`. + +The implementation will add read support and fixture tests before new writes start. +It will verify old journal data, old snapshots, and old SignalR payloads. + +Replacing `RecentMessages` was considered. +That choice would break current clients and would remove the only legacy resume path. + +### D8: Configure input through additive Termina behavior + +Netclaw will configure the current text area with `WithNewlineModifier(ConsoleModifiers.Shift)`. +Bare `Enter` will submit the prompt. +The native input path must preserve `Shift+Enter` through Kitty keyboard input or raw input. + +Termina will add a prompt-history cancellation API if the current component cannot restore drafts. +The new API will not change a current text-area method signature. +Netclaw will use it for Up and Down history navigation. + +Netclaw will implement double Escape with an injected `TimeProvider`. +The first Escape will preserve the current text. +A second Escape inside the defined interval will clear recalled or current text. + +A pending approval owns Escape before the composer does. +One Escape will deny the approval according to the current approval contract. +Paste input will route only to a composer that accepts paste. + +If the terminal cannot distinguish `Shift+Enter`, chat will report the unavailable shortcut. +It will not select another shortcut without an explicit configuration. + +### D9: Keep approval state inside the Decision Gate + +The compact Decision Gate will show the target, effect, scope, and selected decision. +`Ctrl+O` will switch between compact and expanded detail. +The switch will preserve the selected decision and scroll position where possible. + +The expanded form will use a bounded detail view. +Page Up and Page Down will move through long detail. +The renderer will show control characters as safe visible text. +It will never write approval content as terminal control bytes. + +An approval response will use the current actor command and security checks. +The TUI will not create a separate approval policy. + +### D10: Separate display text from semantic copy text + +Settled transcript blocks will avoid decorative side borders and corner characters. +This choice improves native terminal selection for ordinary transcript content. + +Termina will add an additive semantic copy contract for components that have hidden detail. +The contract will expose plain text without ANSI control bytes or border glyphs. +Netclaw will use it for complete tool results, approval detail, and diagnostics. + +Terminal-native selection cannot make selected cell characters unselectable. +The design will not claim that border characters can become unhighlightable. +The borderless transcript and semantic copy path will reduce the practical defect. + +A custom terminal selection system was considered. +It would duplicate emulator behavior and would not work consistently through tmux or remote shells. + +### D11: Use an explicit inspector for complete event detail + +The transcript will show concise settled rows. +The inspector will show complete semantic data for the selected event. +The first implementation may use a temporary full-screen Termina application. + +The inspector will close before inline chat resumes output. +The inline coordinator will commit queued stable blocks after the inspector exits. +The inspector will use the same redaction and control-character policy as semantic copy. + +This choice keeps the inline transcript quiet without data loss. +It also avoids a large bordered panel in the primary scrollback. + +### D12: Prove both compatibility and terminal behavior + +Termina tests will approve the public API surface for the released baseline and the new surface. +They will verify the numeric values of all changed enums. +They will verify that `FullScreen` remains the default. + +`VirtualTerminal` tests will cover commits, parallel arrivals, resize, cursor recovery, and failures. +The full-screen test suite will prove no visible behavior change for current applications. + +Netclaw headless tests will cover every `SessionOutput` disposition. +They will cover parallel tools that finish out of order. +They will cover structured resume and old payload conversion. + +Typed-key tests will cover `Shift+Enter`, history draft restoration, double Escape, and approval priority. +They will use `TimeProvider` and will not use time delays. + +The native smoke harness will cover the inline chat path. +The validation matrix will include Linux, macOS, Windows Terminal, and tmux. +The matrix will cover mouse wheel scroll, resize, selection, paste, `Ctrl+O`, cancellation, and process exit. + +## Actor Boundaries and Persistence + +The session actor will remain the owner of session lifecycle and durable state. +The tool pipeline will publish activity to the session actor through current actor messages. +Subscribers will receive activity through the current filtered pub/sub boundary. + +The SignalR actor will remain a transport adapter. +It will map fields without UI policy or lifecycle inference. +The CLI reducer will own presentation state and responsive style. +Termina will own terminal buffers, cursor state, and output order. + +The journal will store only settled framework-owned transcript data. +It will not store live UI state, expanded state, cursor state, or transient progress. +The actor will rebuild a bounded transcript from journal events and snapshots. + +## Failure Modes and Recovery + +- An inline startup failure will restore terminal modes and return a nonzero result. +- A coordinator write failure will stop new commits and restore the cursor when possible. +- A direct console write will produce a visible ownership diagnostic in development and tests. +- An unknown output discriminator will produce a diagnostic event without false lifecycle state. +- A missing correlation ID from an old payload will use a marked legacy row. +- An invalid approval payload will block the decision and show an error. +- A clipboard failure will retain the selected data and show a visible error. +- A client disconnect will retain durable actor state and discard only transient UI state. +- A process failure will rely on the current session recovery path and the settled timeline. +- A resize proof failure will block inline mode release for that terminal class. + +## Risks / Trade-offs + +- [Primary-buffer reflow can invalidate tracked rows] -> The prototype will test resize and wide-character cases before package release. +- [User scroll can conflict with new live output] -> The coordinator will limit changes to its bottom live region and test terminal behavior. +- [A third-party console writer can corrupt the live region] -> Netclaw will route output through one service and detect known direct writes. +- [Keyboard protocols differ across terminals] -> Native tests will define the supported matrix and visible failure behavior. +- [A structured timeline increases persisted data] -> The actor will bound it to the recent turn policy. +- [New detail can expose sensitive values] -> Existing output filters, redaction, and approval controls will remain in force. +- [Old clients ignore new activity] -> The daemon will keep current fields and discriminators while it adds new data. +- [An enum addition can expose incomplete switches] -> Tests and analyzers will find all Netclaw and Termina switches before release. +- [A temporary inspector pauses inline updates] -> The coordinator will queue events and commit them after inspector exit. +- [Borderless rows reduce grouping cues] -> Indentation, symbols, spacing, and color will carry the visual hierarchy. + +## Migration Plan + +1. Update the source PRD, engineering specification, and TUI mockups. +2. Add Termina API approval tests for the released baseline. +3. Add the new Termina enums, options, interfaces, and full-screen regression tests. +4. Build the inline coordinator against `VirtualTerminal`. +5. Run the native terminal prototype matrix. +6. File or update the approved Netclaw and Termina issues with prototype evidence. +7. Publish a dotted SemVer Termina prerelease after all Termina gates pass. +8. Update Netclaw through the package management workflow. +9. Add the Netclaw presentation reducer and named regions. +10. Add new session output records and complete SignalR mappings. +11. Add structured timeline read support and legacy fixtures. +12. Enable structured timeline writes after the read tests pass. +13. Add typed-key, headless, responsive, and native smoke proof. +14. Run Slopwatch, file-header verification, and the required smoke suite. +15. Verify the OpenSpec change before archive. + +### Rollback + +Full-screen mode will remain the Termina default throughout the migration. +A Netclaw rollback can select the prior package and restore its explicit full-screen chat configuration. +A Termina package rollback can remove the prerelease reference without a data conversion. + +The daemon will continue to emit `RecentMessages` during the migration. +New persisted fields will use additive tags, so older readers can ignore them. +The team will disable new writes before a rollback if an old reader cannot retain unknown fields. + +The runtime will not perform a silent mode fallback. +An operator must select a different mode or package version explicitly. + +## Open Questions + +- Which cursor sequence set remains reliable after primary-buffer resize on each supported terminal? +- Should the first release hide the composer for every active turn? +- Should the inspector always use a temporary alternate buffer? +- Which settled thought summary can the product policy retain? +- Which terminal versions will define the supported native matrix? +- What exact byte and entry limits will bound `RecentTranscript`? +- Can the current journal serializer retain unknown fields across every supported rollback path? diff --git a/openspec/changes/redesign-netclaw-chat-tui/proposal.md b/openspec/changes/redesign-netclaw-chat-tui/proposal.md new file mode 100644 index 000000000..2081265dd --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/proposal.md @@ -0,0 +1,98 @@ +Source PRDs: `PRD-001`, `PRD-004`, `PRD-009` + +## Why + +The current chat flattens typed session events into one mutable text stream. +This model hides useful events and can show false tool state during parallel work. + +The full-screen terminal model also replaces native scrollback, search, and +selection with incomplete application behavior. Netclaw needs a structured, +developer-focused chat before more event types and parallel activity increase +the current defects. + +## What Changes + +### In Scope + +- Add a structured chat presentation model with stable tool-call and sub-agent + identities. +- Show thought, parallel tool, sub-agent, approval, file, error, usage, and + compaction events with distinct settled forms. +- Add a borderless visual grammar with named regions and responsive forms. +- Prototype an opt-in Termina inline mode that uses the primary terminal buffer. +- Keep Termina full-screen mode as the default for existing applications. +- Preserve compact and expanded approval states with `Ctrl+O`. +- Use `Shift+Enter` for a newline and bare `Enter` for prompt submission. +- Add prompt draft restoration and double-Escape prompt clearance. +- Preserve complete event detail through an inspector and semantic copy path. +- Preserve structured event chronology after session resume. +- Add deterministic headless tests and a native chat smoke tape. +- Update `PRD-004` and the old TUI wireframe before implementation begins. +- Reuse Netclaw issues `#577` and `#1338` for their original defects. +- Reuse Termina issues `#45` and `#240` where their scopes match this work. + +### Out of Scope + +- A web or graphical chat client. +- A replacement for terminal-native scrollback or search in inline mode. +- A new daemon-side execution engine. +- Persistence of ephemeral tool progress in model context or the actor journal. +- Background prompt type-ahead during an active turn in the first prototype. +- A change to the default presentation mode for existing Termina applications. + +## Capabilities + +### New Capabilities + +- `netclaw-chat-tui`: Defines the inline screen model, named regions, event + forms, input modes, approval detail, copy behavior, and responsive grammar. + +### Modified Capabilities + +- `netclaw-cli`: Changes the `netclaw chat` presentation contract and the + transition from the full-screen session picker to inline chat. +- `netclaw-session`: Adds correlated live activity fields and complete output + parity for subscribers. +- `netclaw-subagents`: Adds stable run and parent-call correlation to sub-agent + activity output. +- `session-resume`: Restores structured settled events instead of role and text + content alone. +- `netclaw-testing`: Requires event-contract coverage and native terminal proof + for the chat surface. + +## Impact + +### Netclaw + +- `ChatPage` and `ChatViewModel` gain a structured presentation boundary. +- Session output records and SignalR DTOs gain additive correlation and detail + fields. +- Session resume gains a structured history representation. +- Native smoke gains an interactive chat tape. +- `PRD-004`, engineering specifications, and TUI wireframes change. + +### Termina + +- `TerminaRuntimeOptions` gains an explicit presentation mode. +- The runtime gains an opt-in primary-buffer host and terminal-owned scroll + policy. +- The component library gains keyed live blocks and reliable prompt-history + behavior. +- Existing full-screen applications retain their current default behavior. +- Netclaw can consume a prerelease package until a stable Termina release ships. + +### Security + +- Approval detail must render control characters as safe visible text. +- Semantic copy must not emit terminal control bytes. +- A compact approval must keep the target, effect, and scope visible. +- Clipboard and terminal-mode failures must produce visible errors. +- New output fields must not bypass existing audience filters or redaction. + +### Operations + +- The prototype must cover Linux, macOS, Windows Terminal, and tmux. +- Inline mode must restore cursor, input, paste, and terminal modes after every + normal, canceled, and failed exit. +- The application must reject direct concurrent console output that can corrupt + the owned live region. diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-chat-tui/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-chat-tui/spec.md new file mode 100644 index 000000000..5737ff29b --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-chat-tui/spec.md @@ -0,0 +1,245 @@ +## ADDED Requirements + +### Requirement: Inline chat uses the primary terminal buffer + +`netclaw chat` SHALL use the Termina inline presentation mode after the native +prototype passes the supported-terminal matrix. The primary terminal buffer +SHALL own settled transcript scrollback, native selection, and terminal search. +Termina SHALL own only a bounded live region and the active input surface. + +The client SHALL fail with a visible diagnostic when inline mode cannot start. +It SHALL NOT silently fall back to the full-screen mode. + +#### Scenario: Chat starts in the primary buffer + +- **WHEN** an operator starts `netclaw chat` in a supported terminal +- **THEN** Termina does not enter the alternate screen +- **AND** settled output remains in native terminal scrollback + +#### Scenario: Inline mode cannot start + +- **GIVEN** the terminal cannot satisfy the inline-mode contract +- **WHEN** the operator starts `netclaw chat` +- **THEN** the client reports the unsupported terminal state +- **AND** the client does not start a full-screen chat as a fallback + +### Requirement: Chat has named semantic regions + +The chat SHALL use these named regions: Session Header, Transcript, Turn, Live +Deck, Activity Group, Event Row, Decision Gate, Composer, Hint Line, and +Inspector. The Transcript SHALL remain borderless. The Composer MAY use one +small border to identify editable text. + +The Session Header and Hint Line SHALL be printed contextual content. They +SHALL NOT require fixed full-screen coordinates. + +#### Scenario: Idle chat shows the primary regions + +- **WHEN** a new chat becomes ready for input +- **THEN** the Session Header identifies the session and model +- **AND** the settled Transcript has no outer border +- **AND** the Composer shows the current input contract + +#### Scenario: Active turn replaces the Composer + +- **WHEN** a submitted turn remains active +- **THEN** the Live Deck replaces the Composer +- **AND** the Hint Line shows only active-turn actions +- **AND** the first prototype accepts no prompt type-ahead + +### Requirement: Settled transcript content is immutable + +Netclaw SHALL print each settled event once in chronological order. A settled +event SHALL leave the Live Deck and SHALL NOT receive later screen updates. +Each volatile event SHALL use a stable identity before settlement. + +#### Scenario: Tool result settles one row + +- **GIVEN** a tool row in the Live Deck has a stable `CallId` +- **WHEN** its terminal result arrives +- **THEN** Netclaw prints one settled block for that `CallId` +- **AND** Netclaw removes only that row from the Live Deck +- **AND** no later result can replace that settled block + +#### Scenario: Parallel results arrive out of order + +- **GIVEN** calls A, B, and C are active in one Activity Group +- **WHEN** their results arrive in the order B, C, and A +- **THEN** each result updates only its matching `CallId` +- **AND** all three settled records remain visible + +### Requirement: Event lifecycle and display state are independent + +Each Event Row SHALL have one lifecycle state and one display state. Lifecycle +states SHALL include Queued, Active, Succeeded, Failed, Denied, and Canceled. +Display states SHALL include Summary, Expanded, Selected, and Hidden. + +A display action SHALL NOT change execution state or approval state. + +#### Scenario: Expand an active event + +- **GIVEN** an active Event Row has more detail +- **WHEN** the operator expands that row +- **THEN** its display state becomes Expanded +- **AND** its lifecycle state remains Active + +### Requirement: Chat presents complete structured session output + +The chat SHALL define distinct forms for user text, assistant text, thought, +tool call, tool result, sub-agent activity, approval, file, error, usage, +compaction, title, processing state, and turn outcome events. + +Each form SHALL preserve every security-safe field that affects operator +understanding. The compact form MAY summarize a long value only when the +Inspector provides the complete value. + +#### Scenario: Error output retains diagnostic identity + +- **WHEN** the client receives an error with category, correlation ID, message, + and detail +- **THEN** the compact row shows the category, message, and short correlation ID +- **AND** the Inspector provides the complete detail + +#### Scenario: Usage output retains provider detail + +- **WHEN** the provider supplies input, output, cached, and reasoning tokens +- **THEN** the turn usage form retains all supplied token classes +- **AND** narrow layouts remove low-priority display fields without changing + the underlying event data + +### Requirement: Thought activity gives immediate visible feedback + +The first thought delta SHALL create an active Thought Row. Later deltas SHALL +update the same row. The settled form SHALL show duration and reasoning tokens +when available. Thought content SHALL follow provider and policy disclosure +rules. + +#### Scenario: Thought precedes assistant text + +- **WHEN** a model emits thought deltas before assistant text +- **THEN** the chat shows an active Thought Row after the first delta +- **AND** the row remains distinct from assistant content + +#### Scenario: Thought disclosure is forbidden + +- **GIVEN** provider or policy rules forbid thought-content disclosure +- **WHEN** the model reasons +- **THEN** the chat shows an active state without the hidden thought content +- **AND** no semantic copy path exposes that content + +### Requirement: Decision Gate preserves approval context + +A pending approval SHALL replace the Composer with a Decision Gate. The compact +state SHALL show the tool, target, effect, scope, and decision choices. `Ctrl+O` +SHALL toggle the complete safe detail without changing the selected decision. + +One Escape press SHALL deny the request. Paste input SHALL NOT reach the hidden +Composer. Approval detail SHALL render terminal control characters as visible +safe text. + +#### Scenario: Approval detail expands without a decision + +- **GIVEN** Allow once is selected in a compact Decision Gate +- **WHEN** the operator presses `Ctrl+O` +- **THEN** the gate shows complete safe detail +- **AND** Allow once remains selected +- **AND** no approval response is sent + +#### Scenario: Escape denies a pending approval + +- **WHEN** the Decision Gate owns input and the operator presses Escape +- **THEN** Netclaw sends one denial response for that request +- **AND** the hidden Composer receives no Escape input + +#### Scenario: Long approval detail uses a bounded view + +- **GIVEN** expanded approval detail exceeds its maximum height +- **WHEN** the operator presses PageUp or PageDown +- **THEN** only the detail viewport moves +- **AND** the selected approval decision remains unchanged + +### Requirement: Composer uses developer chat input conventions + +Bare Enter SHALL submit the prompt. `Shift+Enter` SHALL add a newline. Up and +Down SHALL traverse prompt history at text boundaries. Down after the newest +history entry SHALL restore the saved draft. + +Two Escape presses inside a `TimeProvider`-based window SHALL clear the prompt. +One Escape press SHALL preserve it. Multiline paste SHALL submit the exact +original content after its compact display summary. + +#### Scenario: Shift Enter inserts a newline + +- **GIVEN** the Composer owns input +- **WHEN** the operator presses `Shift+Enter` +- **THEN** the Composer inserts one newline +- **AND** Netclaw does not submit the prompt + +#### Scenario: Down restores the draft + +- **GIVEN** the operator has a draft and recalls an older prompt +- **WHEN** the operator moves down past the newest history entry +- **THEN** the Composer restores the original draft exactly + +#### Scenario: Double Escape clears recalled text + +- **GIVEN** a recalled prompt is in the Composer +- **WHEN** the operator presses Escape twice within the configured window +- **THEN** the Composer clears all prompt text and history-recall state + +#### Scenario: Single Escape preserves text + +- **GIVEN** a nonempty Composer owns input +- **WHEN** the operator presses Escape once and the window expires +- **THEN** the prompt text remains unchanged + +### Requirement: Inspector and copy use semantic event data + +The Inspector SHALL show complete tool arguments, tool results, error detail, +file metadata, and allowed thought detail. It SHALL support copy for one event +and one complete Turn. + +Semantic copy SHALL exclude borders, rails, spinners, selection markers, hints, +ANSI sequences, and transient elapsed-time frames. A clipboard failure SHALL +produce a visible error. + +#### Scenario: Copy a complete tool result + +- **GIVEN** a compact tool row summarizes a long result +- **WHEN** the operator copies that event +- **THEN** the clipboard text contains the complete result +- **AND** the text contains no decorative screen characters + +#### Scenario: Clipboard transfer fails + +- **WHEN** every configured clipboard transport rejects the copy request +- **THEN** the chat reports a visible copy failure +- **AND** the chat does not report copy success + +### Requirement: Chat layout degrades by semantic priority + +The chat SHALL retain event identity, lifecycle state, approval choices, error +state, input text, and detail availability at every supported width. It SHALL +remove optional metadata before required state clips. + +The automated layout matrix SHALL cover 40, 60, 80, and 120 columns. + +#### Scenario: Render at 40 columns + +- **WHEN** the chat renders parallel tools and a sub-agent at 40 columns +- **THEN** every event remains a distinct block +- **AND** every lifecycle state remains visible +- **AND** optional metadata leaves before required labels clip + +### Requirement: Inline output has one owner + +All interactive chat output SHALL pass through the inline host while it owns a +live region. Direct concurrent writes that could corrupt that region SHALL fail +with a visible diagnostic or route through the host. + +#### Scenario: Background output reaches the chat process + +- **GIVEN** the inline host owns a live region +- **WHEN** a background component attempts a direct console write +- **THEN** the output routes through the inline host or fails visibly +- **AND** the live region and settled transcript remain valid diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-cli/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-cli/spec.md new file mode 100644 index 000000000..27ab89ab4 --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-cli/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: TUI applications select an explicit presentation mode + +Each Termina application SHALL select its presentation mode through runtime +configuration. `netclaw chat` SHALL select Inline. Existing setup, config, +picker, approval, and dashboard applications SHALL retain FullScreen unless a +separate approved change selects another mode. + +#### Scenario: Chat selects Inline + +- **WHEN** the CLI builds the `netclaw chat` Termina application +- **THEN** its runtime options select Inline presentation +- **AND** its scroll policy leaves wheel input and selection with the terminal + +#### Scenario: Init retains FullScreen + +- **WHEN** the CLI builds the `netclaw init` Termina application +- **THEN** its runtime options select FullScreen or use the FullScreen default +- **AND** the change to chat does not alter the init screen lifecycle + +### Requirement: Session selection crosses the presentation boundary cleanly + +The full-screen session picker SHALL close before Netclaw starts inline chat. +The picker SHALL pass the selected session ID through an explicit launch result. +The CLI SHALL NOT switch screen-buffer contracts during page navigation. + +#### Scenario: Select a session from the picker + +- **GIVEN** the full-screen session picker shows a stored session +- **WHEN** the operator confirms that session +- **THEN** the picker exits and restores the primary terminal buffer +- **AND** the CLI starts a new inline chat application with that session ID + +#### Scenario: Session launch fails + +- **GIVEN** the picker exits with a selected session ID +- **WHEN** the inline chat application cannot start +- **THEN** the CLI reports the launch failure in the primary buffer +- **AND** the CLI does not reopen the picker or start a fallback chat silently + +### Requirement: Chat reserves console output ownership + +The chat host SHALL suppress or reroute framework console logging while the +inline host owns a live region. Diagnostic logs SHALL remain available through +the configured file or structured log path. + +#### Scenario: Daemon client reports a warning + +- **GIVEN** inline chat owns a live region +- **WHEN** a daemon client component records a warning +- **THEN** the warning reaches the configured diagnostic log +- **AND** no direct console line corrupts the live region diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-session/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-session/spec.md new file mode 100644 index 000000000..87a41525f --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-session/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Tool activity output has stable call correlation + +The session output contract SHALL carry nonterminal tool activity with the +parent session ID, turn identity, and `CallId`. A terminal tool result SHALL use +the same `CallId`. Parallel calls SHALL never share mutable presentation state. + +Tool activity SHALL remain ephemeral. The actor SHALL NOT add it to model +context or persist it as conversation history. + +#### Scenario: Parallel tool activity remains correlated + +- **GIVEN** one model step starts calls A and B +- **WHEN** both calls emit interleaved activity +- **THEN** each activity output carries its original `CallId` +- **AND** each terminal result carries that same `CallId` + +#### Scenario: Ephemeral activity does not enter model context + +- **WHEN** a tool emits ten nonterminal activity updates and one terminal result +- **THEN** only the terminal result enters the model-facing tool message +- **AND** no nonterminal update enters persisted conversation history + +### Requirement: Session output transport preserves all supported fields + +Every field on a supported `SessionOutput` SHALL survive the in-process to +SignalR DTO boundary unless an explicit security rule removes it. The mapper +SHALL preserve usage detail, error identity, file metadata, compaction detail, +turn outcome, title, processing state, tool correlation, and sub-agent +correlation. + +A mapper SHALL fail a contract test when a new output field lacks a deliberate +wire disposition. + +#### Scenario: Compaction output crosses SignalR + +- **WHEN** the session emits compaction output with cleared-result and summary + counts +- **THEN** the SignalR client receives those same values + +#### Scenario: Error output crosses SignalR + +- **WHEN** the session emits an error with category, correlation ID, and detail +- **THEN** the SignalR client receives the same security-safe fields + +#### Scenario: New field lacks a wire disposition + +- **WHEN** a developer adds a field to a supported output record +- **THEN** the output parity contract test requires an explicit mapped or + security-omitted disposition + +### Requirement: Output filters apply to new activity events + +Tool and sub-agent activity outputs SHALL use `OutputFilter.ToolCalls`. +Thought activity SHALL use `OutputFilter.Thinking`. Lifecycle and approval +events SHALL retain their existing mandatory delivery rules. + +#### Scenario: Slack excludes tool activity + +- **GIVEN** a Slack subscriber excludes `ToolCalls` +- **WHEN** a tool emits nonterminal activity +- **THEN** the Slack subscriber receives no activity output + +#### Scenario: TUI receives full activity + +- **GIVEN** a TUI subscriber requests the full output filter +- **WHEN** thought, tool, and sub-agent activity occurs +- **THEN** the TUI receives each permitted activity category diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-subagents/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-subagents/spec.md new file mode 100644 index 000000000..4437fffa3 --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-subagents/spec.md @@ -0,0 +1,59 @@ +## MODIFIED Requirements + +### Requirement: Subagent observability events + +The system SHALL emit structured `SubAgentOutput` events to session subscribers +when a subagent starts, reports activity, and completes. These events SHALL be +filtered under the `OutputFilter.ToolCalls` category. + +Each event SHALL include a stable `RunId` and the parent tool `CallId`. Activity +events SHALL include a safe phase label and MAY include a safe summary, tool +count, or elapsed duration. Terminal events SHALL include outcome and duration. + +Sub-agent activity SHALL flow through the tool activity stream and session +output relay. It SHALL remain ephemeral and SHALL NOT enter model context or +persisted conversation history. + +#### Scenario: Subagent start event emitted + +- **GIVEN** a tool spawns a subagent within a session's tool execution pipeline +- **WHEN** the subagent begins execution +- **THEN** a `SubAgentOutput` event with `Phase = Started` is emitted +- **AND** the event includes `RunId`, parent `CallId`, agent name, and tool count +- **AND** the event is delivered to subscribers with `ToolCalls` in their filter + +#### Scenario: Subagent activity event emitted + +- **GIVEN** a subagent remains active +- **WHEN** its tool stream emits a safe progress update +- **THEN** a `SubAgentOutput` activity event uses the same `RunId` and parent + `CallId` +- **AND** the update does not enter model context or persisted history + +#### Scenario: Subagent completion event emitted + +- **GIVEN** a subagent completes with success, failure, or cancellation +- **WHEN** the result is received by the calling tool +- **THEN** a `SubAgentOutput` event with `Phase = Completed` is emitted +- **AND** the event uses the same `RunId` and parent `CallId` +- **AND** the event includes outcome and duration + +#### Scenario: Parallel same-name subagents remain distinct + +- **GIVEN** two active subagents have the same definition name +- **WHEN** their activity and terminal events interleave +- **THEN** each event remains correlated by its distinct `RunId` +- **AND** neither terminal event settles the sibling run + +#### Scenario: Headless CLI renders subagent events + +- **GIVEN** the headless CLI subscribes with `OutputFilter.Full` +- **WHEN** a subagent starts, reports activity, and completes +- **THEN** the CLI renders machine-distinct start, activity, and completion + records with the run identity + +#### Scenario: Slack adapter suppresses subagent events + +- **GIVEN** the Slack adapter excludes `ToolCalls` from its subscription +- **WHEN** a subagent starts, reports activity, and completes +- **THEN** no subagent-specific messages are posted to Slack diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md new file mode 100644 index 000000000..85583339e --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: Chat output contracts have deterministic headless proof + +Headless tests SHALL inject every supported `SessionOutput` type through the +chat presentation boundary. Tests SHALL verify stable identity, lifecycle, +settlement, complete detail, and responsive layout without a live provider. + +#### Scenario: Parallel tools complete out of order + +- **GIVEN** headless chat receives tool starts A, B, and C +- **WHEN** results arrive in the order B, C, and A +- **THEN** snapshots show three distinct matching results +- **AND** no result replaces an unrelated row + +#### Scenario: Every output type has a disposition + +- **WHEN** the output contract test enumerates the supported `SessionOutput` + union +- **THEN** every type maps to a visible, deliberately hidden, or security- + filtered presentation disposition +- **AND** an unclassified type fails the test + +#### Scenario: Responsive snapshot matrix + +- **WHEN** representative active and settled Turns render at 40, 60, 80, and + 120 columns +- **THEN** no unrelated events share one line +- **AND** required identity and lifecycle state remain visible + +### Requirement: Chat input contracts have typed-key proof + +Headless typed-key tests SHALL cover submit, `Shift+Enter`, prompt history, +draft restoration, double Escape, approval denial, `Ctrl+O`, detail scroll, and +multiline paste. Time-based key sequences SHALL use `TimeProvider`. + +#### Scenario: Shift Enter does not submit + +- **WHEN** the test enters text and sends `Shift+Enter` +- **THEN** the input contains one newline +- **AND** the submit observer receives no value + +#### Scenario: Approval detail preserves selection + +- **GIVEN** Allow once is selected +- **WHEN** the test sends `Ctrl+O`, PageDown, and `Ctrl+O` +- **THEN** the detail expands, moves, and collapses +- **AND** Allow once remains selected +- **AND** no approval response occurs before Enter + +#### Scenario: Double Escape uses virtual time + +- **GIVEN** a nonempty recalled prompt +- **WHEN** the test sends two Escape keys inside the configured virtual-time + window +- **THEN** the input clears without `Task.Delay` or `Thread.Sleep` + +### Requirement: Native chat smoke proves the terminal contract + +The native smoke harness SHALL run the real published CLI and SHALL prove chat +startup, typed input, multiline input, paste, approval detail, prompt recall, +double Escape, resize, interruption, and clean shutdown. + +The Termina dependency SHALL provide separate native proof for primary-buffer +scrollback, native selection, Linux, macOS, Windows Terminal, and tmux. + +#### Scenario: Native inline chat flow + +- **WHEN** the `netclaw chat` native tape runs against a deterministic daemon +- **THEN** the tape proves the primary chat flow through semantic anchors +- **AND** each nontrivial action has an assertion +- **AND** the tape uses no fixed sleep + +#### Scenario: Full-screen regression suite + +- **WHEN** Termina inline support enters the Netclaw dependency graph +- **THEN** the existing init, config, model, provider, and approval TUI smoke + flows retain their full-screen behavior +- **AND** `./scripts/smoke/run-smoke.sh light` passes diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/session-resume/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/session-resume/spec.md new file mode 100644 index 000000000..24869234a --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/session-resume/spec.md @@ -0,0 +1,78 @@ +## MODIFIED Requirements + +### Requirement: TUI session browser + +The system SHALL provide a Termina full-screen list of recent sessions from the +catalog. The operator SHALL be able to select a session for an inline chat +launch. + +#### Scenario: Open session browser + +- **WHEN** operator runs `netclaw sessions` +- **THEN** the full-screen TUI displays a list of recent sessions +- **AND** each entry shows title (or "Untitled"), channel type, turn count, and + relative last activity time + +#### Scenario: Select session to resume + +- **GIVEN** the session browser is displayed with entries +- **WHEN** the user selects a session and confirms +- **THEN** the session browser exits and restores the primary terminal buffer +- **AND** the CLI starts inline chat with the selected session ID +- **AND** the chat client attaches through `EnsureSession` + +#### Scenario: No sessions available + +- **GIVEN** the session catalog is empty +- **WHEN** the session browser loads +- **THEN** the TUI displays an empty state message +- **AND** offers to exit and start a new inline chat session + +## ADDED Requirements + +### Requirement: Session resume restores structured settled events + +The resume contract SHALL provide a chronological structured representation for +settled user, assistant, thought, tool, sub-agent, file, error, usage, +compaction, approval-outcome, and turn-outcome events that remain available. + +The representation SHALL preserve stable event identities and all +security-permitted detail required by the chat Inspector. It SHALL NOT restore +an old settled event as an active Live Deck row. + +#### Scenario: Resume a tool-rich session + +- **GIVEN** a stored session contains one user turn, two parallel tool calls, a + sub-agent run, one file, and an assistant response +- **WHEN** inline chat resumes that session +- **THEN** the client receives settled structured events in their original order +- **AND** the two tools retain distinct `CallId` values +- **AND** the sub-agent retains its `RunId` and parent `CallId` + +#### Scenario: Resume does not create false active state + +- **GIVEN** a prior session contains completed thought and tool records +- **WHEN** the client resumes the session +- **THEN** every recovered record uses a settled lifecycle state +- **AND** the Live Deck starts empty unless the daemon reports current live work + +### Requirement: Legacy resume data has an explicit conversion path + +The daemon SHALL convert supported legacy role-and-content history into the +canonical settled Turn representation. Invalid or unsupported legacy data SHALL +produce a visible resume error. The client SHALL NOT silently drop the invalid +record or create an empty transcript. + +#### Scenario: Resume supported legacy history + +- **GIVEN** a session uses the previous role-and-content history shape +- **WHEN** the client resumes that session +- **THEN** the daemon converts each supported message into a settled Turn event +- **AND** the client shows the recovered content in chronological order + +#### Scenario: Resume unsupported legacy history + +- **GIVEN** a legacy history record cannot convert without data ambiguity +- **WHEN** the client resumes that session +- **THEN** the daemon reports the record and conversion failure +- **AND** the client does not present an empty or partially silent transcript diff --git a/openspec/changes/redesign-netclaw-chat-tui/tasks.md b/openspec/changes/redesign-netclaw-chat-tui/tasks.md new file mode 100644 index 000000000..b18f12638 --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/tasks.md @@ -0,0 +1,138 @@ +## 1. Planning and Issue Traceability + +- [x] 1.1 Update `PRD-004` with the inline chat, structured event, input, copy, and approval requirements. +- [x] 1.2 Update `PRD-009` with the complete typed output and structured resume contract. +- [x] 1.3 Replace the old chat section in `TUI-001` with the approved named-region mockups and responsive rules. +- [x] 1.4 Update `SPEC-004` with the chat command, session picker boundary, and explicit presentation modes. +- [x] 1.5 Update `SPEC-002` and `SPEC-011` with output correlation, transport mapping, and structured resume behavior. +- [x] 1.6 Update `SPEC-010` with the headless, compatibility, and native terminal proof matrix. +- [x] 1.7 Update the current Netclaw GitHub issues `#577` and `#1338` with the approved scope and OpenSpec link. +- [x] 1.8 File the remaining Netclaw epic and child issues without duplicates, then add their links to this change. +- [x] 1.9 Update the current Termina GitHub issues `#45` and `#240` with the applicable scope and design link. +- [x] 1.10 File the remaining Termina epic and prototype issues without duplicates, then add their links to this change. + +## 2. Termina Extend-Only Compatibility Foundation + +- [x] 2.1 Add a public API approval baseline for the current Termina release. +- [x] 2.2 Add `TerminalPresentationMode` with stable explicit numeric values. +- [x] 2.3 Add `TerminaRuntimeOptions.PresentationMode` with `FullScreen` as the default. +- [x] 2.4 Append `NativeTerminal` to `ScrollInputMode` without a change to current values. +- [x] 2.5 Add `IInlineTerminalControl` without a change to `IAnsiTerminal`. +- [x] 2.6 Implement `IInlineTerminalControl` in `AnsiTerminal` and `VirtualTerminal`. +- [x] 2.7 Make `TerminaApplication` enter the alternate buffer only in `FullScreen` mode. +- [x] 2.8 Preserve direct `AnsiTerminal(bool)` behavior and make application dependency injection own buffer selection. +- [x] 2.9 Add full-screen regression tests for startup, render, resize, and exit behavior. +- [x] 2.10 Verify the approved API diff contains additive public changes only. + +## 3. Termina Inline Coordinator Prototype + +- [x] 3.1 Add an inline coordinator that owns a bounded live region in the primary buffer. +- [x] 3.2 Add the ordered erase, stable commit, and live redraw sequence. +- [x] 3.3 Add an additive `IInlineOutput` service for stable layout commits. +- [ ] 3.4 Route internal diagnostics through the inline output owner. +- [x] 3.5 Add `VirtualTerminal` tests for one stable commit and one live redraw. +- [x] 3.6 Add `VirtualTerminal` tests for parallel commits and deterministic output order. +- [x] 3.7 Add resize tests for narrower, wider, and wide-character live content. +- [x] 3.8 Add failure tests that verify cursor and terminal-mode recovery. +- [ ] 3.9 Run the prototype on Linux terminals and tmux, then record the exact evidence. +- [ ] 3.10 Run the prototype on macOS and Windows Terminal, then record the exact evidence. +- [ ] 3.11 Accept inline mode only if resize, scrollback, selection, paste, and exit recovery pass the matrix. + +## 4. Termina Input, Scroll, and Copy Primitives + +- [ ] 4.1 Add a text-history cancellation API that restores the saved draft. +- [ ] 4.2 Add typed-key tests for Up, Down, draft restoration, and history cancellation. +- [ ] 4.3 Verify `Shift+Enter` across legacy, Kitty, and native raw input paths. +- [ ] 4.4 Add a visible capability result when a terminal cannot distinguish `Shift+Enter`. +- [ ] 4.5 Add dimension-free scroll operations that use the measured viewport. +- [ ] 4.6 Preserve mouse coordinates on wheel input and test route selection. +- [ ] 4.7 Add semantic copy data that remains separate from display glyphs. +- [ ] 4.8 Add clipboard failure output that preserves the selected semantic data. +- [ ] 4.9 Add headless tests that exclude borders, control bytes, and truncated display text from copied data. + +## 5. Netclaw Session Output Contract + +- [ ] 5.1 Add `ToolActivityOutput` with `CallId`, turn identity, safe phase, and safe summary. +- [ ] 5.2 Relay current nonterminal tool activity through the session actor output boundary. +- [ ] 5.3 Add additive `RunId` and parent `CallId` fields to `SubAgentOutput`. +- [ ] 5.4 Populate stable sub-agent identities for start, activity, and completion events. +- [ ] 5.5 Add nullable wire fields and a discriminator for every new output value. +- [ ] 5.6 Map every current compaction, error, usage, file, turn, tool, and sub-agent field in both directions. +- [ ] 5.7 Apply `OutputFilter.ToolCalls` to tool activity and sub-agent activity. +- [ ] 5.8 Prove that Slack and other restricted subscribers do not receive the new activity. +- [ ] 5.9 Prove that transient activity does not enter model context or the actor journal. +- [ ] 5.10 Add DTO round-trip and old-payload fixtures for all additive fields. + +## 6. Structured Session Resume + +- [ ] 6.1 Add a framework-owned settled transcript entry union with stable discriminators. +- [ ] 6.2 Add nullable `RecentTranscript` properties without a change to `RecentMessages`. +- [ ] 6.3 Add a bounded settled timeline to session state and snapshots with new serialization tags. +- [ ] 6.4 Add settled transcript entries to `TurnRecorded` with new serialization tags. +- [ ] 6.5 Build settled entries from user, assistant, tool, sub-agent, file, error, usage, and compaction events. +- [ ] 6.6 Add read support for old journals and snapshots before new timeline writes start. +- [ ] 6.7 Convert supported `SerializableChatMessage` history to explicit legacy transcript entries. +- [ ] 6.8 Emit a diagnostic entry for unsupported legacy detail without a false active state. +- [ ] 6.9 Emit both `RecentMessages` and `RecentTranscript` during the compatibility period. +- [ ] 6.10 Add journal, snapshot, SignalR, and client resume fixtures across old and new shapes. + +## 7. Netclaw Presentation Reducer and Visual Grammar + +- [ ] 7.1 Add immutable chat presentation state with keys for turns, tool calls, sub-agents, thoughts, and approvals. +- [ ] 7.2 Add a pure reducer that maps every `SessionOutput` to state and explicit effects. +- [ ] 7.3 Add parallel tool tests where results finish in a different order than calls. +- [ ] 7.4 Add parallel same-name sub-agent tests that prove stable row identity. +- [ ] 7.5 Add the `Session Header`, `Transcript`, `Activity Rail`, `Decision Gate`, `Composer`, and `Status Line` regions. +- [ ] 7.6 Add borderless settled user, assistant, tool, thought, sub-agent, file, error, usage, and compaction forms. +- [ ] 7.7 Add concise live forms and immutable settled forms for each event lifecycle. +- [ ] 7.8 Add responsive layout rules and snapshots at 40, 60, 80, and 120 columns. +- [ ] 7.9 Add tail-follow state, a new-event count, and an explicit return-to-tail action. +- [ ] 7.10 Replace fixed scroll dimensions with the actual measured viewport. +- [ ] 7.11 Route all chat output through the inline output owner. +- [ ] 7.12 Add a visible diagnostic for an unsupported output type or invalid lifecycle transition. + +## 8. Composer, Approval, Inspector, and Copy Behavior + +- [ ] 8.1 Configure `Shift+Enter` for a newline and bare `Enter` for submission. +- [ ] 8.2 Restore the saved draft after prompt history reaches its newest entry. +- [ ] 8.3 Add double Escape with an injected `TimeProvider` and a defined interval. +- [ ] 8.4 Give a pending approval priority over composer Escape behavior. +- [ ] 8.5 Block paste delivery to a hidden composer while an approval owns focus. +- [ ] 8.6 Preserve compact and expanded approval forms with `Ctrl+O`. +- [ ] 8.7 Preserve the approval selection and bounded detail position across `Ctrl+O` changes. +- [ ] 8.8 Render approval control characters as visible safe text. +- [ ] 8.9 Add an inspector that shows complete event detail without transcript truncation. +- [ ] 8.10 Queue inline output while the inspector owns the terminal and commit it after exit. +- [ ] 8.11 Add semantic copy for an event and a complete turn. +- [ ] 8.12 Add visible copy errors and keep the selected data after a failure. + +## 9. Netclaw Command Integration + +- [ ] 9.1 Configure `netclaw chat` for explicit `Inline` and `NativeTerminal` modes. +- [ ] 9.2 Keep init, config, provider, model, and session picker applications in `FullScreen` mode. +- [ ] 9.3 Exit the session picker before a selected inline chat application starts. +- [ ] 9.4 Show a visible error when the selected chat application cannot start. +- [ ] 9.5 Restore cursor, input, mouse, paste, and terminal modes on normal, canceled, and failed exits. +- [ ] 9.6 Add command tests that prove each application selects its required presentation mode. + +## 10. Package and Cross-Repository Integration + +- [ ] 10.1 Run all Termina unit, compatibility, and native prototype gates. +- [ ] 10.2 Select a dotted SemVer prerelease that follows the Termina release process. +- [ ] 10.3 Publish the Termina prerelease package and record its package and commit links. +- [ ] 10.4 Update Netclaw to the prerelease with the repository package workflow. +- [ ] 10.5 Restore and build Netclaw against the published package, not a local binary. +- [ ] 10.6 Record explicit rollback steps for the package and the Netclaw presentation choice. + +## 11. Verification and Completion + +- [ ] 11.1 Add headless tests that inject every `SessionOutput` subtype. +- [ ] 11.2 Add typed-key tests for prompt, paste, history, Escape, approval, inspector, and copy flows. +- [ ] 11.3 Add a native chat smoke tape with typed input, paste, wheel input, resize, approval, and exit recovery. +- [ ] 11.4 Run `./scripts/smoke/run-smoke.sh light` and retain the result. +- [ ] 11.5 Run the focused Netclaw actor, protocol, CLI, and TUI test suites. +- [ ] 11.6 Run `dotnet slopwatch analyze` in each repository that contains code changes. +- [ ] 11.7 Run `./scripts/Add-FileHeaders.ps1 -Verify` for Netclaw C# changes. +- [ ] 11.8 Verify each issue acceptance criterion against tests or native evidence. +- [ ] 11.9 Run OpenSpec verification and resolve every mismatch. +- [ ] 11.10 Sync the approved delta specifications and archive the completed change. From 23b76a0b5e3b51c24078b7f9c3ffa2a347d9fe19 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 17:53:07 -0500 Subject: [PATCH 02/39] Implement the chat TUI redesign --- Directory.Packages.props | 4 +- .../.system/files/netclaw-operations/SKILL.md | 1 + .../netclaw-operations/references/chat.md | 26 + .../redesign-netclaw-chat-tui/tasks.md | 120 +-- scripts/smoke/run-smoke.sh | 2 +- .../Protocol/SerializationRoundTripTests.cs | 114 +++ .../Sessions/ErrorCorrelationTests.cs | 20 + .../SessionToolPipelineTestFixture.cs | 8 + ...ssionInputCompatibilityIntegrationTests.cs | 4 + .../Sessions/SessionStateTests.cs | 114 +++ .../Sessions/SessionSubscriberManagerTests.cs | 35 + .../SessionToolExecutionPipelineTests.cs | 66 +- .../SessionTranscriptEntryFactoryTests.cs | 137 +++ .../Sessions/SubAgentSpawnIntegrationTests.cs | 93 ++- .../Sessions/ToolBatchHistoryWedgeTests.cs | 18 +- .../Protocol/SessionOutputDto.cs | 13 + .../Protocol/SessionOutputDtoMapper.cs | 68 +- .../Protocol/SessionSnapshot.cs | 3 + .../Protocol/SessionSubscription.cs | 10 +- .../Protocol/SessionTranscriptEntry.cs | 110 +++ .../Serialization/NetclawProtoMapper.cs | 151 +++- .../Protos/netclaw_messages.proto | 45 + src/Netclaw.Actors/Sessions/LlmMessages.cs | 2 + .../Sessions/LlmSessionActor.cs | 287 ++++++- .../Pipelines/SessionToolExecutionPipeline.cs | 96 ++- .../Sessions/SessionLogActor.cs | 9 +- .../Sessions/SessionProtocol.Events.cs | 6 + .../Sessions/SessionProtocol.Outputs.cs | 29 + src/Netclaw.Actors/Sessions/SessionState.cs | 51 +- .../Sessions/SessionTranscriptEntryFactory.cs | 93 +++ .../Sessions/SessionTranscriptExtractor.cs | 118 +++ .../SubAgents/SubAgentNotification.cs | 5 +- .../SubAgents/SubAgentSpawner.cs | 37 +- .../Cli/DaemonClientMappingTests.cs | 259 +++++- .../Tui/ChatPresentationReducerTests.cs | 217 +++++ .../Tui/InlineChatPageTests.cs | 755 +++++++++++++++++ .../Tui/SessionsPageTests.cs | 29 +- .../Tui/TerminalRuntimeProfilesTests.cs | 45 + src/Netclaw.Cli/Program.cs | 251 +++--- src/Netclaw.Cli/Tui/ChatNavigationState.cs | 14 + src/Netclaw.Cli/Tui/ChatPresentation.cs | 630 ++++++++++++++ src/Netclaw.Cli/Tui/ChatViewModel.cs | 10 +- src/Netclaw.Cli/Tui/InlineChatPage.cs | 789 ++++++++++++++++++ src/Netclaw.Cli/Tui/SessionsViewModel.cs | 12 +- .../Tui/TerminalRuntimeProfiles.cs | 29 + .../ToolExecutionContext.cs | 3 + tests/smoke/tapes/chat-inline.tape | 49 ++ 47 files changed, 4661 insertions(+), 326 deletions(-) create mode 100644 feeds/skills/.system/files/netclaw-operations/references/chat.md create mode 100644 src/Netclaw.Actors.Tests/Sessions/SessionTranscriptEntryFactoryTests.cs create mode 100644 src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs create mode 100644 src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs create mode 100644 src/Netclaw.Actors/Sessions/SessionTranscriptExtractor.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs create mode 100644 src/Netclaw.Cli.Tests/Tui/TerminalRuntimeProfilesTests.cs create mode 100644 src/Netclaw.Cli/Tui/ChatPresentation.cs create mode 100644 src/Netclaw.Cli/Tui/InlineChatPage.cs create mode 100644 src/Netclaw.Cli/Tui/TerminalRuntimeProfiles.cs create mode 100644 tests/smoke/tapes/chat-inline.tape diff --git a/Directory.Packages.props b/Directory.Packages.props index 4c4a3611c..0de9666ba 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -76,7 +76,7 @@ - + @@ -142,4 +142,4 @@ - \ No newline at end of file + diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 9b76ea793..879116eac 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -25,6 +25,7 @@ a reference file — load the one matching the user's intent with | How tool arguments are validated | [Tool argument validation](#tool-argument-validation) | | Handle very large tool output | [Large tool output](#large-tool-output) | | Understand approval prompts | [Approval Prompts](#approval-prompts) | +| Use the interactive chat TUI | `skill_read_resource('netclaw-operations', 'references/chat.md')` | | Update identity / where facts go (identity vs memory) | [Identity](#identity) | | Work on a project, switch projects | `skill_read_resource('netclaw-operations', 'references/projects.md')` | | Discover MCP / available tools | `skill_read_resource('netclaw-operations', 'references/tools.md')` | diff --git a/feeds/skills/.system/files/netclaw-operations/references/chat.md b/feeds/skills/.system/files/netclaw-operations/references/chat.md new file mode 100644 index 000000000..add45af16 --- /dev/null +++ b/feeds/skills/.system/files/netclaw-operations/references/chat.md @@ -0,0 +1,26 @@ +# Chat TUI + +`netclaw chat` uses the terminal primary buffer. The terminal owns scrollback +and mouse-wheel scroll. Stable transcript text has no outer border. + +Use these keys: + +- `Enter` sends the prompt. +- `Shift+Enter` adds a new line. +- `Up` and `Down` recall prompts and restore the current draft. +- `Esc Esc` clears the prompt. +- `Ctrl+O` opens the Inspector when chat is idle. +- `Y` copies one Inspector event. `Shift+Y` copies its complete turn. +- `Ctrl+O` expands or collapses an approval detail view. +- `Esc` denies an approval. It also closes the Inspector. +- `Ctrl+Q` exits chat. + +The Composer disappears while a turn or an approval gate is active. Wait for +the Composer before you enter another prompt. The activity deck shows thought, +tool, parallel call, and subagent state. + +The Inspector shows complete semantic event text. It omits display borders from +copy output. A failed copy keeps the event selected and shows a visible error. + +Use `netclaw sessions` to select a saved session. Netclaw closes the session +picker and opens that session in the same primary-buffer chat view. diff --git a/openspec/changes/redesign-netclaw-chat-tui/tasks.md b/openspec/changes/redesign-netclaw-chat-tui/tasks.md index b18f12638..7c8241ed1 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/tasks.md +++ b/openspec/changes/redesign-netclaw-chat-tui/tasks.md @@ -34,7 +34,7 @@ - [x] 3.6 Add `VirtualTerminal` tests for parallel commits and deterministic output order. - [x] 3.7 Add resize tests for narrower, wider, and wide-character live content. - [x] 3.8 Add failure tests that verify cursor and terminal-mode recovery. -- [ ] 3.9 Run the prototype on Linux terminals and tmux, then record the exact evidence. +- [x] 3.9 Run the prototype on Linux terminals and tmux, then record the exact evidence. - [ ] 3.10 Run the prototype on macOS and Windows Terminal, then record the exact evidence. - [ ] 3.11 Accept inline mode only if resize, scrollback, selection, paste, and exit recovery pass the matrix. @@ -46,93 +46,93 @@ - [ ] 4.4 Add a visible capability result when a terminal cannot distinguish `Shift+Enter`. - [ ] 4.5 Add dimension-free scroll operations that use the measured viewport. - [ ] 4.6 Preserve mouse coordinates on wheel input and test route selection. -- [ ] 4.7 Add semantic copy data that remains separate from display glyphs. -- [ ] 4.8 Add clipboard failure output that preserves the selected semantic data. -- [ ] 4.9 Add headless tests that exclude borders, control bytes, and truncated display text from copied data. +- [x] 4.7 Add semantic copy data that remains separate from display glyphs. +- [x] 4.8 Add clipboard failure output that preserves the selected semantic data. +- [x] 4.9 Add headless tests that exclude borders, control bytes, and truncated display text from copied data. ## 5. Netclaw Session Output Contract -- [ ] 5.1 Add `ToolActivityOutput` with `CallId`, turn identity, safe phase, and safe summary. -- [ ] 5.2 Relay current nonterminal tool activity through the session actor output boundary. -- [ ] 5.3 Add additive `RunId` and parent `CallId` fields to `SubAgentOutput`. -- [ ] 5.4 Populate stable sub-agent identities for start, activity, and completion events. -- [ ] 5.5 Add nullable wire fields and a discriminator for every new output value. -- [ ] 5.6 Map every current compaction, error, usage, file, turn, tool, and sub-agent field in both directions. -- [ ] 5.7 Apply `OutputFilter.ToolCalls` to tool activity and sub-agent activity. -- [ ] 5.8 Prove that Slack and other restricted subscribers do not receive the new activity. -- [ ] 5.9 Prove that transient activity does not enter model context or the actor journal. -- [ ] 5.10 Add DTO round-trip and old-payload fixtures for all additive fields. +- [x] 5.1 Add `ToolActivityOutput` with `CallId`, turn identity, safe phase, and safe summary. +- [x] 5.2 Relay current nonterminal tool activity through the session actor output boundary. +- [x] 5.3 Add additive `RunId` and parent `CallId` fields to `SubAgentOutput`. +- [x] 5.4 Populate stable sub-agent identities for start, activity, and completion events. +- [x] 5.5 Add nullable wire fields and a discriminator for every new output value. +- [x] 5.6 Map every current compaction, error, usage, file, turn, tool, and sub-agent field in both directions. +- [x] 5.7 Apply `OutputFilter.ToolCalls` to tool activity and sub-agent activity. +- [x] 5.8 Prove that Slack and other restricted subscribers do not receive the new activity. +- [x] 5.9 Prove that transient activity does not enter model context or the actor journal. +- [x] 5.10 Add DTO round-trip and old-payload fixtures for all additive fields. ## 6. Structured Session Resume -- [ ] 6.1 Add a framework-owned settled transcript entry union with stable discriminators. -- [ ] 6.2 Add nullable `RecentTranscript` properties without a change to `RecentMessages`. -- [ ] 6.3 Add a bounded settled timeline to session state and snapshots with new serialization tags. -- [ ] 6.4 Add settled transcript entries to `TurnRecorded` with new serialization tags. -- [ ] 6.5 Build settled entries from user, assistant, tool, sub-agent, file, error, usage, and compaction events. -- [ ] 6.6 Add read support for old journals and snapshots before new timeline writes start. -- [ ] 6.7 Convert supported `SerializableChatMessage` history to explicit legacy transcript entries. -- [ ] 6.8 Emit a diagnostic entry for unsupported legacy detail without a false active state. -- [ ] 6.9 Emit both `RecentMessages` and `RecentTranscript` during the compatibility period. -- [ ] 6.10 Add journal, snapshot, SignalR, and client resume fixtures across old and new shapes. +- [x] 6.1 Add a framework-owned settled transcript entry union with stable discriminators. +- [x] 6.2 Add nullable `RecentTranscript` properties without a change to `RecentMessages`. +- [x] 6.3 Add a bounded settled timeline to session state and snapshots with new serialization tags. +- [x] 6.4 Add settled transcript entries to `TurnRecorded` with new serialization tags. +- [x] 6.5 Build settled entries from user, assistant, tool, sub-agent, file, error, usage, and compaction events. +- [x] 6.6 Add read support for old journals and snapshots before new timeline writes start. +- [x] 6.7 Convert supported `SerializableChatMessage` history to explicit legacy transcript entries. +- [x] 6.8 Emit a diagnostic entry for unsupported legacy detail without a false active state. +- [x] 6.9 Emit both `RecentMessages` and `RecentTranscript` during the compatibility period. +- [x] 6.10 Add journal, snapshot, SignalR, and client resume fixtures across old and new shapes. ## 7. Netclaw Presentation Reducer and Visual Grammar -- [ ] 7.1 Add immutable chat presentation state with keys for turns, tool calls, sub-agents, thoughts, and approvals. -- [ ] 7.2 Add a pure reducer that maps every `SessionOutput` to state and explicit effects. -- [ ] 7.3 Add parallel tool tests where results finish in a different order than calls. -- [ ] 7.4 Add parallel same-name sub-agent tests that prove stable row identity. -- [ ] 7.5 Add the `Session Header`, `Transcript`, `Activity Rail`, `Decision Gate`, `Composer`, and `Status Line` regions. -- [ ] 7.6 Add borderless settled user, assistant, tool, thought, sub-agent, file, error, usage, and compaction forms. -- [ ] 7.7 Add concise live forms and immutable settled forms for each event lifecycle. -- [ ] 7.8 Add responsive layout rules and snapshots at 40, 60, 80, and 120 columns. +- [x] 7.1 Add immutable chat presentation state with keys for turns, tool calls, sub-agents, thoughts, and approvals. +- [x] 7.2 Add a pure reducer that maps every `SessionOutput` to state and explicit effects. +- [x] 7.3 Add parallel tool tests where results finish in a different order than calls. +- [x] 7.4 Add parallel same-name sub-agent tests that prove stable row identity. +- [x] 7.5 Add the `Session Header`, `Transcript`, `Activity Rail`, `Decision Gate`, `Composer`, and `Status Line` regions. +- [x] 7.6 Add borderless settled user, assistant, tool, thought, sub-agent, file, error, usage, and compaction forms. +- [x] 7.7 Add concise live forms and immutable settled forms for each event lifecycle. +- [x] 7.8 Add responsive layout rules and snapshots at 40, 60, 80, and 120 columns. - [ ] 7.9 Add tail-follow state, a new-event count, and an explicit return-to-tail action. - [ ] 7.10 Replace fixed scroll dimensions with the actual measured viewport. -- [ ] 7.11 Route all chat output through the inline output owner. -- [ ] 7.12 Add a visible diagnostic for an unsupported output type or invalid lifecycle transition. +- [x] 7.11 Route all chat output through the inline output owner. +- [x] 7.12 Add a visible diagnostic for an unsupported output type or invalid lifecycle transition. ## 8. Composer, Approval, Inspector, and Copy Behavior -- [ ] 8.1 Configure `Shift+Enter` for a newline and bare `Enter` for submission. -- [ ] 8.2 Restore the saved draft after prompt history reaches its newest entry. -- [ ] 8.3 Add double Escape with an injected `TimeProvider` and a defined interval. -- [ ] 8.4 Give a pending approval priority over composer Escape behavior. -- [ ] 8.5 Block paste delivery to a hidden composer while an approval owns focus. -- [ ] 8.6 Preserve compact and expanded approval forms with `Ctrl+O`. -- [ ] 8.7 Preserve the approval selection and bounded detail position across `Ctrl+O` changes. -- [ ] 8.8 Render approval control characters as visible safe text. -- [ ] 8.9 Add an inspector that shows complete event detail without transcript truncation. -- [ ] 8.10 Queue inline output while the inspector owns the terminal and commit it after exit. -- [ ] 8.11 Add semantic copy for an event and a complete turn. -- [ ] 8.12 Add visible copy errors and keep the selected data after a failure. +- [x] 8.1 Configure `Shift+Enter` for a newline and bare `Enter` for submission. +- [x] 8.2 Restore the saved draft after prompt history reaches its newest entry. +- [x] 8.3 Add double Escape with an injected `TimeProvider` and a defined interval. +- [x] 8.4 Give a pending approval priority over composer Escape behavior. +- [x] 8.5 Block paste delivery to a hidden composer while an approval owns focus. +- [x] 8.6 Preserve compact and expanded approval forms with `Ctrl+O`. +- [x] 8.7 Preserve the approval selection and bounded detail position across `Ctrl+O` changes. +- [x] 8.8 Render approval control characters as visible safe text. +- [x] 8.9 Add an inspector that shows complete event detail without transcript truncation. +- [x] 8.10 Queue inline output while the inspector owns the terminal and commit it after exit. +- [x] 8.11 Add semantic copy for an event and a complete turn. +- [x] 8.12 Add visible copy errors and keep the selected data after a failure. ## 9. Netclaw Command Integration -- [ ] 9.1 Configure `netclaw chat` for explicit `Inline` and `NativeTerminal` modes. -- [ ] 9.2 Keep init, config, provider, model, and session picker applications in `FullScreen` mode. -- [ ] 9.3 Exit the session picker before a selected inline chat application starts. +- [x] 9.1 Configure `netclaw chat` for explicit `Inline` and `NativeTerminal` modes. +- [x] 9.2 Keep init, config, provider, model, and session picker applications in `FullScreen` mode. +- [x] 9.3 Exit the session picker before a selected inline chat application starts. - [ ] 9.4 Show a visible error when the selected chat application cannot start. - [ ] 9.5 Restore cursor, input, mouse, paste, and terminal modes on normal, canceled, and failed exits. -- [ ] 9.6 Add command tests that prove each application selects its required presentation mode. +- [x] 9.6 Add command tests that prove each application selects its required presentation mode. ## 10. Package and Cross-Repository Integration -- [ ] 10.1 Run all Termina unit, compatibility, and native prototype gates. -- [ ] 10.2 Select a dotted SemVer prerelease that follows the Termina release process. -- [ ] 10.3 Publish the Termina prerelease package and record its package and commit links. -- [ ] 10.4 Update Netclaw to the prerelease with the repository package workflow. -- [ ] 10.5 Restore and build Netclaw against the published package, not a local binary. -- [ ] 10.6 Record explicit rollback steps for the package and the Netclaw presentation choice. +- [x] 10.1 Run all Termina unit, compatibility, and native prototype gates. +- [x] 10.2 Select a dotted SemVer prerelease that follows the Termina release process. +- [x] 10.3 Publish the Termina prerelease package and record its package and commit links. +- [x] 10.4 Update Netclaw to the prerelease with the repository package workflow. +- [x] 10.5 Restore and build Netclaw against the published package, not a local binary. +- [x] 10.6 Record explicit rollback steps for the package and the Netclaw presentation choice. ## 11. Verification and Completion - [ ] 11.1 Add headless tests that inject every `SessionOutput` subtype. -- [ ] 11.2 Add typed-key tests for prompt, paste, history, Escape, approval, inspector, and copy flows. +- [x] 11.2 Add typed-key tests for prompt, paste, history, Escape, approval, inspector, and copy flows. - [ ] 11.3 Add a native chat smoke tape with typed input, paste, wheel input, resize, approval, and exit recovery. -- [ ] 11.4 Run `./scripts/smoke/run-smoke.sh light` and retain the result. -- [ ] 11.5 Run the focused Netclaw actor, protocol, CLI, and TUI test suites. +- [x] 11.4 Run `./scripts/smoke/run-smoke.sh light` and retain the result. +- [x] 11.5 Run the focused Netclaw actor, protocol, CLI, and TUI test suites. - [ ] 11.6 Run `dotnet slopwatch analyze` in each repository that contains code changes. -- [ ] 11.7 Run `./scripts/Add-FileHeaders.ps1 -Verify` for Netclaw C# changes. +- [x] 11.7 Run `./scripts/Add-FileHeaders.ps1 -Verify` for Netclaw C# changes. - [ ] 11.8 Verify each issue acceptance criterion against tests or native evidence. - [ ] 11.9 Run OpenSpec verification and resolve every mismatch. - [ ] 11.10 Sync the approved delta specifications and archive the completed change. diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index 2043f5416..d15f61baf 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -54,7 +54,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}" # Cheapest harness checks first so a harness-level break fails fast # before paying for the wizard + probe tapes. -LIGHT_TAPES=(help init-wizard init-existing init-redo-identity provider-add provider-rename config-search config-exposure config-posture config-features config-audience config-channels config-mention-thread config-surfaces config-ops-surfaces config-workspaces-picker config-skill-picker config-back-nav tui-cleanup mcp-permissions approvals model-manager sessions-tui) +LIGHT_TAPES=(help init-wizard init-existing init-redo-identity provider-add provider-rename config-search config-exposure config-posture config-features config-audience config-channels config-mention-thread config-surfaces config-ops-surfaces config-workspaces-picker config-skill-picker config-back-nav tui-cleanup mcp-permissions approvals model-manager sessions-tui chat-inline) FULL_TAPES=("${LIGHT_TAPES[@]}") LIGHT_SCENARIOS=( diff --git a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs index 7618331ff..88e703f49 100644 --- a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs @@ -132,6 +132,84 @@ public void TurnRecorded_round_trips() Assert.Equal(original.RecordedAtMs, result.RecordedAtMs); } + [Fact] + public void TurnRecorded_round_trips_structured_transcript_entries() + { + var original = new TurnRecorded + { + SessionId = new SessionId("test/transcript"), + UserMessage = new SerializableChatMessage { Role = ChatRole.User, Content = "Check it" }, + AssistantReply = new SerializableChatMessage { Role = ChatRole.Assistant, Content = "Done" }, + RecordedAtMs = 1_700_000_000_000, + TranscriptEntries = + [ + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Tool, + TurnId = "turn-1", + TimestampMs = 1_700_000_000_001, + CallId = "call-1", + ToolName = "shell_execute", + ArgumentsJson = "{\"command\":\"dotnet test\"}", + Result = "Passed" + }, + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Usage, + TurnId = "turn-1", + TimestampMs = 1_700_000_000_002, + InputTokens = 10, + OutputTokens = 4, + TotalTokens = 14, + CachedInputTokens = 3, + ReasoningTokens = 2, + ContextWindowTokens = 4096, + UsagePercent = 0.25, + PromptMs = 12.5, + PredictedPerSecond = 44.2 + }, + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Error, + TurnId = "turn-1", + TimestampMs = 1_700_000_000_003, + ErrorMessage = "Failed", + ErrorDetail = "detail", + ErrorCorrelationId = "correlation", + ErrorCategory = "provider_failure" + } + ] + }; + + var result = RoundTrip(original); + + Assert.Equal(original.TranscriptEntries, result.TranscriptEntries); + } + + [Fact] + public void Old_TurnRecorded_proto_reads_with_an_empty_transcript() + { + var proto = new Serialization.Proto.TurnRecordedProto + { + SessionId = new Serialization.Proto.SessionIdProto { Value = "test/legacy" }, + UserMessage = new Serialization.Proto.SerializableChatMessageProto + { + Role = Serialization.Proto.ChatRole.User, + Content = "Hello" + }, + AssistantReply = new Serialization.Proto.SerializableChatMessageProto + { + Role = Serialization.Proto.ChatRole.Assistant, + Content = "Hi" + }, + RecordedAtMs = 1_700_000_000_000 + }; + + var result = NetclawProtoMapper.FromProto(proto); + + Assert.Empty(result.TranscriptEntries); + } + [Fact] public void TurnRecorded_round_trips_preserving_value_object_source_ids() { @@ -787,6 +865,42 @@ public void SessionSnapshot_null_eligible_turn_number_omits_proto_field() Assert.Null(result.EligibleDeliveryTurnNumber); } + [Fact] + public void SessionSnapshot_round_trips_recent_transcript() + { + var wrapped = new SessionSnapshot + { + TurnCount = 1, + History = [], + RecentTranscript = + [ + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.File, + TurnId = "turn-1", + TimestampMs = 99, + FilePath = "/tmp/report.txt", + FileName = "report.txt", + MimeType = "text/plain" + } + ] + }; + + var result = RoundTrip(wrapped); + + Assert.Equal(wrapped.RecentTranscript, result.RecentTranscript); + } + + [Fact] + public void Old_SessionSnapshot_proto_reads_with_an_empty_transcript() + { + var proto = new Serialization.Proto.SessionSnapshotProto { TurnCount = 2 }; + + var result = NetclawProtoMapper.FromProto(proto); + + Assert.Empty(result.RecentTranscript); + } + [Fact] public void ToolApprovalRequested_round_trips_all_persisted_context() { diff --git a/src/Netclaw.Actors.Tests/Sessions/ErrorCorrelationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ErrorCorrelationTests.cs index df67046cc..9f5b1de5b 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ErrorCorrelationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ErrorCorrelationTests.cs @@ -73,6 +73,26 @@ await sessionManager.Ask(new SendUserMessage Assert.NotEqual(Guid.Empty, error.CorrelationId); var tc = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(TurnOutcome.Failed, tc.Outcome); + + var child = await Sys.ActorSelection($"/user/session-manager/{Uri.EscapeDataString(sessionId.Value)}") + .ResolveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + Watch(child); + Sys.Stop(child); + await ExpectTerminatedAsync(child, cancellationToken: TestContext.Current.CancellationToken); + + var resumedSubscriber = CreateTestProbe("error-resume-subscriber"); + var resumed = await sessionManager.Ask(new JoinSession(resumedSubscriber) + { + SessionId = sessionId, + Filter = OutputFilter.Full + }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(1, resumed.TurnCount); + Assert.NotNull(resumed.RecentMessages); + var resumedError = Assert.Single(resumed.RecentTranscript!, entry => + entry.Type == SessionTranscriptEntryTypes.Error); + Assert.Equal(error.CorrelationId.ToString("D"), resumedError.ErrorCorrelationId); + Assert.Equal(nameof(ErrorCategory.ProviderFailure), resumedError.ErrorCategory); } [Fact] diff --git a/src/Netclaw.Actors.Tests/Sessions/Pipelines/SessionToolPipelineTestFixture.cs b/src/Netclaw.Actors.Tests/Sessions/Pipelines/SessionToolPipelineTestFixture.cs index f8869fec6..50dc75a0a 100644 --- a/src/Netclaw.Actors.Tests/Sessions/Pipelines/SessionToolPipelineTestFixture.cs +++ b/src/Netclaw.Actors.Tests/Sessions/Pipelines/SessionToolPipelineTestFixture.cs @@ -29,6 +29,7 @@ internal sealed class SessionToolPipelineTestFixture( private string _sessionDirectory = Path.GetTempPath(); private InlineOutputBudget _inlineOutputBudget = new(4096); private ToolExecutionTimeout _timeout = new(TimeSpan.FromSeconds(5)); + private Action _emitToolActivityOutput = _ => { }; private Action _emitSubAgentOutput = _ => { }; private Func> _spawnChildActor = static (_, _, _) => Task.FromResult(new object()); @@ -90,6 +91,12 @@ public SessionToolPipelineTestFixture EmittingSubAgentOutput(Action emit) + { + _emitToolActivityOutput = emit; + return this; + } + public SessionToolPipelineTestFixture SpawningChildrenWith( Func> spawn) { @@ -188,6 +195,7 @@ public Task ExecuteAsync(CancellationToken cancellationToken) ToolCalls = toolCalls, DefaultTimeout = _timeout, ReplyTo = replyTo, + EmitToolActivityOutput = _emitToolActivityOutput, EmitSubAgentOutput = _emitSubAgentOutput, ApprovalRequests = new ToolApprovalRequests( _approvalChannel, diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionInputCompatibilityIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionInputCompatibilityIntegrationTests.cs index 0ce6edb97..51aaa3e3e 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionInputCompatibilityIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionInputCompatibilityIntegrationTests.cs @@ -75,6 +75,10 @@ await seeder.Ask(new TurnRecorded }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); await subscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(1, joined.TurnCount); + Assert.Contains(joined.RecentTranscript!, entry => + entry.Type == SessionTranscriptEntryTypes.User && entry.Text == "Describe this image."); + Assert.Contains(joined.RecentTranscript!, entry => + entry.Type == SessionTranscriptEntryTypes.Assistant && entry.Text == "A prior response."); await sessionManager.Ask(new SendUserMessage { diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs index 37a0d1065..0fa69e829 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs @@ -74,6 +74,76 @@ public void Apply_TurnRecorded_is_cumulative() Assert.Equal(2, state.TurnCount); } + [Fact] + public void Apply_legacy_TurnRecorded_derives_settled_transcript_entries() + { + var state = SessionState.Empty.Apply(new TurnRecorded + { + SessionId = TestSessionId, + UserMessage = new SerializableChatMessage { Role = ChatRole.User, Content = "Hello" }, + AssistantReply = new SerializableChatMessage { Role = ChatRole.Assistant, Content = "Hi" }, + RecordedAtMs = 42 + }); + + Assert.Collection( + state.RecentTranscript, + entry => + { + Assert.Equal(SessionTranscriptEntryTypes.User, entry.Type); + Assert.Equal("Hello", entry.Text); + Assert.Equal(42, entry.TimestampMs); + }, + entry => + { + Assert.Equal(SessionTranscriptEntryTypes.Assistant, entry.Type); + Assert.Equal("Hi", entry.Text); + Assert.Equal(42, entry.TimestampMs); + }); + } + + [Fact] + public void Apply_TurnRecorded_uses_explicit_transcript_entries() + { + var expected = new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Tool, + TurnId = "turn-1", + CallId = "call-1", + ToolName = "shell_execute", + Result = "ok" + }; + var state = SessionState.Empty.Apply(new TurnRecorded + { + SessionId = TestSessionId, + UserMessage = new SerializableChatMessage { Role = ChatRole.User, Content = "Run it" }, + AssistantReply = new SerializableChatMessage { Role = ChatRole.Assistant, Content = "Done" }, + TranscriptEntries = [expected] + }); + + Assert.Equal([expected], state.RecentTranscript); + } + + [Fact] + public void KeepRecentTranscriptTurns_keeps_complete_recent_turns() + { + var state = SessionState.Empty; + for (var turn = 1; turn <= 3; turn++) + { + state = state.Apply(new TurnRecorded + { + SessionId = TestSessionId, + UserMessage = new SerializableChatMessage { Role = ChatRole.User, Content = $"User {turn}" }, + AssistantReply = new SerializableChatMessage { Role = ChatRole.Assistant, Content = $"Reply {turn}" } + }); + } + + var result = state.KeepRecentTranscriptTurns(2); + + Assert.Equal(4, result.RecentTranscript.Count); + Assert.Equal("User 2", result.RecentTranscript[0].Text); + Assert.Equal("Reply 3", result.RecentTranscript[^1].Text); + } + [Fact] public void Apply_SessionTitleSet_updates_title() { @@ -345,6 +415,50 @@ public void ToSnapshot_and_FromSnapshot_round_trip() } } + [Fact] + public void From_legacy_snapshot_derives_tool_transcript_without_active_state() + { + var snapshot = new SessionSnapshot + { + TurnCount = 1, + History = + [ + new SerializableChatMessage { Role = ChatRole.User, Content = "Check status" }, + new SerializableChatMessage + { + Role = ChatRole.Assistant, + Content = string.Empty, + ToolCalls = + [ + new SerializableToolCall + { + CallId = new ToolCallId("call-1"), + Name = new ToolName("status"), + ArgumentsJson = "{}" + } + ] + }, + new SerializableChatMessage + { + Role = ChatRole.Tool, + Content = "healthy", + Name = "status", + ToolCallId = new ToolCallId("call-1") + }, + new SerializableChatMessage { Role = ChatRole.Assistant, Content = "All healthy" } + ] + }; + + var restored = SessionState.FromSnapshot(snapshot); + + var tool = Assert.Single(restored.RecentTranscript, entry => + entry.Type == SessionTranscriptEntryTypes.Tool); + Assert.Equal("call-1", tool.CallId); + Assert.Equal("healthy", tool.Result); + Assert.DoesNotContain(restored.RecentTranscript, entry => + entry.Type == SessionTranscriptEntryTypes.Diagnostic); + } + [Fact] public void State_is_immutable_original_not_modified() { diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs index 28eeb3bb4..f1e5cffbc 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs @@ -86,4 +86,39 @@ public async Task Snapshot_preserves_async_callback_recipients_after_later_updat await original.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); await replacement.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } + + [Fact] + public async Task Tool_and_subagent_activity_require_the_tool_calls_filter() + { + var manager = new SessionSubscriberManager(); + var restricted = CreateTestProbe("restricted"); + var full = CreateTestProbe("full"); + var sessionId = new SessionId("channel/thread"); + + manager.AddOrUpdate(restricted.Ref, OutputFilter.Text | OutputFilter.Files); + manager.AddOrUpdate(full.Ref, OutputFilter.Full); + + manager.Emit(new ToolActivityOutput + { + SessionId = sessionId, + CallId = new ToolCallId("call-1"), + ToolName = new ToolName("shell_execute"), + TurnId = new TurnId("turn-1"), + Phase = "stdout", + Summary = "one line" + }, OutputFilter.ToolCalls); + manager.Emit(new SubAgentOutput + { + SessionId = sessionId, + AgentName = new Netclaw.Actors.SubAgents.AgentName("diagnostics"), + Phase = Netclaw.Actors.SubAgents.SubAgentPhase.Activity, + RunId = new SubAgentRunId("run-1"), + ParentCallId = new ToolCallId("call-1"), + ActivityPhase = "testing" + }, OutputFilter.ToolCalls); + + await full.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + await full.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + await restricted.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); + } } diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs index a88999edb..c1a6a7962 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionToolExecutionPipelineTests.cs @@ -71,6 +71,7 @@ public void Batch_derives_tool_authority_from_admitted_turn() ToolCalls = [new FunctionCallContent("call-1", "inspect_context")], DefaultTimeout = new ToolExecutionTimeout(TimeSpan.FromSeconds(5)), ReplyTo = ActorRefs.Nobody, + EmitToolActivityOutput = _ => { }, EmitSubAgentOutput = _ => { }, ApprovalRequests = new ToolApprovalRequests( new ApprovalChannel(), @@ -455,7 +456,7 @@ [new FunctionCallContent("call-1", "inspect_context")], .WithTimeout(TimeSpan.FromSeconds(1)) .ExecuteAsync(TestContext.Current.CancellationToken); - await probe.ExpectMsgAsync( + var completed = await probe.ExpectMsgAsync( TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); await pipelineTask.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); @@ -651,6 +652,44 @@ public async Task Opaque_tool_stream_without_a_completion_item_surfaces_an_error Assert.Contains("without a completion item", result.Content); } + [Fact] + public async Task Parallel_tool_activity_keeps_call_and_turn_correlation() + { + var executor = new CorrelatedActivityExecutor(); + var probe = CreateTestProbe("correlated-activity-probe"); + var activity = new System.Collections.Concurrent.ConcurrentQueue(); + var sessionId = new SessionId("D1/correlated-activity-test"); + var turnContext = InteractiveTurnContext(sessionId) with + { + TurnId = new TurnId("turn-activity") + }; + + var pipelineTask = new SessionToolPipelineTestFixture( + executor, + [ + new FunctionCallContent("call-a", "tool-a", new Dictionary()), + new FunctionCallContent("call-b", "tool-b", new Dictionary()) + ], + sessionId, + probe.Ref) + .WithTurnContext(turnContext) + .EmittingToolActivityOutput(activity.Enqueue) + .ExecuteAsync(TestContext.Current.CancellationToken); + + var completed = await probe.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + await pipelineTask.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + + Assert.Equal(2, activity.Count); + Assert.Equal(["call-a", "call-b"], activity.Select(item => item.CallId.Value).Order().ToArray()); + Assert.All(activity, item => Assert.Equal("turn-activity", item.TurnId.Value)); + Assert.All(activity, item => Assert.DoesNotContain('\x1b', item.Phase)); + Assert.All(activity, item => Assert.DoesNotContain('\n', item.Summary ?? string.Empty)); + Assert.All(completed.ToolResults, item => + Assert.DoesNotContain("summary-", item.Content, StringComparison.Ordinal)); + } + [Fact] public async Task Opaque_streaming_output_does_not_extend_tool_wall_clock_budget() { @@ -975,6 +1014,31 @@ public async IAsyncEnumerable ExecuteStreamAsync( } } + private sealed class CorrelatedActivityExecutor : IToolExecutor + { + public Task AuthorizeAsync( + FunctionCallContent toolCall, + ToolExecutionContext? context = null, + CancellationToken ct = default) + => Task.CompletedTask; + + public Task ExecuteAsync( + FunctionCallContent toolCall, + ToolExecutionContext? context = null, + CancellationToken ct = default) + => throw new NotSupportedException("CorrelatedActivityExecutor is streaming-only."); + + public async IAsyncEnumerable ExecuteStreamAsync( + FunctionCallContent toolCall, + ToolExecutionContext? context = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + await Task.Yield(); + yield return new ToolActivityUpdate($"phase-{toolCall.CallId}\x1b", $"summary-{toolCall.CallId}\n"); + yield return new ToolCompletedUpdate("ok"); + } + } + private sealed class SelfMonitoringStreamingExecutor : IToolExecutor { private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionTranscriptEntryFactoryTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionTranscriptEntryFactoryTests.cs new file mode 100644 index 000000000..bb0dc357f --- /dev/null +++ b/src/Netclaw.Actors.Tests/Sessions/SessionTranscriptEntryFactoryTests.cs @@ -0,0 +1,137 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; +using Netclaw.Actors.Sessions; +using Netclaw.Actors.SubAgents; +using Netclaw.Media; +using Netclaw.Tools; +using Xunit; +using static Netclaw.Actors.Sessions.SessionProtocol; + +namespace Netclaw.Actors.Tests.Sessions; + +public sealed class SessionTranscriptEntryFactoryTests +{ + private static readonly SessionId SessionId = new("test/transcript"); + + [Fact] + public void Settled_outputs_map_to_complete_transcript_entries() + { + var call = new ToolCallOutput + { + SessionId = SessionId, + TimestampMs = 10, + CallId = new ToolCallId("call-1"), + ToolName = new ToolName("shell_execute"), + ArgumentsJson = "{\"command\":\"dotnet test\"}" + }; + var tool = SessionTranscriptEntryFactory.Tool(call, new ToolResultOutput + { + SessionId = SessionId, + TimestampMs = 11, + CallId = call.CallId, + ToolName = call.ToolName, + Result = "Passed" + }, "turn-1"); + var subAgent = SessionTranscriptEntryFactory.SubAgent(new SubAgentOutput + { + SessionId = SessionId, + TimestampMs = 12, + AgentName = new AgentName("reviewer"), + Phase = SubAgentPhase.Completed, + RunId = new SubAgentRunId("run-1"), + ParentCallId = call.CallId, + Success = false, + Outcome = SubAgentRunOutcome.Partial, + OutcomeReason = SubAgentOutcomeReason.ToolIterationBudgetExhausted, + Duration = TimeSpan.FromMilliseconds(250), + FindingsCount = 2, + MemoryDecision = "accepted" + }, "turn-1"); + var file = SessionTranscriptEntryFactory.File(new FileOutput + { + SessionId = SessionId, + TimestampMs = 13, + FilePath = "/tmp/report.txt", + FileName = "report.txt", + MimeType = new MimeType("text/plain") + }, "turn-1"); + var error = SessionTranscriptEntryFactory.Error(new ErrorOutput + { + SessionId = SessionId, + TimestampMs = 14, + Message = "Provider failed", + Category = ErrorCategory.ProviderFailure, + CorrelationId = Guid.Parse("11111111-1111-1111-1111-111111111111"), + Cause = new InvalidOperationException("detail") + }, "turn-1"); + var usage = SessionTranscriptEntryFactory.Usage(new UsageOutput + { + SessionId = SessionId, + TimestampMs = 15, + InputTokens = 100, + OutputTokens = 20, + TotalTokens = 120, + CachedInputTokens = 40, + ReasoningTokens = 8, + ContextWindowTokens = 1000, + UsagePercent = 0.1, + PromptMs = 12, + PredictedPerSecond = 50 + }, "turn-1"); + var compaction = SessionTranscriptEntryFactory.Compaction(new CompactionOutput + { + SessionId = SessionId, + TimestampMs = 16, + MessagesBefore = 20, + MessagesAfter = 6, + ToolResultsCleared = true, + Summarized = true, + ContextWindowTokens = 1000, + PreCompactionInputTokens = 900, + KeepCountUsed = 4 + }, "turn-1"); + + Assert.Equal("dotnet test", System.Text.Json.JsonDocument.Parse(tool.ArgumentsJson!).RootElement + .GetProperty("command").GetString()); + Assert.Equal("Passed", tool.Result); + Assert.Equal("run-1", subAgent.RunId); + Assert.Equal("partial", subAgent.Outcome); + Assert.Equal("report.txt", file.FileName); + Assert.Equal(nameof(ErrorCategory.ProviderFailure), error.ErrorCategory); + Assert.Contains("detail", error.ErrorDetail, StringComparison.Ordinal); + Assert.Equal(8, usage.ReasoningTokens); + Assert.Equal(50, usage.PredictedPerSecond); + Assert.True(compaction.ToolResultsCleared); + Assert.True(compaction.Summarized); + } + + [Fact] + public void Transient_activity_outputs_are_not_session_journal_events() + { + var toolActivity = new ToolActivityOutput + { + SessionId = SessionId, + CallId = new ToolCallId("call-1"), + ToolName = new ToolName("search"), + TurnId = new TurnId("turn-1"), + Phase = "running", + Summary = "private progress" + }; + var subAgentActivity = new SubAgentOutput + { + SessionId = SessionId, + AgentName = new AgentName("reviewer"), + Phase = SubAgentPhase.Activity, + RunId = new SubAgentRunId("run-1"), + ActivityPhase = "reviewing", + ActivitySummary = "private progress" + }; + + Assert.IsNotAssignableFrom(toolActivity); + Assert.IsNotAssignableFrom(subAgentActivity); + } +} diff --git a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs index 4cea1ec5e..4bdd46c9a 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs @@ -236,25 +236,46 @@ await sessionManager.Ask(new SendUserMessage var toolCall = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("spawn_agent", toolCall.ToolName.Value); - var started = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + var started = await ExpectOutputAsync( + subscriber, + static output => output.Phase == SubAgentPhase.Started, + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); Assert.Equal(SubAgentPhase.Started, started.Phase); Assert.Equal("summarizer", started.AgentName.Value); Assert.Equal(2, started.ToolCount); - - var completed = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(started.RunId); + Assert.Equal(toolCall.CallId, started.ParentCallId); + + var activity = await ExpectOutputAsync( + subscriber, + static output => output.Phase == SubAgentPhase.Activity, + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + Assert.Equal(started.RunId, activity.RunId); + Assert.Equal(started.ParentCallId, activity.ParentCallId); + + var completed = await ExpectOutputAsync( + subscriber, + static output => output.Phase == SubAgentPhase.Completed, + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); Assert.Equal(SubAgentPhase.Completed, completed.Phase); Assert.Equal("summarizer", completed.AgentName.Value); + Assert.Equal(started.RunId, completed.RunId); + Assert.Equal(started.ParentCallId, completed.ParentCallId); Assert.True(completed.Success); Assert.Equal(0, completed.FindingsCount); Assert.Null(completed.MemoryDecision); // Drain the tool result output for spawn_agent emitted after tool execution - await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + await ExpectOutputAsync( + subscriber, static _ => true, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); - var text = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var text = await ExpectTextOutputAsync(subscriber, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.Contains("fake", text.Text, StringComparison.OrdinalIgnoreCase); - await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + await ExpectTurnCompletedAsync(subscriber, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); Assert.Equal(2, _clientProvider.Main.CallCount); Assert.Equal(1, _clientProvider.Compaction.CallCount); @@ -342,10 +363,15 @@ await sessionManager.Ask(new SendUserMessage var toolCall = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("spawn_agent", toolCall.ToolName.Value); - var started = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + var started = await ExpectOutputAsync( + subscriber, + static output => output.Phase == SubAgentPhase.Started, + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); Assert.Equal(SubAgentPhase.Started, started.Phase); - var request = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + var request = await ExpectOutputAsync( + subscriber, static _ => true, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); Assert.NotEqual(childCallId, request.CallId.Value); Assert.StartsWith($"{parentCallId}/subagent-approval/", request.CallId.Value, StringComparison.Ordinal); Assert.Contains("subagent-approval", request.CallId.Value, StringComparison.Ordinal); @@ -365,15 +391,22 @@ await sessionManager.Ask(new SendUserMessage }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.IsType(approvalReply); - var completed = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var completed = await ExpectOutputAsync( + subscriber, + static output => output.Phase == SubAgentPhase.Completed, + TimeSpan.FromSeconds(5), + TestContext.Current.CancellationToken); Assert.Equal(SubAgentPhase.Completed, completed.Phase); Assert.True(completed.Success); + Assert.Equal(started.RunId, completed.RunId); + Assert.Equal(started.ParentCallId, completed.ParentCallId); - var result = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + var result = await ExpectOutputAsync( + subscriber, static _ => true, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.Equal("spawn_agent", result.ToolName.Value); - await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); - await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + await ExpectTextOutputAsync(subscriber, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await ExpectTurnCompletedAsync(subscriber, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); Assert.NotNull(_recordingShellTool); Assert.True(_recordingShellTool!.WasCalled); @@ -429,8 +462,13 @@ await sessionManager.Ask(new SendUserMessage }, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - var request = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + await ExpectOutputAsync( + subscriber, + static output => output.Phase == SubAgentPhase.Started, + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + var request = await ExpectOutputAsync( + subscriber, static _ => true, TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); Assert.Contains("subagent-approval", request.CallId.Value, StringComparison.Ordinal); Assert.DoesNotContain("call-subagent-shell-expire", request.CallId.Value, StringComparison.Ordinal); AssertApprovalButtonValuesRoundTrip(request); @@ -778,27 +816,26 @@ private static MessageSource BuildReminderSource(string? reminderId = null) } private static async Task ExpectTextOutputAsync(Akka.TestKit.TestProbe probe, TimeSpan timeout, CancellationToken ct) - { - for (var i = 0; i < 8; i++) - { - var msg = await probe.ExpectMsgAsync(timeout, cancellationToken: ct); - if (msg is TextOutput text) - return text; - } - - throw new Xunit.Sdk.XunitException("Expected TextOutput but only received non-text session outputs."); - } + => await ExpectOutputAsync(probe, static _ => true, timeout, ct); private static async Task ExpectTurnCompletedAsync(Akka.TestKit.TestProbe probe, TimeSpan timeout, CancellationToken ct) + => await ExpectOutputAsync(probe, static _ => true, timeout, ct); + + private static async Task ExpectOutputAsync( + Akka.TestKit.TestProbe probe, + Func predicate, + TimeSpan timeout, + CancellationToken ct) + where TOutput : SessionOutput { - for (var i = 0; i < 8; i++) + for (var i = 0; i < 64; i++) { var msg = await probe.ExpectMsgAsync(timeout, cancellationToken: ct); - if (msg is TurnCompleted completed) - return completed; + if (msg is TOutput output && predicate(output)) + return output; } - throw new Xunit.Sdk.XunitException("Expected TurnCompleted but only received other session outputs."); + throw new Xunit.Sdk.XunitException($"Expected {typeof(TOutput).Name} but only received other session outputs."); } private async Task ColdRespawnAsync(SessionId sessionId) diff --git a/src/Netclaw.Actors.Tests/Sessions/ToolBatchHistoryWedgeTests.cs b/src/Netclaw.Actors.Tests/Sessions/ToolBatchHistoryWedgeTests.cs index e6aab2447..388e6100d 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ToolBatchHistoryWedgeTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ToolBatchHistoryWedgeTests.cs @@ -116,10 +116,7 @@ await subscriber.FishForMessageAsync( m => m is TurnCompleted { Outcome: TurnOutcome.Failed }, TimeSpan.FromSeconds(10), cancellationToken: ct); - // Turn 2: a follow-up user message forces a fresh provider request whose - // assembled messages ARE the conversation history (the error reply is - // in-memory only and never persisted, so this is the only way to observe - // the ordering the provider would see). + // Turn 2 forces a fresh provider request with the prior history. await sessionManager.Ask(new SendUserMessage { SessionId = sessionId, @@ -128,6 +125,9 @@ await sessionManager.Ask(new SendUserMessage await subscriber.FishForMessageAsync( m => m is TextOutput, TimeSpan.FromSeconds(10), cancellationToken: ct); + await subscriber.FishForMessageAsync( + m => m is TurnCompleted { Outcome: TurnOutcome.Completed }, TimeSpan.FromSeconds(10), + cancellationToken: ct); // The last provider request is turn 2's; its messages are the assembled // history including turn 1's tool_calls message, tool results, and the @@ -169,6 +169,16 @@ await subscriber.FishForMessageAsync( + $"tool-result messages answering every call id. Expected [{string.Join(",", expectedIds)}] " + $"but the contiguous run covered [{string.Join(",", answeredIds)}]. " + $"Assembled roles: {string.Join(" -> ", assembled.Select(m => m.Role.Value))}"); + + var resumed = await sessionManager.Ask(new JoinSession(subscriber) + { + SessionId = sessionId, + Filter = OutputFilter.Full + }, TimeSpan.FromSeconds(5), ct); + Assert.Contains(resumed.RecentTranscript!, entry => + entry.Type == SessionTranscriptEntryTypes.Tool && entry.CallId == "call-A"); + Assert.Contains(resumed.RecentTranscript!, entry => + entry.Type == SessionTranscriptEntryTypes.Error); } } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index f42cf6994..e5c68f888 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -3,6 +3,7 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Security; using static Netclaw.Actors.Sessions.SessionProtocol; namespace Netclaw.Actors.Protocol; @@ -19,6 +20,7 @@ public static class SessionOutputTypes public const string Thinking = "thinking"; public const string ThinkingDelta = "thinking_delta"; public const string ToolCall = "tool_call"; + public const string ToolActivity = "tool_activity"; public const string ToolResult = "tool_result"; public const string Usage = "usage"; public const string TurnCompleted = "turn_completed"; @@ -66,6 +68,9 @@ public sealed record SessionOutputDto public string? ToolName { get; init; } public string? ArgumentsJson { get; init; } public string? Result { get; init; } + public string? TurnId { get; init; } + public string? ActivityPhase { get; init; } + public string? ActivitySummary { get; init; } // Usage public long? InputTokens { get; init; } @@ -97,6 +102,8 @@ public sealed record SessionOutputDto // Compaction public int? MessagesBefore { get; init; } public int? MessagesAfter { get; init; } + public bool? ToolResultsCleared { get; init; } + public bool? Summarized { get; init; } public long? PreCompactionInputTokens { get; init; } public int? KeepCountUsed { get; init; } @@ -104,23 +111,29 @@ public sealed record SessionOutputDto public string? Title { get; init; } public int? TurnCount { get; init; } public List? RecentMessages { get; init; } + public List? RecentTranscript { get; init; } // Tool Interaction public string? InteractionKind { get; init; } public string? InteractionDisplayText { get; init; } public string? RequesterSenderId { get; init; } + public string? InteractionRequesterPrincipal { get; init; } public List? InteractionPatterns { get; init; } public List? InteractionCandidateVerbs { get; init; } + public List? InteractionCandidates { get; init; } public string? InteractionCwd { get; init; } public bool? InteractionIsMessy { get; init; } public List? InteractionOptions { get; init; } public bool? InteractionHasAdoptedContext { get; init; } public bool? InteractionHasThirdPartyAdoptedContext { get; init; } public List? InteractionAdoptedSpeakerIds { get; init; } + public bool? InteractionPersistedAdoptedContext { get; init; } // SubAgent public string? AgentName { get; init; } public string? Phase { get; init; } + public string? RunId { get; init; } + public string? ParentCallId { get; init; } public int? ToolCountSub { get; init; } public bool? SubAgentSuccess { get; init; } public string? SubAgentOutcome { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index 0a60f7565..3dbd1f7ac 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -4,7 +4,9 @@ // // ----------------------------------------------------------------------- using Netclaw.Actors.Reminders; +using Netclaw.Configuration; using Netclaw.Media; +using Netclaw.Security; using Netclaw.Tools; using static Netclaw.Actors.Sessions.SessionProtocol; @@ -60,6 +62,18 @@ public static class SessionOutputDtoMapper ArgumentsJson = msg.ArgumentsJson }, + ToolActivityOutput msg => new SessionOutputDto + { + Type = SessionOutputTypes.ToolActivity, + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + CallId = msg.CallId.Value, + ToolName = msg.ToolName.Value, + TurnId = msg.TurnId.Value, + ActivityPhase = msg.Phase, + ActivitySummary = msg.Summary + }, + ToolResultOutput msg => new SessionOutputDto { Type = SessionOutputTypes.ToolResult, @@ -132,6 +146,10 @@ public static class SessionOutputDtoMapper TimestampMs = msg.TimestampMs, AgentName = msg.AgentName.Value, Phase = msg.Phase.ToString().ToLowerInvariant(), + RunId = msg.RunId?.Value, + ParentCallId = msg.ParentCallId?.Value, + ActivityPhase = msg.ActivityPhase, + ActivitySummary = msg.ActivitySummary, ToolCountSub = msg.ToolCount, SubAgentSuccess = msg.Success, SubAgentOutcome = msg.Phase == SubAgents.SubAgentPhase.Completed @@ -169,6 +187,8 @@ public static class SessionOutputDtoMapper TimestampMs = msg.TimestampMs, MessagesBefore = msg.MessagesBefore, MessagesAfter = msg.MessagesAfter, + ToolResultsCleared = msg.ToolResultsCleared, + Summarized = msg.Summarized, ContextWindowTokens = msg.ContextWindowTokens, PreCompactionInputTokens = msg.PreCompactionInputTokens, KeepCountUsed = msg.KeepCountUsed @@ -181,7 +201,8 @@ public static class SessionOutputDtoMapper TimestampMs = msg.TimestampMs, Title = msg.Title, TurnCount = msg.TurnCount, - RecentMessages = msg.RecentMessages?.Select(m => new ChatMessageDto(m.Role, m.Content)).ToList() + RecentMessages = msg.RecentMessages?.Select(m => new ChatMessageDto(m.Role, m.Content)).ToList(), + RecentTranscript = msg.RecentTranscript?.ToList() }, ToolInteractionRequest msg => new SessionOutputDto @@ -194,14 +215,17 @@ public static class SessionOutputDtoMapper ToolName = msg.ToolName.Value, InteractionDisplayText = msg.DisplayText, RequesterSenderId = msg.RequesterSenderId?.Value, + InteractionRequesterPrincipal = msg.RequesterPrincipal?.ToString(), InteractionPatterns = [.. msg.Patterns], InteractionCandidateVerbs = [.. msg.CandidateVerbs], + InteractionCandidates = [.. msg.Candidates], InteractionCwd = msg.Cwd, InteractionIsMessy = msg.IsMessy, InteractionOptions = [.. msg.Options], InteractionHasAdoptedContext = msg.HasAdoptedContext, InteractionHasThirdPartyAdoptedContext = msg.HasThirdPartyAdoptedContext, - InteractionAdoptedSpeakerIds = [.. msg.AdoptedSpeakerIds] + InteractionAdoptedSpeakerIds = [.. msg.AdoptedSpeakerIds], + InteractionPersistedAdoptedContext = msg.PersistedAdoptedContext }, _ => new SessionOutputDto @@ -246,6 +270,16 @@ public static SessionOutput FromDto(SessionOutputDto dto) ToolName = new Netclaw.Tools.ToolName(dto.ToolName ?? "unknown"), ArgumentsJson = dto.ArgumentsJson }, + SessionOutputTypes.ToolActivity => new ToolActivityOutput + { + SessionId = sessionId, + TimestampMs = dto.TimestampMs, + CallId = new ToolCallId(dto.CallId ?? string.Empty), + ToolName = new ToolName(dto.ToolName ?? "unknown"), + TurnId = new TurnId(dto.TurnId ?? string.Empty), + Phase = dto.ActivityPhase ?? "active", + Summary = dto.ActivitySummary + }, SessionOutputTypes.ToolResult => new ToolResultOutput { SessionId = sessionId, @@ -319,6 +353,8 @@ public static SessionOutput FromDto(SessionOutputDto dto) TimestampMs = dto.TimestampMs, MessagesBefore = dto.MessagesBefore ?? 0, MessagesAfter = dto.MessagesAfter ?? 0, + ToolResultsCleared = dto.ToolResultsCleared ?? false, + Summarized = dto.Summarized ?? false, ContextWindowTokens = dto.ContextWindowTokens ?? 0, PreCompactionInputTokens = dto.PreCompactionInputTokens ?? 0, KeepCountUsed = dto.KeepCountUsed ?? 0 @@ -329,7 +365,8 @@ public static SessionOutput FromDto(SessionOutputDto dto) TimestampMs = dto.TimestampMs, Title = dto.Title, TurnCount = dto.TurnCount ?? 0, - RecentMessages = dto.RecentMessages + RecentMessages = dto.RecentMessages, + RecentTranscript = dto.RecentTranscript }, SessionOutputTypes.ToolInteraction => new ToolInteractionRequest { @@ -340,14 +377,22 @@ public static SessionOutput FromDto(SessionOutputDto dto) ToolName = new Netclaw.Tools.ToolName(dto.ToolName ?? "unknown"), DisplayText = dto.InteractionDisplayText ?? string.Empty, RequesterSenderId = dto.RequesterSenderId is { } rsid ? new SenderId(rsid) : null, + RequesterPrincipal = Enum.TryParse( + dto.InteractionRequesterPrincipal, + ignoreCase: true, + out var requesterPrincipal) + ? requesterPrincipal + : null, HasAdoptedContext = dto.InteractionHasAdoptedContext ?? false, HasThirdPartyAdoptedContext = dto.InteractionHasThirdPartyAdoptedContext ?? false, AdoptedSpeakerIds = dto.InteractionAdoptedSpeakerIds ?? [], Patterns = dto.InteractionPatterns ?? [], CandidateVerbs = dto.InteractionCandidateVerbs ?? [], + Candidates = dto.InteractionCandidates ?? [], Cwd = dto.InteractionCwd, IsMessy = dto.InteractionIsMessy ?? false, - Options = dto.InteractionOptions ?? [] + Options = dto.InteractionOptions ?? [], + PersistedAdoptedContext = dto.InteractionPersistedAdoptedContext ?? false }, _ => new ErrorOutput { @@ -355,7 +400,7 @@ public static SessionOutput FromDto(SessionOutputDto dto) TimestampMs = dto.TimestampMs, Message = $"Unknown output type from daemon: {dto.Type}" } - }; + }; } private static SubAgentRunOutcome ParseSubAgentOutcome(string? value, bool? success) @@ -369,9 +414,12 @@ private static SubAgentRunOutcome ParseSubAgentOutcome(string? value, bool? succ private static SubAgentOutput MapSubAgentOutput(SessionOutputDto dto, SessionId sessionId) { - var phase = dto.Phase?.Equals("completed", StringComparison.OrdinalIgnoreCase) == true - ? SubAgents.SubAgentPhase.Completed - : SubAgents.SubAgentPhase.Started; + var phase = dto.Phase?.ToLowerInvariant() switch + { + "completed" => SubAgents.SubAgentPhase.Completed, + "activity" => SubAgents.SubAgentPhase.Activity, + _ => SubAgents.SubAgentPhase.Started + }; return new SubAgentOutput { @@ -379,6 +427,10 @@ private static SubAgentOutput MapSubAgentOutput(SessionOutputDto dto, SessionId TimestampMs = dto.TimestampMs, AgentName = new SubAgents.AgentName(dto.AgentName ?? "unknown"), Phase = phase, + RunId = string.IsNullOrWhiteSpace(dto.RunId) ? null : new SubAgentRunId(dto.RunId), + ParentCallId = string.IsNullOrWhiteSpace(dto.ParentCallId) ? null : new ToolCallId(dto.ParentCallId), + ActivityPhase = dto.ActivityPhase, + ActivitySummary = dto.ActivitySummary, ToolCount = dto.ToolCountSub ?? 0, Success = dto.SubAgentSuccess ?? false, Outcome = phase == SubAgents.SubAgentPhase.Completed diff --git a/src/Netclaw.Actors/Protocol/SessionSnapshot.cs b/src/Netclaw.Actors/Protocol/SessionSnapshot.cs index d8bdc7f51..c3d0a815d 100644 --- a/src/Netclaw.Actors/Protocol/SessionSnapshot.cs +++ b/src/Netclaw.Actors/Protocol/SessionSnapshot.cs @@ -80,4 +80,7 @@ public sealed record AdoptedContextSnapshotMessage public IReadOnlyList AdoptedContextRecords { get; init; } = Array.Empty(); + + public IReadOnlyList RecentTranscript { get; init; } = + Array.Empty(); } diff --git a/src/Netclaw.Actors/Protocol/SessionSubscription.cs b/src/Netclaw.Actors/Protocol/SessionSubscription.cs index 7810db4da..71651a362 100644 --- a/src/Netclaw.Actors/Protocol/SessionSubscription.cs +++ b/src/Netclaw.Actors/Protocol/SessionSubscription.cs @@ -25,7 +25,10 @@ public enum OutputFilter /// — reasoning/thinking tokens. Thinking = 1 << 1, - /// and — tool interactions. + /// + /// , , + /// , and . + /// ToolCalls = 1 << 2, /// — token counts and context window consumption. @@ -109,4 +112,9 @@ public sealed record SessionJoined : SessionOutput /// Null for brand-new sessions. Populated from persisted history. /// public IReadOnlyList? RecentMessages { get; init; } + + /// + /// Recent settled structured entries. Null for a new or legacy session. + /// + public IReadOnlyList? RecentTranscript { get; init; } } diff --git a/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs b/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs new file mode 100644 index 000000000..d677c9e13 --- /dev/null +++ b/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs @@ -0,0 +1,110 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Actors.Protocol; + +/// +/// Stable discriminators for settled session transcript entries. +/// +public static class SessionTranscriptEntryTypes +{ + public const string User = "user"; + public const string Assistant = "assistant"; + public const string Tool = "tool"; + public const string SubAgent = "subagent"; + public const string File = "file"; + public const string Error = "error"; + public const string Usage = "usage"; + public const string Compaction = "compaction"; + public const string Legacy = "legacy"; + public const string Diagnostic = "diagnostic"; +} + +/// +/// A framework-owned settled transcript entry for resume and transport. +/// The value selects the valid optional fields. +/// +public sealed record SessionTranscriptEntry +{ + public required string Type { get; init; } + + public string? TurnId { get; init; } + + public long TimestampMs { get; init; } + + public string? Role { get; init; } + + public string? Text { get; init; } + + public string? CallId { get; init; } + + public string? ToolName { get; init; } + + public string? ArgumentsJson { get; init; } + + public string? Result { get; init; } + + public string? RunId { get; init; } + + public string? ParentCallId { get; init; } + + public string? AgentName { get; init; } + + public string? Outcome { get; init; } + + public string? OutcomeReason { get; init; } + + public double? DurationMs { get; init; } + + public int? FindingsCount { get; init; } + + public string? MemoryDecision { get; init; } + + public string? MemoryDecisionReason { get; init; } + + public string? FilePath { get; init; } + + public string? FileName { get; init; } + + public string? MimeType { get; init; } + + public string? ErrorMessage { get; init; } + + public string? ErrorDetail { get; init; } + + public string? ErrorCorrelationId { get; init; } + + public string? ErrorCategory { get; init; } + + public long? InputTokens { get; init; } + + public long? OutputTokens { get; init; } + + public long? TotalTokens { get; init; } + + public long? CachedInputTokens { get; init; } + + public long? ReasoningTokens { get; init; } + + public int? ContextWindowTokens { get; init; } + + public double? UsagePercent { get; init; } + + public double? PromptMs { get; init; } + + public double? PredictedPerSecond { get; init; } + + public int? MessagesBefore { get; init; } + + public int? MessagesAfter { get; init; } + + public bool? ToolResultsCleared { get; init; } + + public bool? Summarized { get; init; } + + public long? PreCompactionInputTokens { get; init; } + + public int? KeepCountUsed { get; init; } +} diff --git a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs index 5c6296d88..cb9404fb2 100644 --- a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs +++ b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs @@ -129,6 +129,103 @@ internal static Proto.SerializableChatMessageProto ToProto(SerializableChatMessa MediaReferences = proto.MediaReferences.Select(FromProto).ToArray() }; + // ── SessionTranscriptEntry ── + + internal static Proto.SessionTranscriptEntryProto ToProto(SessionTranscriptEntry entry) + { + var proto = new Proto.SessionTranscriptEntryProto + { + Type = entry.Type, + TimestampMs = entry.TimestampMs + }; + + if (entry.TurnId is not null) proto.TurnId = entry.TurnId; + if (entry.Role is not null) proto.Role = entry.Role; + if (entry.Text is not null) proto.Text = entry.Text; + if (entry.CallId is not null) proto.CallId = entry.CallId; + if (entry.ToolName is not null) proto.ToolName = entry.ToolName; + if (entry.ArgumentsJson is not null) proto.ArgumentsJson = entry.ArgumentsJson; + if (entry.Result is not null) proto.Result = entry.Result; + if (entry.RunId is not null) proto.RunId = entry.RunId; + if (entry.ParentCallId is not null) proto.ParentCallId = entry.ParentCallId; + if (entry.AgentName is not null) proto.AgentName = entry.AgentName; + if (entry.Outcome is not null) proto.Outcome = entry.Outcome; + if (entry.OutcomeReason is not null) proto.OutcomeReason = entry.OutcomeReason; + if (entry.DurationMs is not null) proto.DurationMs = entry.DurationMs.Value; + if (entry.FindingsCount is not null) proto.FindingsCount = entry.FindingsCount.Value; + if (entry.MemoryDecision is not null) proto.MemoryDecision = entry.MemoryDecision; + if (entry.MemoryDecisionReason is not null) proto.MemoryDecisionReason = entry.MemoryDecisionReason; + if (entry.FilePath is not null) proto.FilePath = entry.FilePath; + if (entry.FileName is not null) proto.FileName = entry.FileName; + if (entry.MimeType is not null) proto.MimeType = entry.MimeType; + if (entry.ErrorMessage is not null) proto.ErrorMessage = entry.ErrorMessage; + if (entry.ErrorDetail is not null) proto.ErrorDetail = entry.ErrorDetail; + if (entry.ErrorCorrelationId is not null) proto.ErrorCorrelationId = entry.ErrorCorrelationId; + if (entry.ErrorCategory is not null) proto.ErrorCategory = entry.ErrorCategory; + if (entry.InputTokens is not null) proto.InputTokens = entry.InputTokens.Value; + if (entry.OutputTokens is not null) proto.OutputTokens = entry.OutputTokens.Value; + if (entry.TotalTokens is not null) proto.TotalTokens = entry.TotalTokens.Value; + if (entry.CachedInputTokens is not null) proto.CachedInputTokens = entry.CachedInputTokens.Value; + if (entry.ReasoningTokens is not null) proto.ReasoningTokens = entry.ReasoningTokens.Value; + if (entry.ContextWindowTokens is not null) proto.ContextWindowTokens = entry.ContextWindowTokens.Value; + if (entry.UsagePercent is not null) proto.UsagePercent = entry.UsagePercent.Value; + if (entry.PromptMs is not null) proto.PromptMs = entry.PromptMs.Value; + if (entry.PredictedPerSecond is not null) proto.PredictedPerSecond = entry.PredictedPerSecond.Value; + if (entry.MessagesBefore is not null) proto.MessagesBefore = entry.MessagesBefore.Value; + if (entry.MessagesAfter is not null) proto.MessagesAfter = entry.MessagesAfter.Value; + if (entry.ToolResultsCleared is not null) proto.ToolResultsCleared = entry.ToolResultsCleared.Value; + if (entry.Summarized is not null) proto.Summarized = entry.Summarized.Value; + if (entry.PreCompactionInputTokens is not null) + proto.PreCompactionInputTokens = entry.PreCompactionInputTokens.Value; + if (entry.KeepCountUsed is not null) proto.KeepCountUsed = entry.KeepCountUsed.Value; + + return proto; + } + + internal static SessionTranscriptEntry FromProto(Proto.SessionTranscriptEntryProto proto) => new() + { + Type = proto.Type, + TurnId = proto.HasTurnId ? proto.TurnId : null, + TimestampMs = proto.TimestampMs, + Role = proto.HasRole ? proto.Role : null, + Text = proto.HasText ? proto.Text : null, + CallId = proto.HasCallId ? proto.CallId : null, + ToolName = proto.HasToolName ? proto.ToolName : null, + ArgumentsJson = proto.HasArgumentsJson ? proto.ArgumentsJson : null, + Result = proto.HasResult ? proto.Result : null, + RunId = proto.HasRunId ? proto.RunId : null, + ParentCallId = proto.HasParentCallId ? proto.ParentCallId : null, + AgentName = proto.HasAgentName ? proto.AgentName : null, + Outcome = proto.HasOutcome ? proto.Outcome : null, + OutcomeReason = proto.HasOutcomeReason ? proto.OutcomeReason : null, + DurationMs = proto.HasDurationMs ? proto.DurationMs : null, + FindingsCount = proto.HasFindingsCount ? proto.FindingsCount : null, + MemoryDecision = proto.HasMemoryDecision ? proto.MemoryDecision : null, + MemoryDecisionReason = proto.HasMemoryDecisionReason ? proto.MemoryDecisionReason : null, + FilePath = proto.HasFilePath ? proto.FilePath : null, + FileName = proto.HasFileName ? proto.FileName : null, + MimeType = proto.HasMimeType ? proto.MimeType : null, + ErrorMessage = proto.HasErrorMessage ? proto.ErrorMessage : null, + ErrorDetail = proto.HasErrorDetail ? proto.ErrorDetail : null, + ErrorCorrelationId = proto.HasErrorCorrelationId ? proto.ErrorCorrelationId : null, + ErrorCategory = proto.HasErrorCategory ? proto.ErrorCategory : null, + InputTokens = proto.HasInputTokens ? proto.InputTokens : null, + OutputTokens = proto.HasOutputTokens ? proto.OutputTokens : null, + TotalTokens = proto.HasTotalTokens ? proto.TotalTokens : null, + CachedInputTokens = proto.HasCachedInputTokens ? proto.CachedInputTokens : null, + ReasoningTokens = proto.HasReasoningTokens ? proto.ReasoningTokens : null, + ContextWindowTokens = proto.HasContextWindowTokens ? proto.ContextWindowTokens : null, + UsagePercent = proto.HasUsagePercent ? proto.UsagePercent : null, + PromptMs = proto.HasPromptMs ? proto.PromptMs : null, + PredictedPerSecond = proto.HasPredictedPerSecond ? proto.PredictedPerSecond : null, + MessagesBefore = proto.HasMessagesBefore ? proto.MessagesBefore : null, + MessagesAfter = proto.HasMessagesAfter ? proto.MessagesAfter : null, + ToolResultsCleared = proto.HasToolResultsCleared ? proto.ToolResultsCleared : null, + Summarized = proto.HasSummarized ? proto.Summarized : null, + PreCompactionInputTokens = proto.HasPreCompactionInputTokens ? proto.PreCompactionInputTokens : null, + KeepCountUsed = proto.HasKeepCountUsed ? proto.KeepCountUsed : null + }; + // ── SendUserMessage ── internal static Proto.SendUserMessageProto ToProto(SendUserMessage cmd) @@ -166,6 +263,7 @@ internal static Proto.TurnRecordedProto ToProto(TurnRecorded evt) proto.SourceReminderId = reminderId.Value; if (evt.SourceBackgroundJobId is { } backgroundJobId) proto.SourceBackgroundJobId = backgroundJobId.Value; + proto.TranscriptEntries.AddRange(evt.TranscriptEntries.Select(ToProto)); return proto; } @@ -176,7 +274,8 @@ internal static Proto.TurnRecordedProto ToProto(TurnRecorded evt) AssistantReply = FromProto(proto.AssistantReply), RecordedAtMs = proto.RecordedAtMs, SourceReminderId = proto.HasSourceReminderId ? new ReminderId(proto.SourceReminderId) : (ReminderId?)null, - SourceBackgroundJobId = proto.HasSourceBackgroundJobId ? new BackgroundJobId(proto.SourceBackgroundJobId) : (BackgroundJobId?)null + SourceBackgroundJobId = proto.HasSourceBackgroundJobId ? new BackgroundJobId(proto.SourceBackgroundJobId) : (BackgroundJobId?)null, + TranscriptEntries = proto.TranscriptEntries.Select(FromProto).ToArray() }; // ── SessionTitleSet ── @@ -489,6 +588,7 @@ internal static Proto.SessionSnapshotProto ToProto(SessionSnapshot snap) if (snap.WorkingContext is not null) proto.WorkingContext = ToProto(snap.WorkingContext); proto.History.AddRange(snap.History.Select(ToProto)); + proto.RecentTranscript.AddRange(snap.RecentTranscript.Select(ToProto)); proto.ActiveBackgroundJobs.AddRange(snap.ActiveBackgroundJobs.Select(ToProto)); proto.AdoptedContextRecords.AddRange(snap.AdoptedContextRecords.Select(ToAdoptedContextSnapshotRecord)); return proto; @@ -503,6 +603,7 @@ internal static Proto.SessionSnapshotProto ToProto(SessionSnapshot snap) : null, WorkingContext = proto.WorkingContext is not null ? FromProto(proto.WorkingContext) : null, History = proto.History.Select(FromProto).ToArray(), + RecentTranscript = proto.RecentTranscript.Select(FromProto).ToArray(), ActiveBackgroundJobs = proto.ActiveBackgroundJobs.Select(FromProto).ToArray(), AdoptedContextRecords = proto.AdoptedContextRecords.Select(FromAdoptedContextSnapshotRecord).ToArray() }; @@ -553,22 +654,22 @@ private static SessionSnapshot.AdoptedContextSnapshotRecord FromAdoptedContextSn private static Proto.SessionSnapshotProto.Types.AdoptedContextSnapshotRecord.Types.AdoptedContextSnapshotMessage ToAdoptedContextSnapshotMessage(SessionSnapshot.AdoptedContextSnapshotRecord.AdoptedContextSnapshotMessage m) => new() - { - MessageId = m.MessageId, - SenderId = m.SenderId.Value, - TimestampMs = m.TimestampMs, - AuthorityAtInclusion = m.AuthorityAtInclusion - }; + { + MessageId = m.MessageId, + SenderId = m.SenderId.Value, + TimestampMs = m.TimestampMs, + AuthorityAtInclusion = m.AuthorityAtInclusion + }; private static SessionSnapshot.AdoptedContextSnapshotRecord.AdoptedContextSnapshotMessage FromAdoptedContextSnapshotMessage( Proto.SessionSnapshotProto.Types.AdoptedContextSnapshotRecord.Types.AdoptedContextSnapshotMessage proto) => new() - { - MessageId = proto.MessageId, - SenderId = new SenderId(proto.SenderId), - TimestampMs = proto.TimestampMs, - AuthorityAtInclusion = proto.AuthorityAtInclusion - }; + { + MessageId = proto.MessageId, + SenderId = new SenderId(proto.SenderId), + TimestampMs = proto.TimestampMs, + AuthorityAtInclusion = proto.AuthorityAtInclusion + }; // ── WorkingContext ── @@ -732,21 +833,21 @@ internal static AdoptedContextRecorded FromProto(Proto.AdoptedContextRecordedPro private static Proto.AdoptedContextRecordedProto.Types.AdoptedMessageRecordProto ToAdoptedMessageRecord( AdoptedContextRecorded.AdoptedMessageRecord m) => new() - { - MessageId = m.MessageId, - SenderId = m.SenderId.Value, - TimestampMs = m.TimestampMs, - AuthorityAtInclusion = m.AuthorityAtInclusion - }; + { + MessageId = m.MessageId, + SenderId = m.SenderId.Value, + TimestampMs = m.TimestampMs, + AuthorityAtInclusion = m.AuthorityAtInclusion + }; private static AdoptedContextRecorded.AdoptedMessageRecord FromAdoptedMessageRecord( Proto.AdoptedContextRecordedProto.Types.AdoptedMessageRecordProto proto) => new() - { - MessageId = proto.MessageId, - SenderId = new SenderId(proto.SenderId), - TimestampMs = proto.TimestampMs, - AuthorityAtInclusion = proto.AuthorityAtInclusion - }; + { + MessageId = proto.MessageId, + SenderId = new SenderId(proto.SenderId), + TimestampMs = proto.TimestampMs, + AuthorityAtInclusion = proto.AuthorityAtInclusion + }; // ── CursorAdvanced ── diff --git a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto index b8843a08b..2f72364bd 100644 --- a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto +++ b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto @@ -77,6 +77,49 @@ message SerializableChatMessageProto { repeated SerializableMediaReferenceProto media_references = 6; } +message SessionTranscriptEntryProto { + string type = 1; + optional string turn_id = 2; + int64 timestamp_ms = 3; + optional string role = 4; + optional string text = 5; + optional string call_id = 6; + optional string tool_name = 7; + optional string arguments_json = 8; + optional string result = 9; + optional string run_id = 10; + optional string parent_call_id = 11; + optional string agent_name = 12; + optional string outcome = 13; + optional string outcome_reason = 14; + optional double duration_ms = 15; + optional int32 findings_count = 16; + optional string memory_decision = 17; + optional string memory_decision_reason = 18; + optional string file_path = 19; + optional string file_name = 20; + optional string mime_type = 21; + optional string error_message = 22; + optional string error_correlation_id = 23; + optional string error_category = 24; + optional int64 input_tokens = 25; + optional int64 output_tokens = 26; + optional int64 total_tokens = 27; + optional int64 cached_input_tokens = 28; + optional int64 reasoning_tokens = 29; + optional int32 context_window_tokens = 30; + optional double usage_percent = 31; + optional int32 messages_before = 32; + optional int32 messages_after = 33; + optional bool tool_results_cleared = 34; + optional bool summarized = 35; + optional int64 pre_compaction_input_tokens = 36; + optional int32 keep_count_used = 37; + optional string error_detail = 38; + optional double prompt_ms = 39; + optional double predicted_per_second = 40; +} + // ── Commands ── message SendUserMessageProto { @@ -94,6 +137,7 @@ message TurnRecordedProto { int64 recorded_at_ms = 4; optional string source_reminder_id = 5; optional string source_background_job_id = 6; + repeated SessionTranscriptEntryProto transcript_entries = 7; } message SessionTitleSetProto { @@ -268,6 +312,7 @@ message SessionSnapshotProto { repeated ActiveJobInfoProto active_background_jobs = 7; repeated AdoptedContextSnapshotRecord adopted_context_records = 8; reserved 9; + repeated SessionTranscriptEntryProto recent_transcript = 10; } // ── Session state ── diff --git a/src/Netclaw.Actors/Sessions/LlmMessages.cs b/src/Netclaw.Actors/Sessions/LlmMessages.cs index d68c9115b..62ea2174b 100644 --- a/src/Netclaw.Actors/Sessions/LlmMessages.cs +++ b/src/Netclaw.Actors/Sessions/LlmMessages.cs @@ -121,6 +121,7 @@ internal sealed record WorkingContextSnapshotFatal(Exception Cause) internal sealed record CompletedSubAgentRun : INoSerializationVerificationNeeded { public required SubAgentRunId RunId { get; init; } + public required ToolCallId ParentCallId { get; init; } public required SubAgents.AgentName AgentName { get; init; } public required ChildRunCompletion Completion { get; init; } public required TimeSpan Duration { get; init; } @@ -136,6 +137,7 @@ internal sealed record CompletedSubAgentRun : INoSerializationVerificationNeeded internal sealed record AcceptedSubAgentFinding : INoSerializationVerificationNeeded { public required SubAgentRunId RunId { get; init; } + public required ToolCallId ParentCallId { get; init; } public required SubAgents.AgentName AgentName { get; init; } public required TimeSpan Duration { get; init; } public required SubAgentFindingShape Shape { get; init; } diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index ac804cbbd..b4db965d5 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using System.Diagnostics; using System.Text.Json; +using System.Threading.Channels; using Akka.Actor; using Akka.Event; using Akka.Hosting; @@ -206,6 +207,10 @@ public sealed class LlmSessionActor : ReceivePersistentActor, IWithTimers // Persistent state (immutable — replaced on each event) private SessionState _state = SessionState.Empty; + private readonly List _settledTurnEntries = []; + private readonly Dictionary _transcriptToolCalls = + new(StringComparer.Ordinal); + // Explicit state machine phase (metadata + validation layer over Become()) private readonly SessionPhaseMachine _phase = new(); @@ -298,7 +303,8 @@ public LlmSessionActor( { if (offer.Snapshot is SessionSnapshot snapshot) { - _state = SessionState.FromSnapshot(snapshot); + _state = SessionState.FromSnapshot(snapshot) + .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages)); if (snapshot.EligibleDeliveryTurnNumber is { } eligibleTurn) _deliveryRetry.MarkEligible(eligibleTurn); @@ -730,6 +736,9 @@ private void Processing() TimestampMs = msg.TimestampMs, AgentName = msg.AgentName, Phase = msg.Phase, + RunId = msg.RunId, + ActivityPhase = msg.ActivityPhase, + ActivitySummary = msg.ActivitySummary, ToolCount = msg.ToolCount, Success = msg.Success ?? false, Duration = msg.Duration ?? TimeSpan.Zero, @@ -858,6 +867,8 @@ private void HandleToolExecutionCompleted(ToolExecutionCompleted msg) TimestampMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), AgentName = finding.AgentName, Phase = Netclaw.Actors.SubAgents.SubAgentPhase.Completed, + RunId = finding.RunId, + ParentCallId = finding.ParentCallId, Success = true, Outcome = runSummary?.Outcome ?? SubAgentRunOutcome.Completed, OutcomeReason = runSummary?.OutcomeReason, @@ -895,6 +906,8 @@ private void HandleToolExecutionCompleted(ToolExecutionCompleted msg) TimestampMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), AgentName = run.AgentName, Phase = Netclaw.Actors.SubAgents.SubAgentPhase.Completed, + RunId = run.RunId, + ParentCallId = run.ParentCallId, Success = run.Success, Outcome = run.Outcome, OutcomeReason = run.OutcomeReason, @@ -1287,9 +1300,7 @@ private void HandleCompactionWorkCompleted(CompactionWorkCompleted msg) CurrentMemoryAudience(), msg.Summary))); - SaveSnapshot(BuildSnapshot()); - - EmitOutput(new CompactionOutput + var compactionOutput = new CompactionOutput { SessionId = _sessionId, MessagesBefore = msg.MessagesBefore, @@ -1299,7 +1310,18 @@ private void HandleCompactionWorkCompleted(CompactionWorkCompleted msg) ContextWindowTokens = _model.ContextWindowTokens, PreCompactionInputTokens = msg.PreCompactionInputTokens, KeepCountUsed = msg.KeepCountUsed - }); + }; + + _state = (_state with + { + RecentTranscript = _state.RecentTranscript.Add( + SessionTranscriptEntryFactory.Compaction( + compactionOutput, + _activeTurnId?.Value)) + }).KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages)); + + SaveSnapshot(BuildSnapshot()); + EmitOutput(compactionOutput); _log.Info("Compaction complete (before={MessagesBefore}, after={MessagesAfter})", msg.MessagesBefore, _state.History.Count); @@ -2005,6 +2027,11 @@ private void DispatchToolBatch( // from a non-actor thread. var subscriberSnapshot = _subscribers.Snapshot(); var logActor = _logActor; + Action emitToolActivityOutput = output => + { + SessionSubscriberManager.Emit(subscriberSnapshot, output, OutputFilter.ToolCalls); + logActor?.Tell(output); + }; Action emitSubAgentOutput = output => { SessionSubscriberManager.Emit(subscriberSnapshot, output, OutputFilter.ToolCalls); @@ -2051,6 +2078,7 @@ await self.Ask( ToolCalls = toolCalls, DefaultTimeout = new ToolExecutionTimeout(toolExecutionTimeout), ReplyTo = self, + EmitToolActivityOutput = emitToolActivityOutput, EmitSubAgentOutput = emitSubAgentOutput, ApprovalRequests = new ToolApprovalRequests( _approvalChannel, @@ -2087,6 +2115,7 @@ private void HandleTextResponse( var reply = ChatMessageConverter.FromAiMessage(lastMessage); var userMsg = _state.FindLastUserMessage(); + var recordedAtMs = NowMs(); // Track input token count for compaction threshold check if (usage?.InputTokenCount is > 0) @@ -2103,9 +2132,18 @@ private void HandleTextResponse( Content = string.Empty }, AssistantReply = reply, - RecordedAtMs = NowMs(), + RecordedAtMs = recordedAtMs, SourceReminderId = _currentTurnSource?.ReminderId, - SourceBackgroundJobId = _currentTurnSource?.BackgroundJobId + SourceBackgroundJobId = _currentTurnSource?.BackgroundJobId, + TranscriptEntries = BuildTurnTranscriptEntries( + userMsg ?? new SerializableChatMessage + { + Role = Protocol.ChatRole.User, + Content = string.Empty + }, + reply, + usage, + recordedAtMs) }; Persist(turnEvent, evt => @@ -2124,7 +2162,12 @@ private void HandleTextResponse( History = _state.History.Add(evt.AssistantReply), TurnCount = _state.TurnCount + 1, ProcessedReminderIds = processed - }).CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); + }).AppendTranscript(evt) + .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages)) + .CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); + + _settledTurnEntries.Clear(); + _transcriptToolCalls.Clear(); EmitResponseOutputs(lastMessage, usage, includeText: true, includeThinking: true); MaybeSnapshot(); @@ -2158,6 +2201,44 @@ private void HandleTextResponse( }); } + private IReadOnlyList BuildTurnTranscriptEntries( + SerializableChatMessage userMessage, + SerializableChatMessage assistantReply, + UsageDetails? usage, + long recordedAtMs) + { + var turnId = _activeTurnId?.Value; + var extracted = SessionTranscriptExtractor.ExtractTurn( + _state.History, + userMessage, + assistantReply, + turnId, + recordedAtMs); + var entries = new List(); + + entries.AddRange(extracted.Where(entry => entry.Type == SessionTranscriptEntryTypes.User)); + entries.AddRange(_settledTurnEntries.OrderBy(entry => entry.TimestampMs)); + + var capturedToolIds = _settledTurnEntries + .Where(entry => entry.Type == SessionTranscriptEntryTypes.Tool && entry.CallId is not null) + .Select(entry => entry.CallId!) + .ToHashSet(StringComparer.Ordinal); + entries.AddRange(extracted.Where(entry => + entry.Type == SessionTranscriptEntryTypes.Tool + && (entry.CallId is null || !capturedToolIds.Contains(entry.CallId)))); + entries.AddRange(extracted.Where(entry => entry.Type == SessionTranscriptEntryTypes.Diagnostic)); + entries.AddRange(extracted.Where(entry => entry.Type == SessionTranscriptEntryTypes.Assistant)); + + if (usage is not null) + { + entries.Add(SessionTranscriptEntryFactory.Usage( + BuildUsageOutput(usage, recordedAtMs), + turnId)); + } + + return entries; + } + private void DrainBufferedMessagesOrBecomeReady() { if (_restartDrainRequested) @@ -2317,6 +2398,8 @@ private void ContinueIncomingUserMessage(SendUserMessage cmd) _deliveryRetry.Clear(); _currentTurnSource = cmd.Source; BindTurnTelemetry(cmd.Source); + _settledTurnEntries.Clear(); + _transcriptToolCalls.Clear(); _currentTurnContext = TurnContext.FromMessageSource( _sessionId, _activeTurnId ?? new Protocol.TurnId(IdGen.ShortId()), @@ -2460,6 +2543,9 @@ private void CommandSubscriptionMessages() TimestampMs = msg.TimestampMs, AgentName = msg.AgentName, Phase = msg.Phase, + RunId = msg.RunId, + ActivityPhase = msg.ActivityPhase, + ActivitySummary = msg.ActivitySummary, ToolCount = msg.ToolCount, Success = msg.Success ?? false, Duration = msg.Duration ?? TimeSpan.Zero, @@ -2491,7 +2577,10 @@ private void CommandSubscriptionMessages() SessionId = _sessionId, Title = _state.Title, TurnCount = _state.TurnCount, - RecentMessages = SessionRecentMessageExtractor.Extract(_state.History) + RecentMessages = SessionRecentMessageExtractor.Extract(_state.History), + RecentTranscript = _state.RecentTranscript.Count > 0 + ? _state.RecentTranscript + : null }; // On re-join, only reply to the Sender (for Ask callers) — don't @@ -3269,8 +3358,13 @@ await self.Ask( { self.Tell(new RoutedSkillSubAgentActivity( _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + info.RunId, new AgentName(info.AgentName), - info.IsStarted ? SubAgentPhase.Started : SubAgentPhase.Completed, + info.IsActivity + ? SubAgentPhase.Activity + : info.IsStarted ? SubAgentPhase.Started : SubAgentPhase.Completed, + info.ActivityPhase, + info.ActivitySummary, info.ToolCount, info.Success, info.Duration, @@ -3291,13 +3385,20 @@ await self.Ask( SpawnChildActor = spawnChildActor, }, new ToolExecutionTimeout(_config.ToolExecutionTimeout), outputs); - var result = await _subAgentSpawner!.SpawnAsync( + var activityChannel = Channel.CreateUnbounded(); + var spawnTask = _subAgentSpawner!.SpawnAsync( profile, task, runtimeContext: null, context.Invocation, CancellationToken.None, - systemPromptOverlay: skillBody); + systemPromptOverlay: skillBody, + activitySink: activityChannel.Writer); + + await foreach (var activity in activityChannel.Reader.ReadAllAsync()) + _ = activity; + + var result = await spawnTask; self.Tell(new RoutedSkillExecutionCompleted(skill.Name, profile.Name, result)); } @@ -3321,6 +3422,12 @@ private void HandleRoutedSkillExecutionCompleted(RoutedSkillExecutionCompleted m MergeSuccessfulSubAgentWorkingContext(msg.Result.Completion); var userMsg = _state.FindLastUserMessage(); + var recordedAtMs = NowMs(); + var assistantReply = new SerializableChatMessage + { + Role = Protocol.ChatRole.Assistant, + Content = msg.Result.Output + }; var turnEvent = new TurnRecorded { SessionId = _sessionId, @@ -3329,14 +3436,19 @@ private void HandleRoutedSkillExecutionCompleted(RoutedSkillExecutionCompleted m Role = Protocol.ChatRole.User, Content = string.Empty }, - AssistantReply = new SerializableChatMessage - { - Role = Protocol.ChatRole.Assistant, - Content = msg.Result.Output - }, - RecordedAtMs = NowMs(), + AssistantReply = assistantReply, + RecordedAtMs = recordedAtMs, SourceReminderId = _currentTurnSource?.ReminderId, - SourceBackgroundJobId = _currentTurnSource?.BackgroundJobId + SourceBackgroundJobId = _currentTurnSource?.BackgroundJobId, + TranscriptEntries = BuildTurnTranscriptEntries( + userMsg ?? new SerializableChatMessage + { + Role = Protocol.ChatRole.User, + Content = string.Empty + }, + assistantReply, + usage: null, + recordedAtMs) }; Persist(turnEvent, evt => @@ -3355,7 +3467,12 @@ private void HandleRoutedSkillExecutionCompleted(RoutedSkillExecutionCompleted m History = _state.History.Add(evt.AssistantReply), TurnCount = _state.TurnCount + 1, ProcessedReminderIds = processed - }).CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); + }).AppendTranscript(evt) + .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages)) + .CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); + + _settledTurnEntries.Clear(); + _transcriptToolCalls.Clear(); EmitOutput(new TextOutput(msg.Result.Output) { @@ -3481,15 +3598,18 @@ private void ApplyTurnRecorded(TurnRecorded evt) var lastUser = _state.FindLastUserMessage(); if (lastUser == evt.UserMessage) { - _state = (_state with + _state = ((_state with { History = _state.History.Add(evt.AssistantReply), TurnCount = _state.TurnCount + 1 - }).CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); + }).AppendTranscript(evt) + .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages))) + .CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); return; } - _state = _state.Apply(evt); + _state = _state.Apply(evt) + .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages)); } private void ApplyToolBatchStarted(ToolBatchStarted evt) @@ -3778,6 +3898,12 @@ private void EmitUsageOutput(UsageDetails usage) { _sessionMetrics?.RecordTokenUsage(usage.InputTokenCount ?? 0, usage.OutputTokenCount ?? 0); + EmitOutput(BuildUsageOutput(usage, NowMs()), OutputFilter.Usage); + } + + private UsageOutput BuildUsageOutput(UsageDetails usage, long timestampMs) + { + var contextWindow = _model.ContextWindowTokens; double? usagePercent = usage.InputTokenCount.HasValue && contextWindow > 0 ? (double)usage.InputTokenCount.Value / contextWindow @@ -3792,9 +3918,10 @@ private void EmitUsageOutput(UsageDetails usage) double? predictedPerSec = additional is not null && additional.TryGetValue("predicted_tok_per_sec_x100", out var pps) ? pps / 100.0 : null; - EmitOutput(new UsageOutput + return new UsageOutput { SessionId = _sessionId, + TimestampMs = timestampMs, InputTokens = usage.InputTokenCount, OutputTokens = usage.OutputTokenCount, TotalTokens = usage.TotalTokenCount, @@ -3803,8 +3930,8 @@ private void EmitUsageOutput(UsageDetails usage) ContextWindowTokens = contextWindow, UsagePercent = usagePercent, PromptMs = promptMs, - PredictedPerSecond = predictedPerSec, - }, OutputFilter.Usage); + PredictedPerSecond = predictedPerSec + }; } /// @@ -4497,42 +4624,111 @@ private void FailCurrentTurn(string errorMessage, Exception cause, ErrorCategory _pendingToolInteractions.Clear(); _resolvedToolApprovals.Clear(); ClearApprovalTurnState(); - _state = _state.AddErrorReply(errorMessage); var correlationId = Guid.NewGuid(); - - TurnLog().Error(cause, - "turn_failed category={Category} correlationId={CorrelationId} message={Message}", - category, - correlationId, - errorMessage); - - EmitOutput(new ErrorOutput + var recordedAtMs = NowMs(); + var userMessage = _state.FindLastUserMessage() ?? new SerializableChatMessage + { + Role = Protocol.ChatRole.User, + Content = string.Empty + }; + var assistantReply = new SerializableChatMessage + { + Role = Protocol.ChatRole.Assistant, + Content = errorMessage + }; + var errorOutput = new ErrorOutput { SessionId = _sessionId, + TimestampMs = recordedAtMs, Message = errorMessage, Category = category, CorrelationId = correlationId, Cause = cause - }); - EmitOutput(new TurnCompleted + }; + + _settledTurnEntries.Add(SessionTranscriptEntryFactory.Error( + errorOutput, + _activeTurnId?.Value)); + var turnEvent = new TurnRecorded { SessionId = _sessionId, - TurnNumber = new TurnNumber(_state.TurnCount), - Outcome = TurnOutcome.Failed, - SourceReminderId = _currentTurnSource?.ReminderId - }); + UserMessage = userMessage, + AssistantReply = assistantReply, + RecordedAtMs = recordedAtMs, + SourceReminderId = _currentTurnSource?.ReminderId, + SourceBackgroundJobId = _currentTurnSource?.BackgroundJobId, + TranscriptEntries = BuildTurnTranscriptEntries( + userMessage, + assistantReply, + usage: null, + recordedAtMs) + }; + + _state = _state.AddErrorReply(errorMessage); + + TurnLog().Error(cause, + "turn_failed category={Category} correlationId={CorrelationId} message={Message}", + category, + correlationId, + errorMessage); - DrainBufferedMessagesOrBecomeReady(); + Persist(turnEvent, evt => + { + var processed = _state.ProcessedReminderIds; + if (evt.SourceReminderId is { } reminderId && !string.IsNullOrEmpty(reminderId.Value)) + processed = processed.Add(reminderId); + + _state = (_state with { ProcessedReminderIds = processed }) + .AppendTranscript(evt) + .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages)) + .CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); + _settledTurnEntries.Clear(); + _transcriptToolCalls.Clear(); + + EmitOutput(errorOutput); + EmitOutput(new TurnCompleted + { + SessionId = _sessionId, + TurnNumber = new TurnNumber(_state.TurnCount), + Outcome = TurnOutcome.Failed, + SourceReminderId = _currentTurnSource?.ReminderId + }); + + DrainBufferedMessagesOrBecomeReady(); + }); } private void EmitOutput(SessionOutput output, OutputFilter requiredFlag = OutputFilter.None) { + CaptureSettledTranscriptEntry(output); _subscribers.Emit(output, requiredFlag); _logActor?.Tell(output); _observerActor?.Tell(output); } + private void CaptureSettledTranscriptEntry(SessionOutput output) + { + var turnId = _activeTurnId?.Value; + switch (output) + { + case ToolCallOutput call: + _transcriptToolCalls[call.CallId.Value] = call; + break; + case ToolResultOutput result: + _transcriptToolCalls.TryGetValue(result.CallId.Value, out var storedCall); + _settledTurnEntries.Add(SessionTranscriptEntryFactory.Tool(storedCall, result, turnId)); + _transcriptToolCalls.Remove(result.CallId.Value); + break; + case SubAgentOutput { Phase: SubAgents.SubAgentPhase.Completed } subAgent: + _settledTurnEntries.Add(SessionTranscriptEntryFactory.SubAgent(subAgent, turnId)); + break; + case FileOutput file: + _settledTurnEntries.Add(SessionTranscriptEntryFactory.File(file, turnId)); + break; + } + } + private async Task PersistApprovalCandidatesAsync( PendingToolInteraction pending, ApprovalDecision decision, @@ -4662,6 +4858,8 @@ private void ProcessToolCallResult(Pipelines.ToolCallResult result) TimestampMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), AgentName = finding.AgentName, Phase = Netclaw.Actors.SubAgents.SubAgentPhase.Completed, + RunId = finding.RunId, + ParentCallId = finding.ParentCallId, Success = true, Outcome = runSummary?.Outcome ?? SubAgentRunOutcome.Completed, OutcomeReason = runSummary?.OutcomeReason, @@ -4699,6 +4897,8 @@ private void ProcessToolCallResult(Pipelines.ToolCallResult result) TimestampMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), AgentName = run.AgentName, Phase = Netclaw.Actors.SubAgents.SubAgentPhase.Completed, + RunId = run.RunId, + ParentCallId = run.ParentCallId, Success = run.Success, Outcome = run.Outcome, OutcomeReason = run.OutcomeReason, @@ -4905,8 +5105,11 @@ private sealed record RoutedSkillExecutionFailed( private sealed record RoutedSkillSubAgentActivity( long TimestampMs, + SubAgentRunId RunId, AgentName AgentName, SubAgentPhase Phase, + string? ActivityPhase, + string? ActivitySummary, int ToolCount, bool? Success, TimeSpan? Duration, diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index c5f5b5b8b..fa065f2f9 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Collections.Frozen; using System.Collections.Concurrent; +using System.Text; using Akka.Actor; using Akka.Event; using Microsoft.Extensions.AI; @@ -257,6 +258,7 @@ public required IReadOnlyList ToolCalls public ToolRunScope RunScope { get; } public required ToolExecutionTimeout DefaultTimeout { get; init; } public required IActorRef ReplyTo { get; init; } + public required Action EmitToolActivityOutput { get; init; } public required Action EmitSubAgentOutput { get; init; } public required ToolApprovalRequests ApprovalRequests { get; init; } public required BackgroundJobDispatch BackgroundJobs { get; init; } @@ -309,6 +311,7 @@ public void Validate() ArgumentNullException.ThrowIfNull(ToolCalls); ArgumentNullException.ThrowIfNull(DefaultTimeout); ArgumentNullException.ThrowIfNull(ReplyTo); + ArgumentNullException.ThrowIfNull(EmitToolActivityOutput); ArgumentNullException.ThrowIfNull(EmitSubAgentOutput); ArgumentNullException.ThrowIfNull(ApprovalRequests); ArgumentNullException.ThrowIfNull(BackgroundJobs); @@ -455,8 +458,38 @@ private async Task ExecuteSingleToolAsync( string resultText; var completedRuns = new List(); var acceptedFindings = new List(); + void EmitToolActivity(ToolActivityUpdate update) + { + batch.EmitToolActivityOutput(new ToolActivityOutput + { + SessionId = batch.SessionId, + TimestampMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + CallId = new ToolCallId(tc.CallId), + ToolName = new ToolName(tc.Name), + TurnId = batch.TurnContext.TurnId, + Phase = SanitizeActivityText(update.Phase) ?? "active", + Summary = SanitizeActivityText(update.OutputChunk) + }); + } + var outputs = new ToolExecutionOutputs(info => { + if (info.IsActivity) + { + batch.EmitSubAgentOutput(new SubAgentOutput + { + SessionId = batch.SessionId, + TimestampMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), + AgentName = new SubAgents.AgentName(info.AgentName), + Phase = Netclaw.Actors.SubAgents.SubAgentPhase.Activity, + RunId = info.RunId, + ParentCallId = new ToolCallId(tc.CallId), + ActivityPhase = SanitizeActivityText(info.ActivityPhase), + ActivitySummary = SanitizeActivityText(info.ActivitySummary) + }); + return; + } + if (info.IsStarted) { batch.EmitSubAgentOutput(new SubAgentOutput @@ -465,6 +498,8 @@ private async Task ExecuteSingleToolAsync( TimestampMs = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), AgentName = new SubAgents.AgentName(info.AgentName), Phase = Netclaw.Actors.SubAgents.SubAgentPhase.Started, + RunId = info.RunId, + ParentCallId = new ToolCallId(tc.CallId), ToolCount = info.ToolCount, Success = info.Success, Duration = info.Duration @@ -486,6 +521,7 @@ private async Task ExecuteSingleToolAsync( completedRuns.Add(new CompletedSubAgentRun { RunId = info.RunId, + ParentCallId = new ToolCallId(tc.CallId), AgentName = new SubAgents.AgentName(info.AgentName), Completion = ChildRunCompletion.FromReportedOutcome( info.Outcome ?? (info.Success ? SubAgentRunOutcome.Completed : SubAgentRunOutcome.Failed), @@ -506,6 +542,7 @@ private async Task ExecuteSingleToolAsync( acceptedFindings.Add(new AcceptedSubAgentFinding { RunId = info.RunId, + ParentCallId = new ToolCallId(tc.CallId), AgentName = new SubAgents.AgentName(info.AgentName), Duration = info.Duration, Shape = finding.Shape, @@ -648,7 +685,7 @@ private async Task ExecuteSingleToolAsync( } resultText = await ExecuteToolAttemptAsync( - _executor, tc, context, timeout, _timeProvider, batch.CancellationToken); + _executor, tc, context, timeout, _timeProvider, EmitToolActivity, batch.CancellationToken); sw.Stop(); } @@ -788,7 +825,7 @@ ToolAgentCorrection.SessionScratchSuggested scratchCorrection } resultText = await ExecuteToolAttemptAsync( - _executor, tc, context, timeout, _timeProvider, batch.CancellationToken); + _executor, tc, context, timeout, _timeProvider, EmitToolActivity, batch.CancellationToken); sw.Stop(); } @@ -899,6 +936,7 @@ private static async Task ExecuteToolAttemptAsync( ToolExecutionContext context, TimeSpan timeout, TimeProvider timeProvider, + Action emitActivity, CancellationToken cancellationToken) { var grantedOneTimeToolName = context.Approval.OneTimeApprovedToolName; @@ -919,7 +957,7 @@ private static async Task ExecuteToolAttemptAsync( // the spawner Ask — the terminal stream item depends on that finally // running.) if (executor.GetLivenessMode(toolCall) == ToolLivenessMode.SelfMonitoring) - return await DrainToCompletionAsync(stream, toolCall.Name, cancellationToken); + return await DrainToCompletionAsync(stream, toolCall.Name, emitActivity, cancellationToken); // Opaque tools are bounded by one wall-clock budget. A TimeProvider-driven // timeout token (no hand-rolled timer, no volatile) cancels the drain when the @@ -929,7 +967,7 @@ private static async Task ExecuteToolAttemptAsync( using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, budgetCts.Token); try { - return await DrainToCompletionAsync(stream, toolCall.Name, linkedCts.Token); + return await DrainToCompletionAsync(stream, toolCall.Name, emitActivity, linkedCts.Token); } catch (OperationCanceledException) when (budgetCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) @@ -960,18 +998,66 @@ private static async Task ExecuteToolAttemptAsync( /// tool-call contract and fails loudly. /// private static async Task DrainToCompletionAsync( - IAsyncEnumerable stream, string toolName, CancellationToken cancellationToken) + IAsyncEnumerable stream, + string toolName, + Action emitActivity, + CancellationToken cancellationToken) { await foreach (var update in stream.WithCancellation(cancellationToken)) { if (update is ToolCompletedUpdate completed) return completed.Result; + + if (update is ToolActivityUpdate activity) + emitActivity(activity); } throw new InvalidOperationException( $"Tool '{toolName}' stream ended without a completion item."); } + private static string? SanitizeActivityText(string? value) + { + const int maxCharacters = 400; + + if (string.IsNullOrWhiteSpace(value)) + return null; + + var builder = new StringBuilder(Math.Min(value.Length, maxCharacters)); + foreach (var character in value) + { + switch (character) + { + case '\n': + builder.Append("\\n"); + break; + case '\r': + builder.Append("\\r"); + break; + case '\t': + builder.Append("\\t"); + break; + case '\x1b': + builder.Append("\\e"); + break; + default: + if (char.IsControl(character)) + builder.Append($"\\u{(int)character:X4}"); + else + builder.Append(character); + break; + } + + if (builder.Length >= maxCharacters) + break; + } + + if (builder.Length > maxCharacters) + builder.Length = maxCharacters; + + return builder.ToString(); + } + private static bool SetsEqual(IReadOnlySet left, IReadOnlySet right) { if (ReferenceEquals(left, right)) diff --git a/src/Netclaw.Actors/Sessions/SessionLogActor.cs b/src/Netclaw.Actors/Sessions/SessionLogActor.cs index cb6105d30..cacc982fc 100644 --- a/src/Netclaw.Actors/Sessions/SessionLogActor.cs +++ b/src/Netclaw.Actors/Sessions/SessionLogActor.cs @@ -195,6 +195,9 @@ private void OnOutput(SessionOutput output) { TextOutput text => $"Assistant: {TextTruncation.EllipsisAppend(text.Text, 1000)}", ToolCallOutput toolCall => FormatToolCall(toolCall), + ToolActivityOutput activity => + $"Tool activity: {activity.ToolName} (call={activity.CallId}, turn={activity.TurnId}) " + + $"phase={activity.Phase} summary={activity.Summary ?? "-"}", ToolResultOutput toolResult => $"Tool result: {toolResult.ToolName} (call={toolResult.CallId}) → {TextTruncation.EllipsisAppend(SecretOutputRedactor.Redact(toolResult.Result), 1000)}", ThinkingOutput thinking => $"Thinking: {TextTruncation.EllipsisAppend(thinking.Text, 1000)}", ThinkingDeltaOutput thinkingDelta => $"Thinking delta: {TextTruncation.EllipsisAppend(thinkingDelta.Delta, 1000)}", @@ -205,9 +208,11 @@ private void OnOutput(SessionOutput output) $"Compaction: {compaction.MessagesBefore} → {compaction.MessagesAfter} messages " + $"(keep={compaction.KeepCountUsed}, context={compaction.PreCompactionInputTokens}/{compaction.ContextWindowTokens} tokens)", SubAgentOutput sa when sa.Phase == SubAgentPhase.Started => - $"SubAgent started: {sa.AgentName} (tools={sa.ToolCount})", + $"SubAgent started: {sa.AgentName} (run={sa.RunId?.Value ?? "-"}, parent={sa.ParentCallId?.Value ?? "-"}, tools={sa.ToolCount})", + SubAgentOutput sa when sa.Phase == SubAgentPhase.Activity => + $"SubAgent activity: {sa.AgentName} (run={sa.RunId?.Value ?? "-"}, parent={sa.ParentCallId?.Value ?? "-"}, phase={sa.ActivityPhase ?? "active"}) {sa.ActivitySummary ?? string.Empty}", SubAgentOutput sa => - $"SubAgent completed: {sa.AgentName} (success={sa.Success}, outcome={sa.Outcome.ToString().ToLowerInvariant()}, reason={sa.OutcomeReason?.Value ?? "-"}, duration={sa.Duration.TotalSeconds:F1}s, findings={sa.FindingsCount}, memory={sa.MemoryDecision ?? "n/a"}{(string.IsNullOrWhiteSpace(sa.MemoryDecisionReason) ? string.Empty : $", memoryReason={sa.MemoryDecisionReason}")})", + $"SubAgent completed: {sa.AgentName} (run={sa.RunId?.Value ?? "-"}, parent={sa.ParentCallId?.Value ?? "-"}, success={sa.Success}, outcome={sa.Outcome.ToString().ToLowerInvariant()}, reason={sa.OutcomeReason?.Value ?? "-"}, duration={sa.Duration.TotalSeconds:F1}s, findings={sa.FindingsCount}, memory={sa.MemoryDecision ?? "n/a"}{(string.IsNullOrWhiteSpace(sa.MemoryDecisionReason) ? string.Empty : $", memoryReason={sa.MemoryDecisionReason}")})", ErrorOutput error => $"Error [{error.Category}] (ref: {error.CorrelationId:N}): {error.Message}", FileOutput file => $"File: {file.FileName} ({file.MimeType})", _ => null diff --git a/src/Netclaw.Actors/Sessions/SessionProtocol.Events.cs b/src/Netclaw.Actors/Sessions/SessionProtocol.Events.cs index 0d1fc0dbe..03697926c 100644 --- a/src/Netclaw.Actors/Sessions/SessionProtocol.Events.cs +++ b/src/Netclaw.Actors/Sessions/SessionProtocol.Events.cs @@ -49,6 +49,12 @@ public sealed record TurnRecorded : ISessionEvent /// public BackgroundJobId? SourceBackgroundJobId { get; init; } + /// + /// Settled structured entries for this turn. Empty for legacy records. + /// + public IReadOnlyList TranscriptEntries { get; init; } = + Array.Empty(); + public DateTimeOffset RecordedAt => DateTimeOffset.FromUnixTimeMilliseconds(RecordedAtMs); public DateTimeOffset Timestamp => RecordedAt; diff --git a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs index 8fa0e4612..b8466fbd1 100644 --- a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs +++ b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs @@ -90,6 +90,23 @@ public sealed record ToolResultOutput : SessionOutput public required string Result { get; init; } } + /// + /// A nonterminal tool update with stable call and turn correlation. + /// Requires . + /// + public sealed record ToolActivityOutput : SessionOutput + { + public required ToolCallId CallId { get; init; } + + public required ToolName ToolName { get; init; } + + public required Protocol.TurnId TurnId { get; init; } + + public required string Phase { get; init; } + + public string? Summary { get; init; } + } + /// /// Token usage report for the completed turn. /// Requires . @@ -253,6 +270,18 @@ public sealed record SubAgentOutput : SessionOutput public required SubAgents.AgentName AgentName { get; init; } public required SubAgents.SubAgentPhase Phase { get; init; } + /// Stable identity for one sub-agent run. + public SubAgentRunId? RunId { get; init; } + + /// Tool call that owns this run. Null for a routed skill run. + public ToolCallId? ParentCallId { get; init; } + + /// Safe activity phase for . + public string? ActivityPhase { get; init; } + + /// Safe activity summary for . + public string? ActivitySummary { get; init; } + /// Number of tools available to the subagent (on Started). public int ToolCount { get; init; } diff --git a/src/Netclaw.Actors/Sessions/SessionState.cs b/src/Netclaw.Actors/Sessions/SessionState.cs index d45a27eaa..8487439ea 100644 --- a/src/Netclaw.Actors/Sessions/SessionState.cs +++ b/src/Netclaw.Actors/Sessions/SessionState.cs @@ -46,6 +46,9 @@ public sealed record AdoptedContextAuditMessage( public ImmutableList History { get; init; } = []; + public ImmutableList RecentTranscript { get; init; } = + []; + public int TurnCount { get; init; } public string? Title { get; init; } @@ -103,7 +106,7 @@ public SessionState Apply(TurnRecorded evt) // Background-job dedup/remove/prune is delegated to the single shared // helper so the replay path here and the live turn-completion path in // LlmSessionActor cannot drift. - return (this with + return (AppendTranscript(evt) with { History = History.Add(evt.UserMessage).Add(evt.AssistantReply), TurnCount = TurnCount + 1, @@ -111,6 +114,46 @@ public SessionState Apply(TurnRecorded evt) }).CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); } + public SessionState AppendTranscript(TurnRecorded evt) + { + var entries = evt.TranscriptEntries.Count > 0 + ? evt.TranscriptEntries + : SessionTranscriptExtractor.Extract( + [evt.UserMessage, evt.AssistantReply], + timestampMs: evt.RecordedAtMs); + + return this with { RecentTranscript = RecentTranscript.AddRange(entries) }; + } + + public SessionState KeepRecentTranscriptTurns(int maximumTurnCount) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumTurnCount); + + var turnCount = 0; + var oldestKeptTurnIndex = 0; + for (var index = RecentTranscript.Count - 1; index >= 0; index--) + { + if (RecentTranscript[index].Type != SessionTranscriptEntryTypes.User) + continue; + + turnCount++; + if (turnCount <= maximumTurnCount) + { + oldestKeptTurnIndex = index; + continue; + } + + return this with + { + RecentTranscript = RecentTranscript.GetRange( + oldestKeptTurnIndex, + RecentTranscript.Count - oldestKeptTurnIndex) + }; + } + + return this; + } + /// /// Single source of truth for per-turn background-job bookkeeping, shared by /// the replay path () and the live @@ -432,6 +475,7 @@ public SessionSnapshot ToSnapshot() return new SessionSnapshot { History = new List(History), + RecentTranscript = [.. RecentTranscript], TurnCount = TurnCount, Title = Title, WorkingContext = WorkingContext.IsEmpty ? null : WorkingContext, @@ -489,9 +533,14 @@ [.. record.Messages message.AuthorityAtInclusion))])) : []; + var recentTranscript = snapshot.RecentTranscript.Count > 0 + ? snapshot.RecentTranscript + : SessionTranscriptExtractor.Extract(snapshot.History); + return new SessionState { History = ImmutableList.CreateRange(snapshot.History), + RecentTranscript = ImmutableList.CreateRange(recentTranscript), TurnCount = snapshot.TurnCount, Title = snapshot.Title, WorkingContext = snapshot.WorkingContext ?? WorkingContext.Empty, diff --git a/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs b/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs new file mode 100644 index 000000000..a2728a05f --- /dev/null +++ b/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs @@ -0,0 +1,93 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; +using static Netclaw.Actors.Sessions.SessionProtocol; + +namespace Netclaw.Actors.Sessions; + +internal static class SessionTranscriptEntryFactory +{ + public static SessionTranscriptEntry Tool( + ToolCallOutput? call, + ToolResultOutput result, + string? turnId) => new() + { + Type = SessionTranscriptEntryTypes.Tool, + TurnId = turnId, + TimestampMs = result.TimestampMs, + CallId = result.CallId.Value, + ToolName = result.ToolName.Value, + ArgumentsJson = call?.ArgumentsJson, + Result = result.Result + }; + + public static SessionTranscriptEntry SubAgent(SubAgentOutput output, string? turnId) => new() + { + Type = SessionTranscriptEntryTypes.SubAgent, + TurnId = turnId, + TimestampMs = output.TimestampMs, + RunId = output.RunId?.Value, + ParentCallId = output.ParentCallId?.Value, + AgentName = output.AgentName.Value, + Outcome = output.Outcome.ToString().ToLowerInvariant(), + OutcomeReason = output.OutcomeReason?.Value, + DurationMs = output.Duration.TotalMilliseconds, + FindingsCount = output.FindingsCount, + MemoryDecision = output.MemoryDecision, + MemoryDecisionReason = output.MemoryDecisionReason + }; + + public static SessionTranscriptEntry File(FileOutput output, string? turnId) => new() + { + Type = SessionTranscriptEntryTypes.File, + TurnId = turnId, + TimestampMs = output.TimestampMs, + FilePath = output.FilePath, + FileName = output.FileName, + MimeType = output.MimeType.Value + }; + + public static SessionTranscriptEntry Error(ErrorOutput output, string? turnId) => new() + { + Type = SessionTranscriptEntryTypes.Error, + TurnId = turnId, + TimestampMs = output.TimestampMs, + ErrorMessage = output.Message, + ErrorDetail = output.Cause?.ToString(), + ErrorCorrelationId = output.CorrelationId.ToString("D"), + ErrorCategory = output.Category.ToString() + }; + + public static SessionTranscriptEntry Usage(UsageOutput output, string? turnId) => new() + { + Type = SessionTranscriptEntryTypes.Usage, + TurnId = turnId, + TimestampMs = output.TimestampMs, + InputTokens = output.InputTokens, + OutputTokens = output.OutputTokens, + TotalTokens = output.TotalTokens, + CachedInputTokens = output.CachedInputTokens, + ReasoningTokens = output.ReasoningTokens, + ContextWindowTokens = output.ContextWindowTokens, + UsagePercent = output.UsagePercent, + PromptMs = output.PromptMs, + PredictedPerSecond = output.PredictedPerSecond + }; + + public static SessionTranscriptEntry Compaction(CompactionOutput output, string? turnId) => new() + { + Type = SessionTranscriptEntryTypes.Compaction, + TurnId = turnId, + TimestampMs = output.TimestampMs, + MessagesBefore = output.MessagesBefore, + MessagesAfter = output.MessagesAfter, + ToolResultsCleared = output.ToolResultsCleared, + Summarized = output.Summarized, + ContextWindowTokens = output.ContextWindowTokens, + PreCompactionInputTokens = output.PreCompactionInputTokens, + KeepCountUsed = output.KeepCountUsed + }; +} diff --git a/src/Netclaw.Actors/Sessions/SessionTranscriptExtractor.cs b/src/Netclaw.Actors/Sessions/SessionTranscriptExtractor.cs new file mode 100644 index 000000000..261f81994 --- /dev/null +++ b/src/Netclaw.Actors/Sessions/SessionTranscriptExtractor.cs @@ -0,0 +1,118 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; + +namespace Netclaw.Actors.Sessions; + +internal static class SessionTranscriptExtractor +{ + public static IReadOnlyList ExtractTurn( + IReadOnlyList history, + SerializableChatMessage userMessage, + SerializableChatMessage assistantReply, + string? turnId, + long timestampMs) + { + var startIndex = -1; + for (var index = history.Count - 1; index >= 0; index--) + { + if (history[index] != userMessage) + continue; + + startIndex = index; + break; + } + + var messages = startIndex >= 0 + ? history.Skip(startIndex).ToList() + : [userMessage]; + messages.Add(assistantReply); + + return Extract(messages, turnId, timestampMs); + } + + public static IReadOnlyList Extract( + IEnumerable history, + string? turnId = null, + long timestampMs = 0) + { + var entries = new List(); + var calls = new Dictionary(StringComparer.Ordinal); + + foreach (var message in history) + { + foreach (var call in message.ToolCalls) + calls[call.CallId.Value] = call; + + switch (message.Role) + { + case ChatRole.User when !string.IsNullOrWhiteSpace(message.Content): + entries.Add(new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.User, + TurnId = turnId, + TimestampMs = timestampMs, + Role = "user", + Text = message.Content + }); + break; + case ChatRole.Assistant when !string.IsNullOrWhiteSpace(message.Content): + entries.Add(new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Assistant, + TurnId = turnId, + TimestampMs = timestampMs, + Role = "assistant", + Text = message.Content + }); + break; + case ChatRole.Assistant when message.ToolCalls.Count > 0: + break; + case ChatRole.Tool when message.ToolCallId is { } callId: + calls.TryGetValue(callId.Value, out var call); + entries.Add(new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Tool, + TurnId = turnId, + TimestampMs = timestampMs, + CallId = callId.Value, + ToolName = message.Name ?? call?.Name.Value ?? "unknown", + ArgumentsJson = call?.ArgumentsJson, + Result = message.Content + }); + calls.Remove(callId.Value); + break; + case ChatRole.System: + break; + default: + entries.Add(new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Diagnostic, + TurnId = turnId, + TimestampMs = timestampMs, + Text = $"Legacy transcript detail for role '{message.Role}' is not supported." + }); + break; + } + } + + foreach (var call in calls.Values) + { + entries.Add(new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Diagnostic, + TurnId = turnId, + TimestampMs = timestampMs, + CallId = call.CallId.Value, + ToolName = call.Name.Value, + ArgumentsJson = call.ArgumentsJson, + Text = "Legacy tool call has no settled result." + }); + } + + return entries; + } +} diff --git a/src/Netclaw.Actors/SubAgents/SubAgentNotification.cs b/src/Netclaw.Actors/SubAgents/SubAgentNotification.cs index 9513ef1cf..e66f285c2 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentNotification.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentNotification.cs @@ -10,8 +10,9 @@ namespace Netclaw.Actors.SubAgents; /// public enum SubAgentPhase { - Started, - Completed + Started = 0, + Completed = 1, + Activity = 2 } /// diff --git a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs index 452f03c5e..9a1ee6341 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs @@ -288,7 +288,13 @@ public async Task SpawnAsync( // through its own session-correlated logs regardless. Streaming // spawn_agent calls pass a real sink so the parent tool's // liveness watchdog sees progress. - ActivitySink = activitySink + ActivitySink = activitySink is null + ? null + : new SubAgentActivityWriter( + activitySink, + context.Outputs, + runId, + definition.Name.Value) }, // No Ask timeout: a healthy run is bounded by the sub-agent's own // watchdogs, not by wall-clock, so any finite ceiling here could @@ -509,4 +515,33 @@ private static string AppendSystemPromptOverlay(string basePrompt, string? overl // operator's quality workflow aligned without exposing SOUL.md or TOOLING.md. return _promptProvider.GetOperatingRules(context.Audience); } + + private sealed class SubAgentActivityWriter( + ChannelWriter inner, + ToolExecutionOutputs outputs, + SubAgentRunId runId, + string agentName) : ChannelWriter + { + public override bool TryComplete(Exception? error = null) => inner.TryComplete(error); + + public override bool TryWrite(ToolActivityUpdate item) + { + if (!inner.TryWrite(item)) + return false; + + outputs.ReportSubAgentActivity(new SubAgentNotificationInfo + { + RunId = runId, + AgentName = agentName, + IsStarted = false, + IsActivity = true, + ActivityPhase = item.Phase, + ActivitySummary = item.OutputChunk + }); + return true; + } + + public override ValueTask WaitToWriteAsync(CancellationToken cancellationToken = default) + => inner.WaitToWriteAsync(cancellationToken); + } } diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs index 2bf5926ee..269d96aa9 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs @@ -4,8 +4,12 @@ // // ----------------------------------------------------------------------- using Netclaw.Actors.Protocol; +using Netclaw.Actors.Reminders; using Netclaw.Actors.SubAgents; using Netclaw.Cli.Daemon; +using Netclaw.Configuration; +using Netclaw.Media; +using Netclaw.Security; using Netclaw.Tools; using Xunit; using static Netclaw.Actors.Sessions.SessionProtocol; @@ -74,6 +78,98 @@ public void FromDto_maps_tool_result_output() Assert.Equal("ok", result.Result); } + [Fact] + public void ToolActivityOutput_roundtrips_all_correlation_fields() + { + var original = new ToolActivityOutput + { + SessionId = new SessionId("signalr/test"), + TimestampMs = 124, + CallId = new ToolCallId("call-activity"), + ToolName = new ToolName("shell_execute"), + TurnId = new TurnId("turn-7"), + Phase = "stdout", + Summary = "tests pass" + }; + + var dto = SessionOutputDtoMapper.ToDto(original); + var roundTripped = DaemonClient.FromDto(dto); + + Assert.Equal(SessionOutputTypes.ToolActivity, dto.Type); + Assert.Equal("turn-7", dto.TurnId); + var result = Assert.IsType(roundTripped); + Assert.Equal("call-activity", result.CallId.Value); + Assert.Equal("shell_execute", result.ToolName.Value); + Assert.Equal("turn-7", result.TurnId.Value); + Assert.Equal("stdout", result.Phase); + Assert.Equal("tests pass", result.Summary); + } + + [Fact] + public void UsageOutput_roundtrips_complete_provider_detail() + { + var original = new UsageOutput + { + SessionId = new SessionId("signalr/test"), + TimestampMs = 130, + InputTokens = 1000, + OutputTokens = 200, + TotalTokens = 1200, + CachedInputTokens = 400, + ReasoningTokens = 80, + ContextWindowTokens = 128000, + UsagePercent = 0.125, + PromptMs = 22.5, + PredictedPerSecond = 41.2 + }; + + var result = Assert.IsType( + DaemonClient.FromDto(SessionOutputDtoMapper.ToDto(original))); + + Assert.Equal(original.InputTokens, result.InputTokens); + Assert.Equal(original.OutputTokens, result.OutputTokens); + Assert.Equal(original.TotalTokens, result.TotalTokens); + Assert.Equal(original.CachedInputTokens, result.CachedInputTokens); + Assert.Equal(original.ReasoningTokens, result.ReasoningTokens); + Assert.Equal(original.ContextWindowTokens, result.ContextWindowTokens); + Assert.Equal(original.UsagePercent, result.UsagePercent); + Assert.Equal(original.PromptMs, result.PromptMs); + Assert.Equal(original.PredictedPerSecond, result.PredictedPerSecond); + } + + [Fact] + public void File_and_turn_outputs_roundtrip_complete_detail() + { + var file = new FileOutput + { + SessionId = new SessionId("signalr/test"), + TimestampMs = 140, + FilePath = "/work/report.md", + FileName = "report.md", + MimeType = new MimeType("text/markdown") + }; + var turn = new TurnCompleted + { + SessionId = new SessionId("signalr/test"), + TimestampMs = 141, + TurnNumber = new TurnNumber(8), + Outcome = TurnOutcome.Failed, + SourceReminderId = new ReminderId("daily:1") + }; + + var fileResult = Assert.IsType( + DaemonClient.FromDto(SessionOutputDtoMapper.ToDto(file))); + var turnResult = Assert.IsType( + DaemonClient.FromDto(SessionOutputDtoMapper.ToDto(turn))); + + Assert.Equal(file.FilePath, fileResult.FilePath); + Assert.Equal(file.FileName, fileResult.FileName); + Assert.Equal(file.MimeType, fileResult.MimeType); + Assert.Equal(turn.TurnNumber, turnResult.TurnNumber); + Assert.Equal(turn.Outcome, turnResult.Outcome); + Assert.Equal(turn.SourceReminderId, turnResult.SourceReminderId); + } + [Fact] public void FromDto_unknown_type_becomes_error_output() { @@ -105,6 +201,17 @@ public void FromDto_maps_session_joined_with_recent_messages() [ new ChatMessageDto("user", "Hello"), new ChatMessageDto("assistant", "Hi there!") + ], + RecentTranscript = + [ + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Tool, + TurnId = "turn-1", + CallId = "call-1", + ToolName = "status", + Result = "healthy" + } ] }; @@ -120,6 +227,9 @@ public void FromDto_maps_session_joined_with_recent_messages() Assert.Equal("Hello", joined.RecentMessages[0].Content); Assert.Equal("assistant", joined.RecentMessages[1].Role); Assert.Equal("Hi there!", joined.RecentMessages[1].Content); + var tool = Assert.Single(joined.RecentTranscript!); + Assert.Equal("call-1", tool.CallId); + Assert.Equal("healthy", tool.Result); } [Fact] @@ -142,6 +252,34 @@ public void FromDto_maps_session_joined_without_recent_messages() Assert.Null(joined.Title); Assert.Equal(0, joined.TurnCount); Assert.Null(joined.RecentMessages); + Assert.Null(joined.RecentTranscript); + } + + [Fact] + public void SessionJoined_roundtrips_both_resume_shapes() + { + var original = new SessionJoined + { + SessionId = new SessionId("signalr/test"), + TimestampMs = 101, + TurnCount = 1, + RecentMessages = [new ChatMessageDto("user", "Hello")], + RecentTranscript = + [ + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.User, + TurnId = "turn-1", + Text = "Hello" + } + ] + }; + + var dto = SessionOutputDtoMapper.ToDto(original); + var result = Assert.IsType(DaemonClient.FromDto(dto)); + + Assert.Single(result.RecentMessages!); + Assert.Equal(original.RecentTranscript, result.RecentTranscript); } [Fact] @@ -153,6 +291,8 @@ public void SubAgentOutput_roundtrips_through_dto_started() TimestampMs = 500, AgentName = new AgentName("memory-curator"), Phase = SubAgentPhase.Started, + RunId = new SubAgentRunId("run-started"), + ParentCallId = new ToolCallId("call-parent"), ToolCount = 5 }; @@ -161,6 +301,8 @@ public void SubAgentOutput_roundtrips_through_dto_started() Assert.Equal("memory-curator", dto.AgentName); Assert.Equal("started", dto.Phase); Assert.Equal(5, dto.ToolCountSub); + Assert.Equal("run-started", dto.RunId); + Assert.Equal("call-parent", dto.ParentCallId); Assert.Null(dto.SubAgentOutcome); Assert.Null(dto.SubAgentOutcomeReason); Assert.Null(dto.MemoryDecision); @@ -170,6 +312,35 @@ public void SubAgentOutput_roundtrips_through_dto_started() Assert.Equal("memory-curator", result.AgentName.Value); Assert.Equal(SubAgentPhase.Started, result.Phase); Assert.Equal(5, result.ToolCount); + Assert.Equal("run-started", result.RunId?.Value); + Assert.Equal("call-parent", result.ParentCallId?.Value); + } + + [Fact] + public void SubAgentOutput_roundtrips_activity_with_stable_identity() + { + var original = new SubAgentOutput + { + SessionId = new SessionId("signalr/test"), + TimestampMs = 550, + AgentName = new AgentName("test-diagnostics"), + Phase = SubAgentPhase.Activity, + RunId = new SubAgentRunId("run-activity"), + ParentCallId = new ToolCallId("call-parent"), + ActivityPhase = "running tools", + ActivitySummary = "dotnet test" + }; + + var dto = SessionOutputDtoMapper.ToDto(original); + var roundTripped = DaemonClient.FromDto(dto); + + Assert.Equal("activity", dto.Phase); + var result = Assert.IsType(roundTripped); + Assert.Equal(SubAgentPhase.Activity, result.Phase); + Assert.Equal("run-activity", result.RunId?.Value); + Assert.Equal("call-parent", result.ParentCallId?.Value); + Assert.Equal("running tools", result.ActivityPhase); + Assert.Equal("dotnet test", result.ActivitySummary); } [Fact] @@ -181,6 +352,8 @@ public void SubAgentOutput_roundtrips_through_dto_completed() TimestampMs = 600, AgentName = new AgentName("memory-retriever"), Phase = SubAgentPhase.Completed, + RunId = new SubAgentRunId("run-completed"), + ParentCallId = new ToolCallId("call-parent"), Success = true, Outcome = SubAgentRunOutcome.Partial, OutcomeReason = SubAgentOutcomeReason.ToolIterationBudgetExhausted, @@ -211,6 +384,39 @@ public void SubAgentOutput_roundtrips_through_dto_completed() Assert.Equal(12300, result.Duration.TotalMilliseconds, 1); Assert.Equal("accepted", result.MemoryDecision); Assert.Equal(2, result.FindingsCount); + Assert.Equal("run-completed", result.RunId?.Value); + Assert.Equal("call-parent", result.ParentCallId?.Value); + } + + [Fact] + public void CompactionOutput_roundtrips_complete_detail() + { + var original = new CompactionOutput + { + SessionId = new SessionId("signalr/test"), + TimestampMs = 700, + MessagesBefore = 40, + MessagesAfter = 8, + ToolResultsCleared = true, + Summarized = true, + ContextWindowTokens = 128000, + PreCompactionInputTokens = 97000, + KeepCountUsed = 6 + }; + + var dto = SessionOutputDtoMapper.ToDto(original); + var roundTripped = DaemonClient.FromDto(dto); + + Assert.True(dto.ToolResultsCleared); + Assert.True(dto.Summarized); + var result = Assert.IsType(roundTripped); + Assert.Equal(40, result.MessagesBefore); + Assert.Equal(8, result.MessagesAfter); + Assert.True(result.ToolResultsCleared); + Assert.True(result.Summarized); + Assert.Equal(128000, result.ContextWindowTokens); + Assert.Equal(97000, result.PreCompactionInputTokens); + Assert.Equal(6, result.KeepCountUsed); } [Theory] @@ -227,7 +433,8 @@ public void ErrorOutput_roundtrips_correlation_id_and_category_through_dto(Error TimestampMs = 100, Message = "Something went wrong.", CorrelationId = correlationId, - Category = category + Category = category, + Cause = new InvalidOperationException("provider detail") }; var dto = SessionOutputDtoMapper.ToDto(original); @@ -241,6 +448,7 @@ public void ErrorOutput_roundtrips_correlation_id_and_category_through_dto(Error Assert.Equal(correlationId, result.CorrelationId); Assert.Equal(category, result.Category); Assert.Equal("Something went wrong.", result.Message); + Assert.Contains("provider detail", result.Cause?.Message); } [Fact] @@ -298,11 +506,14 @@ public void ToolInteractionRequest_roundtrips_through_dto() ToolName = new Netclaw.Tools.ToolName("shell_execute"), DisplayText = "git push origin main", RequesterSenderId = new SenderId("device-1"), + RequesterPrincipal = PrincipalClassification.Operator, HasAdoptedContext = true, HasThirdPartyAdoptedContext = true, AdoptedSpeakerIds = ["device-1", "device-2"], Patterns = ["git push"], CandidateVerbs = ["git push"], + Candidates = [new ApprovalCandidate("git push", "/work/netclaw")], + PersistedAdoptedContext = true, Options = [ new ToolInteractionOption(ApprovalOptionKeys.ApproveOnceKey, ApprovalOptionKeys.ApproveOnceLabel), @@ -318,10 +529,13 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("approval", dto.InteractionKind); Assert.Equal("git push origin main", dto.InteractionDisplayText); Assert.Equal("device-1", dto.RequesterSenderId); + Assert.Equal(nameof(PrincipalClassification.Operator), dto.InteractionRequesterPrincipal); Assert.True(dto.InteractionHasAdoptedContext); Assert.True(dto.InteractionHasThirdPartyAdoptedContext); Assert.Equal(["device-1", "device-2"], dto.InteractionAdoptedSpeakerIds); Assert.Equal(["git push"], dto.InteractionCandidateVerbs); + Assert.Equal([new ApprovalCandidate("git push", "/work/netclaw")], dto.InteractionCandidates); + Assert.True(dto.InteractionPersistedAdoptedContext); Assert.Equal(5, dto.InteractionOptions!.Count); var roundTripped = DaemonClient.FromDto(dto); @@ -330,11 +544,14 @@ public void ToolInteractionRequest_roundtrips_through_dto() Assert.Equal("shell_execute", result.ToolName.Value); Assert.Equal("git push origin main", result.DisplayText); Assert.Equal("device-1", result.RequesterSenderId?.Value); + Assert.Equal(PrincipalClassification.Operator, result.RequesterPrincipal); Assert.True(result.HasAdoptedContext); Assert.True(result.HasThirdPartyAdoptedContext); Assert.Equal(["device-1", "device-2"], result.AdoptedSpeakerIds); Assert.Equal(["git push"], result.Patterns); Assert.Equal(["git push"], result.CandidateVerbs); + Assert.Equal([new ApprovalCandidate("git push", "/work/netclaw")], result.Candidates); + Assert.True(result.PersistedAdoptedContext); Assert.Equal(5, result.Options.Count); } @@ -357,6 +574,46 @@ public void ErrorOutput_defaults_to_unknown_category_when_dto_field_missing() Assert.NotEqual(Guid.Empty, error.CorrelationId); } + [Fact] + public void Old_wire_payloads_keep_defaults_for_additive_activity_fields() + { + const string subAgentJson = """ + {"Type":"subagent","SessionId":"signalr/test","TimestampMs":10,"AgentName":"legacy","Phase":"started","ToolCountSub":2} + """; + const string compactionJson = """ + {"Type":"compaction","SessionId":"signalr/test","TimestampMs":11,"MessagesBefore":9,"MessagesAfter":3} + """; + const string interactionJson = """ + {"Type":"tool_interaction","SessionId":"signalr/test","TimestampMs":12,"InteractionKind":"approval","CallId":"call-1","ToolName":"shell_execute","InteractionDisplayText":"git status","InteractionOptions":[]} + """; + const string joinedJson = """ + {"Type":"session_joined","SessionId":"signalr/test","TimestampMs":13,"TurnCount":1,"RecentMessages":[{"Role":"user","Content":"Hello"}]} + """; + + var subAgentDto = System.Text.Json.JsonSerializer.Deserialize(subAgentJson)!; + var compactionDto = System.Text.Json.JsonSerializer.Deserialize(compactionJson)!; + var interactionDto = System.Text.Json.JsonSerializer.Deserialize(interactionJson)!; + var joinedDto = System.Text.Json.JsonSerializer.Deserialize(joinedJson)!; + + var subAgent = Assert.IsType(DaemonClient.FromDto(subAgentDto)); + Assert.Null(subAgent.RunId); + Assert.Null(subAgent.ParentCallId); + Assert.Null(subAgent.ActivityPhase); + + var compaction = Assert.IsType(DaemonClient.FromDto(compactionDto)); + Assert.False(compaction.ToolResultsCleared); + Assert.False(compaction.Summarized); + + var interaction = Assert.IsType(DaemonClient.FromDto(interactionDto)); + Assert.Null(interaction.RequesterPrincipal); + Assert.Empty(interaction.Candidates); + Assert.False(interaction.PersistedAdoptedContext); + + var joined = Assert.IsType(DaemonClient.FromDto(joinedDto)); + Assert.Single(joined.RecentMessages!); + Assert.Null(joined.RecentTranscript); + } + [Fact] public void SessionOutputDto_turn_number_serializes_as_bare_json_integer() { diff --git a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs new file mode 100644 index 000000000..d6e64d99d --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs @@ -0,0 +1,217 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; +using Netclaw.Actors.SubAgents; +using Netclaw.Cli.Tui; +using Netclaw.Tools; +using Xunit; +using static Netclaw.Actors.Sessions.SessionProtocol; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class ChatPresentationReducerTests +{ + private static readonly SessionId SessionId = new("test/chat"); + + [Fact] + public void Parallel_tool_results_update_only_their_call_rows() + { + var state = ChatPresentationState.Empty; + state = Apply(state, ToolCall("call-a", "search", 1)); + state = Apply(state, ToolCall("call-b", "search", 2)); + + var second = ChatPresentationReducer.Reduce(state, ToolResult("call-b", "result-b", 3)); + + Assert.True(second.State.Tools.ContainsKey("call-a")); + Assert.False(second.State.Tools.ContainsKey("call-b")); + var secondBlock = Assert.Single(second.Effects.OfType()).Block; + Assert.Equal("tool:call-b", secondBlock.Key); + Assert.Contains("result-b", secondBlock.SemanticText, StringComparison.Ordinal); + + var first = ChatPresentationReducer.Reduce(second.State, ToolResult("call-a", "result-a", 4)); + + Assert.Empty(first.State.Tools); + Assert.Equal(["tool:call-b", "tool:call-a"], + first.State.Transcript.Select(block => block.Key).ToArray()); + } + + [Fact] + public void Parallel_same_name_subagents_keep_distinct_run_rows() + { + var state = ChatPresentationState.Empty; + state = Apply(state, SubAgent("run-a", SubAgentPhase.Started, 1)); + state = Apply(state, SubAgent("run-b", SubAgentPhase.Started, 2)); + state = Apply(state, SubAgent("run-b", SubAgentPhase.Activity, 3, "reading")); + + Assert.Equal(2, state.SubAgents.Count); + Assert.Equal("reading", state.SubAgents["run-b"].Phase); + Assert.Equal("started", state.SubAgents["run-a"].Phase); + + state = Apply(state, SubAgent("run-a", SubAgentPhase.Completed, 4)); + + Assert.False(state.SubAgents.ContainsKey("run-a")); + Assert.True(state.SubAgents.ContainsKey("run-b")); + Assert.Contains(state.Transcript, block => block.Key == "subagent:run-a"); + } + + [Fact] + public void Transient_thought_and_tool_activity_stay_out_of_settled_transcript() + { + var state = Apply(ChatPresentationState.Empty, new ThinkingDeltaOutput("private step") + { + SessionId = SessionId, + TimestampMs = 1 + }); + state = Apply(state, ToolCall("call-a", "search", 2)); + state = Apply(state, new ToolActivityOutput + { + SessionId = SessionId, + TimestampMs = 3, + CallId = new ToolCallId("call-a"), + ToolName = new ToolName("search"), + TurnId = new TurnId("turn-1"), + Phase = "running", + Summary = "query 1" + }); + + Assert.Empty(state.Transcript); + Assert.Equal("private step", state.ThoughtText); + Assert.Equal("running", state.Tools["call-a"].Phase); + + state = Apply(state, new ThinkingOutput("short reason") + { + SessionId = SessionId, + TimestampMs = 4 + }); + + var thought = Assert.Single(state.Transcript); + Assert.Equal(ChatBlockKind.Thought, thought.Kind); + Assert.Equal("short reason", thought.Detail); + } + + [Fact] + public void Usage_block_shows_reasoning_tokens_and_keeps_complete_detail() + { + var state = Apply(ChatPresentationState.Empty, new UsageOutput + { + SessionId = SessionId, + TimestampMs = 10, + InputTokens = 100, + OutputTokens = 20, + CachedInputTokens = 40, + ReasoningTokens = 12, + ContextWindowTokens = 1000, + UsagePercent = 0.1, + PromptMs = 18, + PredictedPerSecond = 55 + }); + + var usage = Assert.Single(state.Transcript); + Assert.Contains("12 thought", usage.Summary, StringComparison.Ordinal); + Assert.Contains("Cached input tokens: 40", usage.Detail, StringComparison.Ordinal); + Assert.Contains("Speed: 55.0 tokens/s", usage.Detail, StringComparison.Ordinal); + } + + [Fact] + public void Session_resume_prefers_structured_transcript_over_legacy_messages() + { + var state = Apply(ChatPresentationState.Empty, new SessionJoined + { + SessionId = SessionId, + TimestampMs = 10, + TurnCount = 1, + RecentMessages = [new ChatMessageDto("assistant", "legacy text")], + RecentTranscript = + [ + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Tool, + CallId = "call-1", + ToolName = "status", + Result = "healthy" + } + ] + }); + + Assert.DoesNotContain(state.Transcript, block => block.Summary == "legacy text"); + Assert.Contains(state.Transcript, block => + block.Kind == ChatBlockKind.Tool && block.SemanticText.Contains("healthy", StringComparison.Ordinal)); + } + + [Fact] + public void Unsupported_output_commits_a_visible_diagnostic() + { + var state = Apply(ChatPresentationState.Empty, new UnknownOutput + { + SessionId = SessionId, + TimestampMs = 9 + }); + + var diagnostic = Assert.Single(state.Transcript); + Assert.Equal(ChatBlockKind.Diagnostic, diagnostic.Kind); + Assert.Contains(nameof(UnknownOutput), diagnostic.Summary, StringComparison.Ordinal); + } + + [Fact] + public void Failed_turn_settles_incomplete_activity_as_diagnostics() + { + var state = Apply(ChatPresentationState.Empty, ToolCall("call-a", "search", 1)); + state = Apply(state, SubAgent("run-a", SubAgentPhase.Started, 2)); + + state = Apply(state, new TurnCompleted + { + SessionId = SessionId, + TimestampMs = 3, + TurnNumber = new TurnNumber(1), + Outcome = TurnOutcome.Failed + }); + + Assert.Empty(state.Tools); + Assert.Empty(state.SubAgents); + Assert.Equal(2, state.Transcript.Count(block => block.Kind == ChatBlockKind.Diagnostic)); + } + + private static ChatPresentationState Apply(ChatPresentationState state, SessionOutput output) => + ChatPresentationReducer.Reduce(state, output).State; + + private static ToolCallOutput ToolCall(string callId, string name, long timestamp) => new() + { + SessionId = SessionId, + TimestampMs = timestamp, + CallId = new ToolCallId(callId), + ToolName = new ToolName(name), + ArgumentsJson = $"{{\"call\":\"{callId}\"}}" + }; + + private static ToolResultOutput ToolResult(string callId, string result, long timestamp) => new() + { + SessionId = SessionId, + TimestampMs = timestamp, + CallId = new ToolCallId(callId), + ToolName = new ToolName("search"), + Result = result + }; + + private static SubAgentOutput SubAgent( + string runId, + SubAgentPhase phase, + long timestamp, + string? activityPhase = null) => new() + { + SessionId = SessionId, + TimestampMs = timestamp, + AgentName = new AgentName("reviewer"), + Phase = phase, + RunId = new SubAgentRunId(runId), + ParentCallId = new ToolCallId("parent"), + ActivityPhase = activityPhase, + Success = true, + Outcome = SubAgentRunOutcome.Completed, + Duration = TimeSpan.FromSeconds(2) + }; + + private sealed record UnknownOutput : SessionOutput; +} diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs new file mode 100644 index 000000000..c4062eb0e --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -0,0 +1,755 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Threading.Channels; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Netclaw.Actors.Protocol; +using Netclaw.Actors.SubAgents; +using Netclaw.Cli.Daemon; +using Netclaw.Cli.Tui; +using Netclaw.Configuration; +using Netclaw.Tools; +using Termina; +using Termina.Clipboard; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; +using static Netclaw.Actors.Sessions.SessionProtocol; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class InlineChatPageTests +{ + private static readonly SessionId SessionId = new("test/chat"); + + [Fact] + public async Task ShiftEnter_AddsNewline_AndEnterSubmitsExactText() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + + harness.Input.EnqueueString("first line"); + harness.Input.EnqueueKey(ConsoleKey.Enter, shift: true); + harness.Input.EnqueueString("second line"); + harness.Input.EnqueueKey(ConsoleKey.Enter); + + var submitted = await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token); + + Assert.Equal("first line\nsecond line", submitted); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("second line")); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task HistoryDown_RestoresTheSavedDraft() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + + harness.Input.EnqueueString("previous prompt"); + harness.Input.EnqueueKey(ConsoleKey.Enter); + Assert.Equal("previous prompt", + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + + harness.Input.EnqueueString("saved draft"); + harness.Input.EnqueueKey(ConsoleKey.UpArrow); + harness.Input.EnqueueKey(ConsoleKey.DownArrow); + harness.Input.EnqueueKey(ConsoleKey.Enter); + + Assert.Equal("saved draft", + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task DoubleEscape_ClearsRecalledInput() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + + harness.Input.EnqueueString("previous prompt"); + harness.Input.EnqueueKey(ConsoleKey.Enter); + _ = await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token); + harness.Input.EnqueueKey(ConsoleKey.UpArrow); + harness.Input.EnqueueKey(ConsoleKey.Escape); + harness.Input.EnqueueKey(ConsoleKey.Escape); + harness.Input.EnqueueString("replacement"); + harness.Input.EnqueueKey(ConsoleKey.Enter); + + Assert.Equal("replacement", + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task OneEscape_DoesNotClearInput() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + + harness.Input.EnqueueString("keep this"); + harness.Input.EnqueueKey(ConsoleKey.Escape); + harness.Input.EnqueueKey(ConsoleKey.Enter); + + Assert.Equal("keep this", + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task MultilinePaste_SubmitsTheExactOriginalText() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + const string pasted = "first pasted line\nsecond pasted line"; + + harness.Events.Enqueue(new PasteEvent(pasted)); + harness.Events.Enqueue(new KeyPressed( + new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false))); + + Assert.Equal(pasted, + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task Approval_BlocksPasteFromTheHiddenComposer() + { + await using var harness = CreateHarness(approval: BuildApproval()); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + + harness.Events.Enqueue(new PasteEvent("blocked paste")); + harness.Events.Enqueue(new KeyPressed( + new ConsoleKeyInfo('\0', ConsoleKey.O, false, false, true))); + await harness.WaitUntilAsync(() => harness.ViewModel.IsApprovalDetailVisible.Value); + harness.Input.EnqueueKey(ConsoleKey.Escape); + _ = await harness.ViewModel.ReadApprovalAsync(harness.Cancellation.Token); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Composer")); + + harness.Input.EnqueueString("safe prompt"); + harness.Input.EnqueueKey(ConsoleKey.Enter); + Assert.Equal("safe prompt", + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task CtrlO_KeepsTheApprovalSelection() + { + var approval = BuildApproval(); + await using var harness = CreateHarness(approval: approval); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + + harness.Input.EnqueueKey(ConsoleKey.DownArrow); + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + harness.Input.EnqueueKey(ConsoleKey.Enter); + + Assert.Equal(ApprovalOptionKeys.ApproveSession, + await harness.ViewModel.ReadApprovalAsync(harness.Cancellation.Token)); + Assert.True(harness.ViewModel.IsApprovalDetailVisible.Value); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task Escape_DeniesAnApproval() + { + await using var harness = CreateHarness(approval: BuildApproval()); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + + harness.Input.EnqueueKey(ConsoleKey.Escape); + + Assert.Equal(ApprovalOptionKeys.Deny, + await harness.ViewModel.ReadApprovalAsync(harness.Cancellation.Token)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task CtrlO_ShowsApprovalSecurityContext() + { + var approval = BuildApproval() with + { + Patterns = ["dotnet"], + CandidateVerbs = ["dotnet"], + Cwd = "/work/netclaw", + IsMessy = true, + HasAdoptedContext = true, + HasThirdPartyAdoptedContext = true, + PersistedAdoptedContext = true + }; + await using var harness = CreateHarness(approval: approval); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Directory: /work/netclaw")); + + var screen = harness.Terminal.ToString(); + Assert.Contains("Patterns: dotnet", screen, StringComparison.Ordinal); + Assert.Contains("Verbs: dotnet", screen, StringComparison.Ordinal); + Assert.Contains("Complex command", screen, StringComparison.Ordinal); + Assert.Contains("third-party context", screen, StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task ApprovalDetail_PreservesSelectionAndScrollPositionAcrossCollapse() + { + var detail = string.Join('\n', Enumerable.Range(0, 40).Select(index => $"command line {index}")); + var approval = BuildApproval() with { DisplayText = detail }; + await using var harness = CreateHarness(approval: approval, height: 24); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + + harness.Input.EnqueueKey(ConsoleKey.DownArrow); + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Page.ApprovalDetailCanScrollDown); + harness.Input.EnqueueKey(ConsoleKey.PageDown); + await harness.WaitUntilAsync(() => harness.Page.ApprovalDetailScrollOffset > 0); + var offset = harness.Page.ApprovalDetailScrollOffset; + + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => !harness.ViewModel.IsApprovalDetailVisible.Value); + Assert.Equal(offset, harness.Page.ApprovalDetailScrollOffset); + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.ViewModel.IsApprovalDetailVisible.Value); + Assert.Equal(offset, harness.Page.ApprovalDetailScrollOffset); + + harness.Input.EnqueueKey(ConsoleKey.Enter); + Assert.Equal(ApprovalOptionKeys.ApproveSession, + await harness.ViewModel.ReadApprovalAsync(harness.Cancellation.Token)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task Generation_HidesTheComposerAndClearsFocus() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Composer")); + Assert.NotNull(harness.Focus.CurrentFocus); + + harness.ViewModel.IsGenerating.Value = true; + harness.ViewModel.StatusMessage.Value = "Generating..."; + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("Generating") + && harness.Terminal.Contains("Work active") + && !harness.Terminal.Contains("Composer") + && harness.Focus.CurrentFocus is null); + + harness.ViewModel.IsGenerating.Value = false; + harness.ViewModel.StatusMessage.Value = "Ready"; + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("Composer") && harness.Focus.CurrentFocus is not null); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task StableTranscript_UsesPrimaryScrollbackWithoutAnOuterBorder() + { + var output = new TextOutput("A stable answer") + { + SessionId = SessionId, + TimestampMs = 0 + }; + await using var harness = CreateHarness(outputs: [output]); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("A stable answer")); + + Assert.Equal("NETCLAW", harness.Terminal.GetLine(0)); + Assert.Equal("A stable answer", harness.Terminal.GetLine(1)); + Assert.DoesNotContain('│', harness.Terminal.GetLine(1)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task ActivityDeck_ShowsParallelToolsThoughtAndSubAgent() + { + var outputs = new SessionOutput[] + { + new ThinkingDeltaOutput("Compare both results") + { + SessionId = SessionId, + TimestampMs = 1 + }, + ToolCall("call-a", "search", 2), + ToolCall("call-b", "fetch", 3), + new ToolActivityOutput + { + SessionId = SessionId, + TimestampMs = 4, + CallId = new ToolCallId("call-b"), + ToolName = new ToolName("fetch"), + TurnId = new TurnId("turn-1"), + Phase = "running", + Summary = "documentation" + }, + new SubAgentOutput + { + SessionId = SessionId, + TimestampMs = 5, + AgentName = new AgentName("reviewer"), + Phase = SubAgentPhase.Activity, + RunId = new SubAgentRunId("run-a"), + ParentCallId = new ToolCallId("call-a"), + ActivityPhase = "reviewing", + ActivitySummary = "API surface" + } + }; + await using var harness = CreateHarness(outputs: outputs); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("Compare both results") + && harness.Terminal.Contains("search") + && harness.Terminal.Contains("fetch") + && harness.Terminal.Contains("reviewer")); + + var screen = harness.Terminal.ToString(); + Assert.Contains("TOOL search", screen, StringComparison.Ordinal); + Assert.Contains("TOOL fetch", screen, StringComparison.Ordinal); + Assert.Contains("AGENT reviewer", screen, StringComparison.Ordinal); + Assert.DoesNotContain("Composer", screen, StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task Inspector_ShowsTheCompleteSemanticToolResult() + { + var outputs = new SessionOutput[] + { + ToolCall("call-a", "search", 1), + new ToolResultOutput + { + SessionId = SessionId, + TimestampMs = 2, + CallId = new ToolCallId("call-a"), + ToolName = new ToolName("search"), + Result = "compact line\ncomplete hidden line" + } + }; + await using var harness = CreateHarness(outputs: outputs); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("compact line")); + + Assert.DoesNotContain("complete hidden line", harness.Terminal.ToString(), StringComparison.Ordinal); + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("complete hidden line")); + + Assert.Contains("Arguments: {\"call\":\"call-a\"}", + harness.Terminal.ToString(), StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task Inspector_DefersNewStableBlocksUntilItCloses() + { + var first = new TextOutput("first answer") + { + SessionId = SessionId, + TimestampMs = 1 + }; + await using var harness = CreateHarness(outputs: [first]); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("first answer")); + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); + + harness.ViewModel.Emit(new TextOutput("queued answer") + { + SessionId = SessionId, + TimestampMs = 2 + }); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("1/2")); + Assert.DoesNotContain("queued answer", harness.Terminal.ToString(), StringComparison.Ordinal); + + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("queued answer")); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task Inspector_EventCopyUsesCompleteSemanticText() + { + var outputs = new SessionOutput[] + { + ToolCall("call-a", "search", 1), + new ToolResultOutput + { + SessionId = SessionId, + TimestampMs = 2, + CallId = new ToolCallId("call-a"), + ToolName = new ToolName("search"), + Result = "first line\n\x1b[31mcomplete result\x1b[0m" + } + }; + await using var harness = CreateHarness(outputs: outputs); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("first line")); + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); + + harness.Input.EnqueueKey(ConsoleKey.Y); + await harness.WaitUntilAsync(() => harness.Clipboard.LastCopiedText is not null); + + var copied = harness.Clipboard.LastCopiedText!; + Assert.Contains("complete result", copied, StringComparison.Ordinal); + Assert.Contains("Arguments: {\"call\":\"call-a\"}", copied, StringComparison.Ordinal); + Assert.DoesNotContain('\x1b', copied); + Assert.DoesNotContain('│', copied); + Assert.DoesNotContain('╭', copied); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task Inspector_ShiftYCopiesTheCompleteTurnInOrder() + { + var joined = new SessionJoined + { + SessionId = SessionId, + TimestampMs = 1, + TurnCount = 1, + RecentTranscript = + [ + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.User, + Text = "check status", + TurnId = "turn-1" + }, + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Tool, + ToolName = "status", + CallId = "call-a", + Result = "healthy", + TurnId = "turn-1" + }, + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Assistant, + Text = "all healthy", + TurnId = "turn-1" + } + ] + }; + await using var harness = CreateHarness(outputs: [joined]); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("all healthy")); + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); + + harness.Input.EnqueueKey(ConsoleKey.Y, shift: true); + await harness.WaitUntilAsync(() => harness.Clipboard.LastCopiedText is not null); + + var copied = harness.Clipboard.LastCopiedText!; + Assert.True(copied.IndexOf("check status", StringComparison.Ordinal) + < copied.IndexOf("healthy", StringComparison.Ordinal)); + Assert.True(copied.IndexOf("healthy", StringComparison.Ordinal) + < copied.IndexOf("all healthy", StringComparison.Ordinal)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task Inspector_CopyFailureStaysVisibleAndKeepsTheEvent() + { + var output = new TextOutput("copy target") + { + SessionId = SessionId, + TimestampMs = 1 + }; + await using var harness = CreateHarness(outputs: [output], clipboardSucceeds: false); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("copy target")); + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); + + harness.Input.EnqueueKey(ConsoleKey.Y); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Copy failed")); + + harness.Input.EnqueueKey(ConsoleKey.Y); + await harness.WaitUntilAsync(() => harness.Clipboard.CopyCount == 2); + Assert.Equal("NETCLAW\ncopy target", harness.Clipboard.LastCopiedText); + await harness.StopAsync(runTask); + } + + [Theory] + [InlineData(40)] + [InlineData(60)] + [InlineData(80)] + [InlineData(120)] + public async Task CommonWidths_KeepTheComposerAndStatusVisible(int width) + { + await using var harness = CreateHarness(width: width); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Composer")); + + var screen = harness.Terminal.ToString(); + Assert.Contains("NETCLAW", screen, StringComparison.Ordinal); + Assert.Contains("Composer", screen, StringComparison.Ordinal); + Assert.Contains("Enter", screen, StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + + private static ToolCallOutput ToolCall(string callId, string name, long timestamp) => new() + { + SessionId = SessionId, + TimestampMs = timestamp, + CallId = new ToolCallId(callId), + ToolName = new ToolName(name), + ArgumentsJson = $"{{\"call\":\"{callId}\"}}" + }; + + private static ToolInteractionRequest BuildApproval() => new() + { + SessionId = SessionId, + TimestampMs = 1, + Kind = "approval", + CallId = new ToolCallId("approval-call"), + ToolName = new ToolName("shell_execute"), + DisplayText = "dotnet test src/Netclaw.Cli.Tests", + Options = + [ + new ToolInteractionOption( + ApprovalOptionKeys.ApproveOnceKey, + ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption( + ApprovalOptionKeys.ApproveSessionKey, + ApprovalOptionKeys.ApproveSessionLabel), + new ToolInteractionOption( + ApprovalOptionKeys.DenyKey, + ApprovalOptionKeys.DenyLabel) + ] + }; + + private static InlineHarness CreateHarness( + IReadOnlyList? outputs = null, + ToolInteractionRequest? approval = null, + bool clipboardSucceeds = true, + int width = 120, + int height = 40) + { + var terminal = new VirtualTerminal(width, height); + var input = new VirtualInputSource(); + var events = new TestEventInputSource(); + var clipboard = new TestClipboardService(clipboardSucceeds); + var time = new FakeTimeProvider( + new DateTimeOffset(2026, 8, 11, 12, 0, 0, TimeSpan.Zero)); + TestChatViewModel? viewModel = null; + InlineChatPage? page = null; + + var services = new ServiceCollection(); + services.AddSingleton(terminal); + services.AddSingleton(time); + services.AddSingleton(clipboard); + services.AddTerminaVirtualInput(input); + services.AddSingleton(events); + services.AddTermina("/chat", builder => + { + builder.ConfigureRuntime(options => + { + options.PresentationMode = TerminalPresentationMode.Inline; + options.ScrollInputMode = ScrollInputMode.NativeTerminal; + }); + builder.RegisterRoute( + "/chat", + serviceProvider => page = new InlineChatPage( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService()), + _ => viewModel = new TestChatViewModel(outputs ?? [], approval)); + }); + + var provider = services.BuildServiceProvider(); + var app = provider.GetRequiredService(); + return new InlineHarness( + provider, + terminal, + input, + events, + clipboard, + app.Focus, + page!, + app, + viewModel!); + } + + private sealed class InlineHarness : IAsyncDisposable + { + private readonly ServiceProvider _provider; + + public InlineHarness( + ServiceProvider provider, + VirtualTerminal terminal, + VirtualInputSource input, + TestEventInputSource events, + TestClipboardService clipboard, + IFocusManager focus, + InlineChatPage page, + TerminaApplication app, + TestChatViewModel viewModel) + { + _provider = provider; + Terminal = terminal; + Input = input; + Events = events; + Clipboard = clipboard; + Focus = focus; + Page = page; + App = app; + ViewModel = viewModel; + } + + public VirtualTerminal Terminal { get; } + + public VirtualInputSource Input { get; } + + public TestEventInputSource Events { get; } + + public TestClipboardService Clipboard { get; } + + public IFocusManager Focus { get; } + + public InlineChatPage Page { get; } + + public TerminaApplication App { get; } + + public TestChatViewModel ViewModel { get; } + + public CancellationTokenSource Cancellation { get; } = new(TimeSpan.FromSeconds(10)); + + private Task? RunTask { get; set; } + + public Task StartAsync() + { + RunTask = App.RunAsync(Cancellation.Token); + return RunTask; + } + + public async Task StopAsync(Task runTask) + { + Input.EnqueueKey(ConsoleKey.Q, control: true); + await runTask; + } + + public async Task WaitUntilAsync(Func condition) + { + while (!condition()) + { + if (RunTask is { IsCompleted: true }) + await RunTask; + Cancellation.Token.ThrowIfCancellationRequested(); + await Task.Yield(); + } + } + + public async ValueTask DisposeAsync() + { + Cancellation.Cancel(); + Cancellation.Dispose(); + await _provider.DisposeAsync(); + } + } + + private sealed class TestChatViewModel : ChatViewModel + { + private readonly IReadOnlyList _outputs; + private readonly ToolInteractionRequest? _approval; + private readonly Channel _submissions = Channel.CreateUnbounded(); + private readonly Channel _approvalSelections = Channel.CreateUnbounded(); + + public TestChatViewModel( + IReadOnlyList outputs, + ToolInteractionRequest? approval) + : base( + new DaemonClient("http://127.0.0.1:1"), + TimeProvider.System, + new ModelCapabilities { ModelId = "test-model" }, + new ChatNavigationState(), + new NetclawPaths()) + { + _outputs = outputs; + _approval = approval; + } + + protected override Task InitializeSessionAsync() => Task.CompletedTask; + + public override void OnActivated() + { + base.OnActivated(); + foreach (var output in _outputs) + PublishOutputForTesting(output); + if (_approval is not null) + SeedPendingInteractionForTesting(_approval); + } + + public override Task SubmitAsync(string text) + { + _submissions.Writer.TryWrite(text); + return Task.CompletedTask; + } + + protected override Task SubmitInteractionSelectionAsync(string selectedKey) + { + _approvalSelections.Writer.TryWrite(selectedKey); + PublishOutputForTesting(new TurnCompleted + { + SessionId = SessionId, + TimestampMs = 10, + TurnNumber = new TurnNumber(1), + Outcome = TurnOutcome.Completed + }); + return Task.CompletedTask; + } + + public ValueTask ReadSubmissionAsync(CancellationToken cancellationToken) => + _submissions.Reader.ReadAsync(cancellationToken); + + public ValueTask ReadApprovalAsync(CancellationToken cancellationToken) => + _approvalSelections.Reader.ReadAsync(cancellationToken); + + public void Emit(SessionOutput output) + { + PublishOutputForTesting(output); + } + } + + private sealed class TestEventInputSource : IInputSource + { + private readonly Channel _events = Channel.CreateUnbounded(); + + public void Enqueue(IInputEvent input) + { + _events.Writer.TryWrite(input); + } + + public async Task RunAsync( + ChannelWriter writer, + CancellationToken cancellationToken) + { + await foreach (var input in _events.Reader.ReadAllAsync(cancellationToken)) + await writer.WriteAsync(input, cancellationToken); + } + } + + private sealed class TestClipboardService(bool succeeds) : IClipboardService + { + public string? LastCopiedText { get; private set; } + + public int CopyCount { get; private set; } + + public bool Copy(string text) + { + CopyCount++; + LastCopiedText = text; + return succeeds; + } + } +} diff --git a/src/Netclaw.Cli.Tests/Tui/SessionsPageTests.cs b/src/Netclaw.Cli.Tests/Tui/SessionsPageTests.cs index 1ff1681a8..6974e6c5c 100644 --- a/src/Netclaw.Cli.Tests/Tui/SessionsPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/SessionsPageTests.cs @@ -195,7 +195,6 @@ public async Task EnterOnSelectedSession_ResumesThatSession_NotTheFirst() input.EnqueueKey(ConsoleKey.DownArrow); input.EnqueueKey(ConsoleKey.DownArrow); input.EnqueueKey(ConsoleKey.Enter); - input.EnqueueKey(ConsoleKey.Q, false, false, true); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await app.RunAsync(cts.Token); @@ -203,6 +202,7 @@ public async Task EnterOnSelectedSession_ResumesThatSession_NotTheFirst() Assert.Equal(2, vm.SelectedIndex.Value); // SessionId strips the "session-" prefix. Assert.Equal("003", nav.ResumeSessionId); + Assert.True(nav.ChatLaunchRequested); } [Fact] @@ -216,12 +216,11 @@ public async Task NKey_StartsNewChat_WithoutResuming() var (_, app, _, nav) = CreateHeadlessApp(out var input, sessions); input.EnqueueKey(ConsoleKey.N); - input.EnqueueKey(ConsoleKey.Q, false, false, true); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await app.RunAsync(cts.Token); Assert.Null(nav.ResumeSessionId); + Assert.True(nav.ChatLaunchRequested); } [Fact] @@ -239,12 +238,11 @@ public async Task Escape_AtRoot_DoesNotQuit() input.EnqueueKey(ConsoleKey.Escape); // must be a no-op input.EnqueueKey(ConsoleKey.Enter); // still on sessions root -> resume - input.EnqueueKey(ConsoleKey.Q, false, false, true); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await app.RunAsync(cts.Token); Assert.Equal("001", nav.ResumeSessionId); + Assert.True(nav.ChatLaunchRequested); } [Fact] @@ -316,13 +314,6 @@ public async Task LongList_DownArrowScrollsSelectedRowIntoView_WithHighlight() return capturedVm; }); - // Landing route for the Enter/N resume paths: the ViewModel navigates to - // "/chat" after setting resume state. The stub terminates immediately so the - // app loop exits without waiting on the cancellation timeout. - builder.RegisterRoute( - "/chat", - _ => new StubChatPage(), - _ => new StubChatViewModel()); }); var sp = services.BuildServiceProvider(); @@ -386,18 +377,4 @@ private sealed class MockHttpClientFactory : IHttpClientFactory public HttpClient CreateClient(string name) => new(_handler); } - - private sealed class StubChatViewModel : ReactiveViewModel - { - public override void OnActivated() - { - base.OnActivated(); - Shutdown(); - } - } - - private sealed class StubChatPage : ReactivePage - { - public override ILayoutNode BuildLayout() => Layouts.Empty(); - } } diff --git a/src/Netclaw.Cli.Tests/Tui/TerminalRuntimeProfilesTests.cs b/src/Netclaw.Cli.Tests/Tui/TerminalRuntimeProfilesTests.cs new file mode 100644 index 000000000..245f2f239 --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/TerminalRuntimeProfilesTests.cs @@ -0,0 +1,45 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Cli.Tui; +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class TerminalRuntimeProfilesTests +{ + [Fact] + public void InlineChat_GivesScrollAndWheelInputToThePrimaryTerminal() + { + var options = new TerminaRuntimeOptions(); + + TerminalRuntimeProfiles.ConfigureInlineChat(options); + + Assert.Equal(TerminalPresentationMode.Inline, options.PresentationMode); + Assert.Equal(ScrollInputMode.NativeTerminal, options.ScrollInputMode); + Assert.True(options.PreferRawInput); + Assert.Equal(CtrlCHandlingMode.DoublePressWhenRawInput, options.CtrlCHandlingMode); + } + + [Fact] + public void SelectionApps_ExplicitlyRestoreFullScreenMode() + { + var options = new TerminaRuntimeOptions + { + PresentationMode = TerminalPresentationMode.Inline, + ScrollInputMode = ScrollInputMode.NativeTerminal + }; + + TerminalRuntimeProfiles.ConfigureFullScreenSelection(options); + + Assert.Equal(TerminalPresentationMode.FullScreen, options.PresentationMode); + Assert.Equal(ScrollInputMode.AlternateScroll, options.ScrollInputMode); + Assert.True(options.PreferRawInput); + Assert.Equal(CtrlCHandlingMode.DoublePressWhenRawInput, options.CtrlCHandlingMode); + } +} diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 65ed9dd91..9c76f73b5 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -528,134 +528,134 @@ static async Task RunAsync(string[] args) return; case "pair": - { - if (args.Length > 2 && IsHelpToken(args[2])) - { - WriteDaemonPairHelp(); - return; - } - - var pairBuilder = Host.CreateApplicationBuilder(args); - ConfigureConfigServices(pairBuilder.Services, pairBuilder.Configuration); - pairBuilder.Logging.ClearProviders(); - pairBuilder.Logging.SetMinimumLevel(LogLevel.Warning); - - using var pairHost = pairBuilder.Build(); - var pairApi = pairHost.Services.GetRequiredService(); - var pairHubUrl = $"{pairApi.Endpoint}/hub/session"; - var pairPaths = pairHost.Services.GetRequiredService(); - var pairExposureMode = DaemonClientFactory.ResolveExposureMode(pairPaths); - var pairTokenFactory = DaemonClientFactory.CreateAccessTokenProvider(pairApi.Endpoint, pairPaths, pairExposureMode); - - await using var pairConn = new HubConnectionBuilder() - .ConfigureAccessToken(pairHubUrl, pairTokenFactory) - .Build(); - - try - { - await pairConn.StartAsync(); - } - catch (Exception ex) { - Console.Error.WriteLine($"error: Could not connect to daemon at {pairApi.Endpoint}: {ex.Message}"); - Console.Error.WriteLine("Ensure the daemon is running: netclaw daemon start"); - Environment.ExitCode = 1; - return; - } - - try - { - var pairingResult = await pairConn.InvokeAsync("GeneratePairingCode"); - Console.WriteLine($"Pairing code: {pairingResult.FormattedCode}"); - Console.WriteLine($"Expires at: {pairingResult.ExpiresAt.ToLocalTime():HH:mm:ss} (local time)"); - Console.WriteLine(); - Console.WriteLine("On the remote device, run:"); - Console.WriteLine($" netclaw pair {pairApi.Endpoint}"); - } - catch (HubException ex) - { - Console.Error.WriteLine($"error: {ex.Message}"); - Environment.ExitCode = 1; - } - - return; - } + if (args.Length > 2 && IsHelpToken(args[2])) + { + WriteDaemonPairHelp(); + return; + } - case "devices": - { - var devicesSubcmd = args.Length > 2 ? args[2] : "list"; - if (IsHelpToken(devicesSubcmd)) - { - WriteDaemonDevicesHelp(); - return; - } + var pairBuilder = Host.CreateApplicationBuilder(args); + ConfigureConfigServices(pairBuilder.Services, pairBuilder.Configuration); + pairBuilder.Logging.ClearProviders(); + pairBuilder.Logging.SetMinimumLevel(LogLevel.Warning); - var devBuilder = Host.CreateApplicationBuilder(args); - ConfigureConfigServices(devBuilder.Services, devBuilder.Configuration); - devBuilder.Logging.ClearProviders(); - devBuilder.Logging.SetMinimumLevel(LogLevel.Warning); + using var pairHost = pairBuilder.Build(); + var pairApi = pairHost.Services.GetRequiredService(); + var pairHubUrl = $"{pairApi.Endpoint}/hub/session"; + var pairPaths = pairHost.Services.GetRequiredService(); + var pairExposureMode = DaemonClientFactory.ResolveExposureMode(pairPaths); + var pairTokenFactory = DaemonClientFactory.CreateAccessTokenProvider(pairApi.Endpoint, pairPaths, pairExposureMode); - using var devHost = devBuilder.Build(); - var devApi = devHost.Services.GetRequiredService(); + await using var pairConn = new HubConnectionBuilder() + .ConfigureAccessToken(pairHubUrl, pairTokenFactory) + .Build(); - if (devicesSubcmd is "revoke") - { - var deviceName = args.Length > 3 ? args[3] : null; - if (string.IsNullOrWhiteSpace(deviceName)) + try + { + await pairConn.StartAsync(); + } + catch (Exception ex) { - Console.Error.WriteLine("error: device name required."); - Console.Error.WriteLine("Usage: netclaw daemon devices revoke "); + Console.Error.WriteLine($"error: Could not connect to daemon at {pairApi.Endpoint}: {ex.Message}"); + Console.Error.WriteLine("Ensure the daemon is running: netclaw daemon start"); Environment.ExitCode = 1; return; } try { - var removed = await devApi.RevokePairedDeviceAsync(deviceName); - if (removed) - Console.WriteLine($"Device '{deviceName}' revoked."); - else - { - Console.Error.WriteLine($"Device '{deviceName}' not found."); - Environment.ExitCode = 1; - } + var pairingResult = await pairConn.InvokeAsync("GeneratePairingCode"); + Console.WriteLine($"Pairing code: {pairingResult.FormattedCode}"); + Console.WriteLine($"Expires at: {pairingResult.ExpiresAt.ToLocalTime():HH:mm:ss} (local time)"); + Console.WriteLine(); + Console.WriteLine("On the remote device, run:"); + Console.WriteLine($" netclaw pair {pairApi.Endpoint}"); } - catch (HttpRequestException ex) + catch (HubException ex) { - Console.Error.WriteLine($"error: Could not reach daemon: {ex.Message}"); + Console.Error.WriteLine($"error: {ex.Message}"); Environment.ExitCode = 1; } + + return; } - else + + case "devices": { - // Default: list devices - try + var devicesSubcmd = args.Length > 2 ? args[2] : "list"; + if (IsHelpToken(devicesSubcmd)) { - var devices = await devApi.ListPairedDevicesAsync(); - if (devices.Count == 0) + WriteDaemonDevicesHelp(); + return; + } + + var devBuilder = Host.CreateApplicationBuilder(args); + ConfigureConfigServices(devBuilder.Services, devBuilder.Configuration); + devBuilder.Logging.ClearProviders(); + devBuilder.Logging.SetMinimumLevel(LogLevel.Warning); + + using var devHost = devBuilder.Build(); + var devApi = devHost.Services.GetRequiredService(); + + if (devicesSubcmd is "revoke") + { + var deviceName = args.Length > 3 ? args[3] : null; + if (string.IsNullOrWhiteSpace(deviceName)) { - Console.WriteLine("No paired devices."); + Console.Error.WriteLine("error: device name required."); + Console.Error.WriteLine("Usage: netclaw daemon devices revoke "); + Environment.ExitCode = 1; + return; } - else + + try { - Console.WriteLine($"{"Name",-24} {"Created",-22} {"Last Used",-22}"); - Console.WriteLine(new string('-', 70)); - foreach (var d in devices) + var removed = await devApi.RevokePairedDeviceAsync(deviceName); + if (removed) + Console.WriteLine($"Device '{deviceName}' revoked."); + else { - Console.WriteLine( - $"{d.Name,-24} {d.CreatedAt.ToLocalTime(),-22:yyyy-MM-dd HH:mm} {d.LastUsedAt.ToLocalTime(),-22:yyyy-MM-dd HH:mm}"); + Console.Error.WriteLine($"Device '{deviceName}' not found."); + Environment.ExitCode = 1; } } + catch (HttpRequestException ex) + { + Console.Error.WriteLine($"error: Could not reach daemon: {ex.Message}"); + Environment.ExitCode = 1; + } } - catch (HttpRequestException ex) + else { - Console.Error.WriteLine($"error: Could not reach daemon: {ex.Message}"); - Environment.ExitCode = 1; + // Default: list devices + try + { + var devices = await devApi.ListPairedDevicesAsync(); + if (devices.Count == 0) + { + Console.WriteLine("No paired devices."); + } + else + { + Console.WriteLine($"{"Name",-24} {"Created",-22} {"Last Used",-22}"); + Console.WriteLine(new string('-', 70)); + foreach (var d in devices) + { + Console.WriteLine( + $"{d.Name,-24} {d.CreatedAt.ToLocalTime(),-22:yyyy-MM-dd HH:mm} {d.LastUsedAt.ToLocalTime(),-22:yyyy-MM-dd HH:mm}"); + } + } + } + catch (HttpRequestException ex) + { + Console.Error.WriteLine($"error: Could not reach daemon: {ex.Message}"); + Environment.ExitCode = 1; + } } - } - return; - } + return; + } default: WriteDaemonHelp(); @@ -1098,8 +1098,8 @@ static async Task RunAsync(string[] args) case "chat": webBuilder.Services.AddTermina("/chat", termina => { - ConfigureNativeSelection(termina); - termina.RegisterRoute("/chat"); + ConfigureInlineChat(termina); + termina.RegisterRoute("/chat"); }); break; @@ -1108,7 +1108,6 @@ static async Task RunAsync(string[] args) { ConfigureNativeSelection(termina); termina.RegisterRoute("/sessions"); - termina.RegisterRoute("/chat"); }); break; @@ -1130,18 +1129,43 @@ static async Task RunAsync(string[] args) return; } - using var app = webBuilder.Build(); - await RunTerminaHostAsync(app); + using (var app = webBuilder.Build()) + await RunTerminaHostAsync(app); + + if (mode == "sessions" && navState.ChatLaunchRequested) + await RunInlineChatHostAsync(args, navState.ResumeSessionId); } -static void ConfigureNativeSelection(TerminaBuilder termina) +static async Task RunInlineChatHostAsync(string[] args, string? resumeSessionId) { - termina.ConfigureRuntime(options => + var builder = WebApplication.CreateBuilder(args); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + ConfigureConfigServices(builder.Services, builder.Configuration); + ConfigureCliChatServices(builder.Services, builder.Configuration); + builder.Services.AddSingleton(new ChatNavigationState + { + ResumeSessionId = resumeSessionId + }); + builder.Logging.ClearProviders(); + builder.Logging.SetMinimumLevel(LogLevel.Warning); + builder.Services.AddTermina("/chat", termina => { - options.PreferRawInput = true; - options.ScrollInputMode = ScrollInputMode.AlternateScroll; - options.CtrlCHandlingMode = CtrlCHandlingMode.DoublePressWhenRawInput; + ConfigureInlineChat(termina); + termina.RegisterRoute("/chat"); }); + + using var app = builder.Build(); + await RunTerminaHostAsync(app); +} + +static void ConfigureNativeSelection(TerminaBuilder termina) +{ + termina.ConfigureRuntime(TerminalRuntimeProfiles.ConfigureFullScreenSelection); +} + +static void ConfigureInlineChat(TerminaBuilder termina) +{ + termina.ConfigureRuntime(TerminalRuntimeProfiles.ConfigureInlineChat); } static void WriteCrashLog(Exception ex) @@ -1386,6 +1410,15 @@ static void WriteChatHelp() Console.WriteLine(" --json Output structured JSON (headless mode only)"); Console.WriteLine(" Includes sessionId, response, toolCalls, and usage"); Console.WriteLine(); + Console.WriteLine("Interactive keys:"); + Console.WriteLine(" Enter Send the prompt"); + Console.WriteLine(" Shift+Enter Add a line to the prompt"); + Console.WriteLine(" Up / Down Recall prompts and restore the current draft"); + Console.WriteLine(" Esc Esc Clear the prompt"); + Console.WriteLine(" Ctrl+O Open the Inspector or expand an approval"); + Console.WriteLine(" Y / Shift+Y Copy an Inspector event or its complete turn"); + Console.WriteLine(" Ctrl+Q Exit chat"); + Console.WriteLine(); Console.WriteLine("Examples:"); Console.WriteLine(" netclaw chat Interactive TUI"); Console.WriteLine(" netclaw chat --resume abc123 Resume session in TUI"); diff --git a/src/Netclaw.Cli/Tui/ChatNavigationState.cs b/src/Netclaw.Cli/Tui/ChatNavigationState.cs index bede95541..5a68e3447 100644 --- a/src/Netclaw.Cli/Tui/ChatNavigationState.cs +++ b/src/Netclaw.Cli/Tui/ChatNavigationState.cs @@ -12,6 +12,11 @@ namespace Netclaw.Cli.Tui; /// public sealed class ChatNavigationState { + /// + /// Gets whether the session picker requested a separate inline chat host. + /// + public bool ChatLaunchRequested { get; private set; } + /// /// When set, will resume this session ID /// instead of creating a new one. Consumed (cleared) on first read. @@ -28,6 +33,15 @@ public sealed class ChatNavigationState return id; } + /// + /// Requests a new inline chat host after the full-screen picker exits. + /// + public void RequestChatLaunch(string? resumeSessionId) + { + ResumeSessionId = resumeSessionId; + ChatLaunchRequested = true; + } + /// /// When set, will auto-send this message /// (hidden from the UI) after the session is established. Used by the diff --git a/src/Netclaw.Cli/Tui/ChatPresentation.cs b/src/Netclaw.Cli/Tui/ChatPresentation.cs new file mode 100644 index 000000000..f2cfbee50 --- /dev/null +++ b/src/Netclaw.Cli/Tui/ChatPresentation.cs @@ -0,0 +1,630 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Collections.Immutable; +using Netclaw.Actors.Protocol; +using Netclaw.Tools; +using static Netclaw.Actors.Sessions.SessionProtocol; + +namespace Netclaw.Cli.Tui; + +internal enum ChatBlockKind +{ + System, + User, + Assistant, + Thought, + Tool, + SubAgent, + Approval, + File, + Error, + Usage, + Compaction, + Diagnostic +} + +internal sealed record ChatPresentationBlock( + string Key, + ChatBlockKind Kind, + string Label, + string Summary, + string SemanticText, + long TimestampMs, + string? TurnId = null, + string? Detail = null, + bool IsFailure = false); + +internal sealed record ToolActivityPresentation( + string CallId, + string ToolName, + string? ArgumentsJson, + string Phase, + string? Summary, + long StartedAtMs, + string? TurnId); + +internal sealed record SubAgentActivityPresentation( + string RunId, + string? ParentCallId, + string AgentName, + string Phase, + string? Summary, + long StartedAtMs); + +internal sealed record ChatPresentationState +{ + public static readonly ChatPresentationState Empty = new(); + + public ImmutableList Transcript { get; init; } = []; + + public ImmutableDictionary Tools { get; init; } = + ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal); + + public ImmutableDictionary SubAgents { get; init; } = + ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal); + + public string AssistantText { get; init; } = string.Empty; + + public string ThoughtText { get; init; } = string.Empty; + + public int TurnNumber { get; init; } = 1; + + public string? CurrentTurnId { get; init; } + + public bool HasJoined { get; init; } + + public bool IsProcessing { get; init; } + + public ToolInteractionRequest? PendingApproval { get; init; } +} + +internal abstract record ChatPresentationEffect +{ + public sealed record Commit(ChatPresentationBlock Block) : ChatPresentationEffect; + + public sealed record RefreshLiveRegion : ChatPresentationEffect; + + public sealed record SetStatus(string Text) : ChatPresentationEffect; + + public sealed record ShowApproval(ToolInteractionRequest Request) : ChatPresentationEffect; + + public sealed record ClearApproval : ChatPresentationEffect; +} + +internal sealed record ChatReduction( + ChatPresentationState State, + IReadOnlyList Effects); + +internal static class ChatPresentationReducer +{ + public static ChatReduction Reduce(ChatPresentationState state, SessionOutput output) + { + var effects = new List(); + var next = output switch + { + SessionJoined joined => ReduceJoined(state, joined, effects), + TextDeltaOutput textDelta => state with + { + AssistantText = state.AssistantText + textDelta.Delta + }, + TextOutput text => CommitAssistant(state, text, effects), + ThinkingDeltaOutput thoughtDelta => state with + { + ThoughtText = state.ThoughtText + thoughtDelta.Delta + }, + ThinkingOutput thought => CommitThought(state, thought, effects), + ToolCallOutput toolCall => StartTool(state, toolCall), + ToolActivityOutput activity => UpdateTool(state, activity), + ToolResultOutput toolResult => CompleteTool(state, toolResult, effects), + SubAgentOutput subAgent => ReduceSubAgent(state, subAgent, effects), + UsageOutput usage => Commit(state, UsageBlock(usage, state.CurrentTurnId), effects), + ErrorOutput error => Commit(state, ErrorBlock(error, state.CurrentTurnId), effects), + FileOutput file => Commit(state, FileBlock(file, state.CurrentTurnId), effects), + CompactionOutput compaction => Commit(state, CompactionBlock(compaction, state.CurrentTurnId), effects), + ToolInteractionRequest approval => ShowApproval(state, approval, effects), + TurnCompleted completed => CompleteTurn(state, completed, effects), + ProcessingStateOutput processing => state with { IsProcessing = processing.IsProcessing }, + SessionTitleOutput title => Commit(state, new ChatPresentationBlock( + $"title:{title.TimestampMs}", + ChatBlockKind.System, + "SESSION", + title.Title, + $"Session title: {title.Title}", + title.TimestampMs), effects), + BufferFlush => FlushAssistant(state, output.TimestampMs, effects), + _ => Commit(state, DiagnosticBlock( + $"unsupported:{output.GetType().Name}:{output.TimestampMs}", + $"Unsupported session output: {output.GetType().Name}", + output.TimestampMs, + state.CurrentTurnId), effects) + }; + + if (output is TextDeltaOutput or ThinkingDeltaOutput or ToolCallOutput or ToolActivityOutput + or ProcessingStateOutput) + { + effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); + } + + return new ChatReduction(next, effects); + } + + public static ChatReduction RecordUserPrompt( + ChatPresentationState state, + string prompt, + long timestampMs) + { + var block = new ChatPresentationBlock( + $"turn:{state.TurnNumber}:user", + ChatBlockKind.User, + "YOU", + prompt, + $"YOU\n{prompt}", + timestampMs, + state.CurrentTurnId, + prompt); + return new ChatReduction( + state with { Transcript = state.Transcript.Add(block) }, + [new ChatPresentationEffect.Commit(block)]); + } + + private static ChatPresentationState ReduceJoined( + ChatPresentationState state, + SessionJoined joined, + List effects) + { + if (state.HasJoined) + { + effects.Add(new ChatPresentationEffect.SetStatus("Reconnected")); + return state; + } + + var sessionSummary = joined.Title ?? (joined.TurnCount > 0 ? "Resumed session" : "New session"); + var sessionBlock = new ChatPresentationBlock( + $"session:{joined.SessionId.Value}", + ChatBlockKind.System, + "SESSION", + sessionSummary, + $"Session: {sessionSummary}", + joined.TimestampMs); + state = Commit(state, sessionBlock, effects); + + if (joined.RecentTranscript is { Count: > 0 }) + { + for (var index = 0; index < joined.RecentTranscript.Count; index++) + state = Commit(state, ResumeBlock(joined.RecentTranscript[index], index), effects); + } + else if (joined.RecentMessages is { Count: > 0 }) + { + for (var index = 0; index < joined.RecentMessages.Count; index++) + { + var message = joined.RecentMessages[index]; + var kind = string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) + ? ChatBlockKind.User + : ChatBlockKind.Assistant; + var label = kind == ChatBlockKind.User ? "YOU" : "NETCLAW"; + state = Commit(state, new ChatPresentationBlock( + $"legacy:{index}:{message.Role}", + kind, + label, + message.Content, + $"{label}\n{message.Content}", + joined.TimestampMs, + Detail: message.Content), effects); + } + } + + effects.Add(new ChatPresentationEffect.SetStatus("Ready")); + return state with + { + HasJoined = true, + TurnNumber = joined.TurnCount + 1 + }; + } + + private static ChatPresentationState CommitAssistant( + ChatPresentationState state, + TextOutput output, + List effects) + { + var text = string.IsNullOrEmpty(output.Text) ? state.AssistantText : output.Text; + if (string.IsNullOrEmpty(text)) + return state; + + var block = new ChatPresentationBlock( + $"turn:{state.TurnNumber}:assistant", + ChatBlockKind.Assistant, + "NETCLAW", + text, + $"NETCLAW\n{text}", + output.TimestampMs, + state.CurrentTurnId, + text); + return Commit(state with { AssistantText = string.Empty }, block, effects); + } + + private static ChatPresentationState FlushAssistant( + ChatPresentationState state, + long timestampMs, + List effects) + { + if (string.IsNullOrEmpty(state.AssistantText)) + return state; + + var block = new ChatPresentationBlock( + $"turn:{state.TurnNumber}:assistant:preamble:{timestampMs}", + ChatBlockKind.Assistant, + "NETCLAW", + state.AssistantText, + $"NETCLAW\n{state.AssistantText}", + timestampMs, + state.CurrentTurnId, + state.AssistantText); + return Commit(state with { AssistantText = string.Empty }, block, effects); + } + + private static ChatPresentationState CommitThought( + ChatPresentationState state, + ThinkingOutput output, + List effects) + { + var text = string.IsNullOrEmpty(output.Text) ? state.ThoughtText : output.Text; + if (string.IsNullOrWhiteSpace(text)) + return state with { ThoughtText = string.Empty }; + + var block = new ChatPresentationBlock( + $"turn:{state.TurnNumber}:thought:{output.TimestampMs}", + ChatBlockKind.Thought, + "THOUGHT", + FirstLine(text), + $"THOUGHT\n{text}", + output.TimestampMs, + state.CurrentTurnId, + text); + return Commit(state with { ThoughtText = string.Empty }, block, effects); + } + + private static ChatPresentationState StartTool(ChatPresentationState state, ToolCallOutput output) + { + var tool = new ToolActivityPresentation( + output.CallId.Value, + output.ToolName.Value, + output.ArgumentsJson, + "queued", + null, + output.TimestampMs, + state.CurrentTurnId); + return state with { Tools = state.Tools.SetItem(tool.CallId, tool) }; + } + + private static ChatPresentationState UpdateTool(ChatPresentationState state, ToolActivityOutput output) + { + var key = output.CallId.Value; + var existing = state.Tools.TryGetValue(key, out var tool) + ? tool + : new ToolActivityPresentation( + key, + output.ToolName.Value, + null, + output.Phase, + output.Summary, + output.TimestampMs, + output.TurnId.Value); + return state with + { + CurrentTurnId = output.TurnId.Value, + Tools = state.Tools.SetItem(key, existing with + { + Phase = output.Phase, + Summary = output.Summary, + TurnId = output.TurnId.Value + }) + }; + } + + private static ChatPresentationState CompleteTool( + ChatPresentationState state, + ToolResultOutput output, + List effects) + { + state.Tools.TryGetValue(output.CallId.Value, out var active); + var detail = string.Join('\n', new[] + { + active?.ArgumentsJson is null ? null : $"Arguments: {active.ArgumentsJson}", + $"Result: {output.Result}" + }.Where(value => value is not null)); + var block = new ChatPresentationBlock( + $"tool:{output.CallId.Value}", + ChatBlockKind.Tool, + "TOOL", + $"✓ {output.ToolName.Value} · {FirstLine(output.Result)}", + $"Tool: {output.ToolName.Value}\nCall: {output.CallId.Value}\n{detail}", + output.TimestampMs, + active?.TurnId ?? state.CurrentTurnId, + detail); + return Commit(state with { Tools = state.Tools.Remove(output.CallId.Value) }, block, effects); + } + + private static ChatPresentationState ReduceSubAgent( + ChatPresentationState state, + SubAgentOutput output, + List effects) + { + var key = output.RunId?.Value ?? $"legacy:{output.AgentName.Value}"; + if (output.Phase != Actors.SubAgents.SubAgentPhase.Completed) + { + var current = state.SubAgents.TryGetValue(key, out var active) + ? active + : new SubAgentActivityPresentation( + key, + output.ParentCallId?.Value, + output.AgentName.Value, + output.Phase.ToString().ToLowerInvariant(), + output.ActivitySummary, + output.TimestampMs); + return state with + { + SubAgents = state.SubAgents.SetItem(key, current with + { + Phase = output.ActivityPhase ?? output.Phase.ToString().ToLowerInvariant(), + Summary = output.ActivitySummary + }) + }; + } + + var outcome = output.Outcome.ToString().ToLowerInvariant(); + var detail = $"Run: {key}\nOutcome: {outcome}\nDuration: {output.Duration.TotalSeconds:F1}s" + + (output.OutcomeReason is null ? string.Empty : $"\nReason: {output.OutcomeReason.Value.Value}") + + (output.MemoryDecision is null ? string.Empty : $"\nMemory: {output.MemoryDecision}"); + var block = new ChatPresentationBlock( + $"subagent:{key}", + ChatBlockKind.SubAgent, + "AGENT", + $"{output.AgentName.Value} · {outcome} · {output.Duration.TotalSeconds:F1}s", + $"Sub-agent: {output.AgentName.Value}\n{detail}", + output.TimestampMs, + state.CurrentTurnId, + detail, + output.Outcome == SubAgentRunOutcome.Failed); + return Commit(state with { SubAgents = state.SubAgents.Remove(key) }, block, effects); + } + + private static ChatPresentationState ShowApproval( + ChatPresentationState state, + ToolInteractionRequest approval, + List effects) + { + effects.Add(new ChatPresentationEffect.ShowApproval(approval)); + effects.Add(new ChatPresentationEffect.SetStatus("Approval required")); + effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); + return state with { PendingApproval = approval }; + } + + private static ChatPresentationState CompleteTurn( + ChatPresentationState state, + TurnCompleted completed, + List effects) + { + state = FlushAssistant(state, completed.TimestampMs, effects); + if (!string.IsNullOrWhiteSpace(state.ThoughtText)) + { + state = CommitThought(state, new ThinkingOutput(state.ThoughtText) + { + SessionId = completed.SessionId, + TimestampMs = completed.TimestampMs + }, effects); + } + + foreach (var tool in state.Tools.Values.OrderBy(tool => tool.StartedAtMs)) + { + state = Commit(state, DiagnosticBlock( + $"tool:{tool.CallId}:incomplete", + $"Tool '{tool.ToolName}' ended without a settled result.", + completed.TimestampMs, + tool.TurnId), effects); + } + + foreach (var subAgent in state.SubAgents.Values.OrderBy(run => run.StartedAtMs)) + { + state = Commit(state, DiagnosticBlock( + $"subagent:{subAgent.RunId}:incomplete", + $"Sub-agent '{subAgent.AgentName}' ended without a terminal event.", + completed.TimestampMs, + state.CurrentTurnId), effects); + } + + effects.Add(new ChatPresentationEffect.ClearApproval()); + effects.Add(new ChatPresentationEffect.SetStatus("Ready")); + effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); + return state with + { + Tools = state.Tools.Clear(), + SubAgents = state.SubAgents.Clear(), + PendingApproval = null, + IsProcessing = false, + TurnNumber = Math.Max(state.TurnNumber + 1, completed.TurnNumber.Value + 1), + CurrentTurnId = null + }; + } + + private static ChatPresentationState Commit( + ChatPresentationState state, + ChatPresentationBlock block, + List effects) + { + effects.Add(new ChatPresentationEffect.Commit(block)); + return state with { Transcript = state.Transcript.Add(block) }; + } + + private static ChatPresentationBlock ResumeBlock(SessionTranscriptEntry entry, int index) + { + var kind = entry.Type switch + { + SessionTranscriptEntryTypes.User => ChatBlockKind.User, + SessionTranscriptEntryTypes.Assistant => ChatBlockKind.Assistant, + SessionTranscriptEntryTypes.Tool => ChatBlockKind.Tool, + SessionTranscriptEntryTypes.SubAgent => ChatBlockKind.SubAgent, + SessionTranscriptEntryTypes.File => ChatBlockKind.File, + SessionTranscriptEntryTypes.Error => ChatBlockKind.Error, + SessionTranscriptEntryTypes.Usage => ChatBlockKind.Usage, + SessionTranscriptEntryTypes.Compaction => ChatBlockKind.Compaction, + _ => ChatBlockKind.Diagnostic + }; + var label = Label(kind); + var summary = kind switch + { + ChatBlockKind.User or ChatBlockKind.Assistant => entry.Text ?? string.Empty, + ChatBlockKind.Tool => $"✓ {entry.ToolName ?? "unknown"} · {FirstLine(entry.Result)}", + ChatBlockKind.SubAgent => $"{entry.AgentName ?? "sub-agent"} · {entry.Outcome ?? "complete"}", + ChatBlockKind.File => $"{entry.FileName ?? "file"} · {entry.FilePath}", + ChatBlockKind.Error => entry.ErrorMessage ?? "Unknown error", + ChatBlockKind.Usage => UsageSummary(entry), + ChatBlockKind.Compaction => $"{entry.MessagesBefore ?? 0} → {entry.MessagesAfter ?? 0} messages", + _ => entry.Text ?? $"Unsupported transcript entry: {entry.Type}" + }; + var detail = ResumeDetail(entry); + var identity = entry.CallId ?? entry.RunId ?? entry.TurnId ?? index.ToString(System.Globalization.CultureInfo.InvariantCulture); + return new ChatPresentationBlock( + $"resume:{entry.Type}:{identity}:{index}", + kind, + label, + summary, + $"{label}\n{detail}", + entry.TimestampMs, + entry.TurnId, + detail, + kind == ChatBlockKind.Error || string.Equals(entry.Outcome, "failed", StringComparison.Ordinal)); + } + + private static ChatPresentationBlock UsageBlock(UsageOutput usage, string? turnId) + { + var summary = $"{usage.InputTokens ?? 0} in · {usage.OutputTokens ?? 0} out" + + (usage.ReasoningTokens is > 0 ? $" · {usage.ReasoningTokens} thought" : string.Empty) + + (usage.UsagePercent is not null ? $" · {usage.UsagePercent:P0} context" : string.Empty); + var detail = $"Input tokens: {usage.InputTokens ?? 0}\nOutput tokens: {usage.OutputTokens ?? 0}" + + $"\nCached input tokens: {usage.CachedInputTokens ?? 0}" + + $"\nReasoning tokens: {usage.ReasoningTokens ?? 0}" + + (usage.PromptMs is null ? string.Empty : $"\nPrompt time: {usage.PromptMs:F1} ms") + + (usage.PredictedPerSecond is null ? string.Empty : $"\nSpeed: {usage.PredictedPerSecond:F1} tokens/s"); + return new ChatPresentationBlock( + $"usage:{usage.TimestampMs}", + ChatBlockKind.Usage, + "USAGE", + summary, + $"Usage\n{detail}", + usage.TimestampMs, + turnId, + detail); + } + + private static ChatPresentationBlock ErrorBlock(ErrorOutput error, string? turnId) + { + var detail = $"Category: {error.Category}\nCorrelation: {error.CorrelationId:D}" + + (error.Cause is null ? string.Empty : $"\n{error.Cause}"); + return new ChatPresentationBlock( + $"error:{error.CorrelationId:D}", + ChatBlockKind.Error, + "ERROR", + error.Message, + $"Error: {error.Message}\n{detail}", + error.TimestampMs, + turnId, + detail, + true); + } + + private static ChatPresentationBlock FileBlock(FileOutput file, string? turnId) + { + var detail = $"Name: {file.FileName}\nType: {file.MimeType.Value}\nPath: {file.FilePath}"; + return new ChatPresentationBlock( + $"file:{file.TimestampMs}:{file.FilePath}", + ChatBlockKind.File, + "FILE", + $"{file.FileName} · {file.MimeType.Value}", + detail, + file.TimestampMs, + turnId, + detail); + } + + private static ChatPresentationBlock CompactionBlock(CompactionOutput output, string? turnId) + { + var detail = $"Messages: {output.MessagesBefore} → {output.MessagesAfter}" + + $"\nTool results cleared: {output.ToolResultsCleared}" + + $"\nSummary created: {output.Summarized}" + + $"\nInput tokens: {output.PreCompactionInputTokens}" + + $"\nKeep count: {output.KeepCountUsed}"; + return new ChatPresentationBlock( + $"compaction:{output.TimestampMs}", + ChatBlockKind.Compaction, + "CONTEXT", + $"{output.MessagesBefore} → {output.MessagesAfter} messages", + $"Context compaction\n{detail}", + output.TimestampMs, + turnId, + detail); + } + + private static ChatPresentationBlock DiagnosticBlock( + string key, + string text, + long timestampMs, + string? turnId) => new( + key, + ChatBlockKind.Diagnostic, + "DIAGNOSTIC", + text, + text, + timestampMs, + turnId, + text, + true); + + private static string ResumeDetail(SessionTranscriptEntry entry) => entry.Type switch + { + SessionTranscriptEntryTypes.User or SessionTranscriptEntryTypes.Assistant => entry.Text ?? string.Empty, + SessionTranscriptEntryTypes.Tool => $"Tool: {entry.ToolName ?? "unknown"}\nCall: {entry.CallId ?? "unknown"}" + + (entry.ArgumentsJson is null ? string.Empty : $"\nArguments: {entry.ArgumentsJson}") + + $"\nResult: {entry.Result ?? string.Empty}", + SessionTranscriptEntryTypes.SubAgent => $"Agent: {entry.AgentName ?? "unknown"}\nRun: {entry.RunId ?? "unknown"}" + + $"\nOutcome: {entry.Outcome ?? "unknown"}" + + (entry.OutcomeReason is null ? string.Empty : $"\nReason: {entry.OutcomeReason}"), + SessionTranscriptEntryTypes.File => $"Name: {entry.FileName}\nType: {entry.MimeType}\nPath: {entry.FilePath}", + SessionTranscriptEntryTypes.Error => $"Error: {entry.ErrorMessage}\nCategory: {entry.ErrorCategory}" + + $"\nCorrelation: {entry.ErrorCorrelationId}" + + (entry.ErrorDetail is null ? string.Empty : $"\n{entry.ErrorDetail}"), + SessionTranscriptEntryTypes.Usage => UsageSummary(entry), + SessionTranscriptEntryTypes.Compaction => $"Messages: {entry.MessagesBefore ?? 0} → {entry.MessagesAfter ?? 0}", + _ => entry.Text ?? $"Unsupported transcript entry: {entry.Type}" + }; + + private static string UsageSummary(SessionTranscriptEntry entry) => + $"{entry.InputTokens ?? 0} in · {entry.OutputTokens ?? 0} out" + + (entry.ReasoningTokens is > 0 ? $" · {entry.ReasoningTokens} thought" : string.Empty); + + private static string Label(ChatBlockKind kind) => kind switch + { + ChatBlockKind.User => "YOU", + ChatBlockKind.Assistant => "NETCLAW", + ChatBlockKind.Thought => "THOUGHT", + ChatBlockKind.Tool => "TOOL", + ChatBlockKind.SubAgent => "AGENT", + ChatBlockKind.File => "FILE", + ChatBlockKind.Error => "ERROR", + ChatBlockKind.Usage => "USAGE", + ChatBlockKind.Compaction => "CONTEXT", + _ => "DIAGNOSTIC" + }; + + private static string FirstLine(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return "complete"; + + var end = text.IndexOfAny(['\r', '\n']); + var first = end < 0 ? text : text[..end]; + return first.Length <= 100 ? first : string.Concat(first.AsSpan(0, 97), "..."); + } +} diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index a32a641a1..43c539841 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -180,7 +180,7 @@ or DaemonConnectionState.Reconnecting /// /// Submit user text to the session pipeline. /// - public async Task SubmitAsync(string text) + public virtual async Task SubmitAsync(string text) { if (string.IsNullOrWhiteSpace(text)) return; @@ -396,6 +396,14 @@ internal void SeedPendingInteractionForTesting(ToolInteractionRequest interactio RequestRedraw(); } + /// + /// Test seam that publishes a session output without a daemon connection. + /// + internal void PublishOutputForTesting(SessionOutput output) + { + _outputSubject.OnNext(output); + } + /// /// Opens the per-session USAGE log file if not already open. Matches /// HeadlessChannel's filename and append semantics so a single session diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs new file mode 100644 index 000000000..76306c550 --- /dev/null +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -0,0 +1,789 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using R3; +using Termina.Clipboard; +using Termina.Input; +using Termina.Layout; +using Termina.Reactive; +using Termina.Rendering; +using Termina.Terminal; +using static Netclaw.Actors.Sessions.SessionProtocol; + +namespace Netclaw.Cli.Tui; + +/// +/// Primary-buffer chat page. Stable blocks enter terminal scrollback. +/// The live region contains activity, approvals, the composer, and status. +/// +public sealed class InlineChatPage : ReactivePage +{ + private static readonly TimeSpan DoubleEscapeWindow = TimeSpan.FromMilliseconds(500); + + private readonly IAnsiTerminal _terminal; + private readonly IInlineOutput _inlineOutput; + private readonly IClipboardService _clipboardService; + private readonly TimeProvider _timeProvider; + private readonly object _commitLock = new(); + private readonly CompositeDisposable _approvalSubscriptions = []; + + private TextAreaNode _promptInput = null!; + private DynamicLayoutNode _liveRegion = null!; + private SelectionListNode? _approvalList; + private ScrollableContainerNode? _approvalDetail; + private CopyableTextNode? _inspectorCopyNode; + private ScrollableContainerNode? _inspectorDetail; + private string? _approvalCallId; + private string? _approvalDetailCallId; + private string? _inspectorBlockKey; + private string? _inspectorCopyStatus; + private ChatPresentationState _state = ChatPresentationState.Empty; + private Task _commitTail = Task.CompletedTask; + private readonly List _deferredInspectorCommits = []; + private long? _lastEscapeTimestamp; + private int _inspectorIndex; + private bool _inspectorOpen; + + public InlineChatPage( + IAnsiTerminal terminal, + IInlineOutput inlineOutput, + IClipboardService clipboardService, + TimeProvider timeProvider) + { + _terminal = terminal; + _inlineOutput = inlineOutput; + _clipboardService = clipboardService; + _timeProvider = timeProvider; + FocusPolicy = FocusPolicy.FirstFocusable; + } + + protected override void OnBound() + { + base.OnBound(); + + _promptInput = new TextAreaNode() + .WithPlaceholder("Ask Netclaw...") + .WithMaxHeight(8) + .WithHistory(100) + .WithNewlineModifier(ConsoleModifiers.Shift); + _liveRegion = new DynamicLayoutNode(BuildLiveRegion); + + _promptInput.Submitted + .Where(text => !string.IsNullOrWhiteSpace(text)) + .Subscribe(SubmitPrompt) + .DisposeWith(Subscriptions); + + ViewModel.SessionOutput + .Subscribe(output => Post(() => ApplyOutput(output))) + .DisposeWith(Subscriptions); + + ViewModel.StatusMessage + .Subscribe(_ => _liveRegion.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.SessionIdDisplay + .Subscribe(_ => _liveRegion.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.IsApprovalDetailVisible + .Subscribe(_ => _liveRegion.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.IsGenerating + .Subscribe(isGenerating => + { + if (isGenerating) + Focus.ClearFocus(); + else if (ShowsComposer(_state)) + Focus.SetFocus(_promptInput); + _liveRegion.Invalidate(); + }) + .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(_ => _liveRegion.Invalidate()) + .DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() => _liveRegion; + + internal int ApprovalDetailScrollOffset => _approvalDetail?.ScrollOffset ?? 0; + + internal bool ApprovalDetailCanScrollDown => _approvalDetail?.CanScrollDown == true; + + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) + { + if (keyInfo.Key == ConsoleKey.Q + && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.RequestAppShutdown(); + return true; + } + + if (_inspectorOpen) + return HandleInspectorInput(keyInfo); + + if (keyInfo.Key == ConsoleKey.Escape) + { + HandleEscape(); + return true; + } + + if (_state.PendingApproval is not null && ViewModel.IsApprovalDetailVisible.Value) + { + if (keyInfo.Key == ConsoleKey.PageUp) + { + _approvalDetail?.PageUp(); + return true; + } + + if (keyInfo.Key == ConsoleKey.PageDown) + { + _approvalDetail?.PageDown(); + return true; + } + } + + if (_state.PendingApproval is not null + && keyInfo.Key == ConsoleKey.O + && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) + { + ViewModel.ToggleApprovalDetail(); + return true; + } + + if (_state.PendingApproval is null + && keyInfo.Key == ConsoleKey.O + && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control) + && _state.Transcript.Count > 0) + { + OpenInspector(); + return true; + } + + return base.HandlePageInput(keyInfo); + } + + private void SubmitPrompt(string text) + { + _promptInput.Clear(); + _lastEscapeTimestamp = null; + ApplyReduction(ChatPresentationReducer.RecordUserPrompt( + _state, + text, + _timeProvider.GetUtcNow().ToUnixTimeMilliseconds())); + _ = ViewModel.SubmitAsync(text); + } + + private void ApplyOutput(SessionOutput output) + { + ApplyReduction(ChatPresentationReducer.Reduce(_state, output)); + } + + private void ApplyReduction(ChatReduction reduction) + { + var hadComposer = ShowsComposer(_state); + var hadApproval = _state.PendingApproval is not null; + _state = reduction.State; + + foreach (var effect in reduction.Effects) + { + switch (effect) + { + case ChatPresentationEffect.Commit commit: + if (_inspectorOpen) + _deferredInspectorCommits.Add(commit.Block); + else + QueueCommit(commit.Block); + break; + case ChatPresentationEffect.SetStatus status: + ViewModel.StatusMessage.Value = status.Text; + break; + } + } + + var hasApproval = _state.PendingApproval is not null; + var hasComposer = ShowsComposer(_state); + if (hadApproval != hasApproval || hadComposer != hasComposer) + { + if (!hasApproval) + ClearApprovalList(); + InvalidateLayout(); + if (hasApproval) + Focus.SetFocus(EnsureApprovalList()); + else if (hasComposer) + Focus.SetFocus(_promptInput); + else + Focus.ClearFocus(); + } + else + { + _liveRegion.Invalidate(); + } + } + + private void QueueCommit(ChatPresentationBlock block) + { + lock (_commitLock) + { + _commitTail = CommitAfterAsync(_commitTail, block); + } + } + + private async Task CommitAfterAsync(Task prior, ChatPresentationBlock block) + { + try + { + await prior.ConfigureAwait(false); + await _inlineOutput.CommitAsync( + ChatPresentationRenderer.BuildStableBlock(block, _terminal.Width), + CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + Post(() => + { + ViewModel.StatusMessage.Value = $"Output failed: {ex.Message}"; + _liveRegion.Invalidate(); + }); + } + } + + private ILayoutNode BuildLiveRegion() + { + if (_inspectorOpen) + return BuildInspector(); + + var content = Layouts.Vertical() + .WithChild(BuildSessionHeader()) + .WithChild(BuildActivityDeck()); + + if (_state.PendingApproval is not null) + content.WithChild(BuildDecisionGate(_state.PendingApproval)); + else if (ShowsComposer(_state)) + content.WithChild(BuildComposer()); + + return content.WithChild(BuildStatusLine()); + } + + private ILayoutNode BuildInspector() + { + var block = _state.Transcript[_inspectorIndex]; + if (_inspectorDetail is null || _inspectorBlockKey != block.Key) + { + _inspectorBlockKey = block.Key; + _inspectorDetail ??= new ScrollableContainerNode() + .WithAutoScroll(AutoScrollPolicy.None) + .WithScrollbar(true); + var semanticText = ChatPresentationRenderer.SemanticCopyText(block.SemanticText); + var displayText = RemoveDuplicateInspectorLabel(semanticText, block.Label); + _inspectorCopyNode = new CopyableTextNode(_clipboardService, displayText) + .WithSemanticContent(semanticText) + .WithHint(null); + _inspectorDetail.WithContent(_inspectorCopyNode); + _inspectorDetail.ScrollToTop(); + } + + var heading = $" INSPECTOR {_inspectorIndex + 1}/{_state.Transcript.Count} {block.Label}"; + var content = Layouts.Vertical() + .WithChild(new TextNode(heading).WithForeground(Color.BrightBlue).Bold()) + .WithChild(_inspectorDetail.Fill()); + if (_inspectorCopyStatus is not null) + { + var color = _inspectorCopyStatus.StartsWith("Copy failed", StringComparison.Ordinal) + ? Color.Red + : Color.Green; + content.WithChild(new TextNode($" {_inspectorCopyStatus}").WithForeground(color)); + } + var help = _terminal.Width >= 86 + ? " ↑↓ event · PgUp/PgDn detail · Y event · Shift+Y turn · Ctrl+O/Esc close" + : " ↑↓ event · PgUp/PgDn detail · Y copy · Esc close"; + content.WithChild(new TextNode(help) + .WithForeground(Color.Gray)); + + return new PanelNode() + .WithBorder(BorderStyle.Single) + .WithBorderColor(Color.BrightBlue) + .WithContent(content) + .Height(Math.Max(8, _terminal.Height - 1)); + } + + private static string RemoveDuplicateInspectorLabel(string text, string label) + { + var prefix = $"{label}\n"; + return text.StartsWith(prefix, StringComparison.Ordinal) + ? text[prefix.Length..] + : text; + } + + private bool HandleInspectorInput(ConsoleKeyInfo keyInfo) + { + switch (keyInfo.Key) + { + case ConsoleKey.Escape: + CloseInspector(); + return true; + case ConsoleKey.O when keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control): + CloseInspector(); + return true; + case ConsoleKey.UpArrow: + SelectInspectorEvent(-1); + return true; + case ConsoleKey.DownArrow: + SelectInspectorEvent(1); + return true; + case ConsoleKey.PageUp: + _inspectorDetail?.PageUp(); + return true; + case ConsoleKey.PageDown: + _inspectorDetail?.PageDown(); + return true; + case ConsoleKey.Home: + _inspectorDetail?.ScrollToTop(); + return true; + case ConsoleKey.End: + _inspectorDetail?.ScrollToBottom(); + return true; + case ConsoleKey.Y: + CopyInspectorSelection(keyInfo.Modifiers.HasFlag(ConsoleModifiers.Shift)); + return true; + default: + return true; + } + } + + private void OpenInspector() + { + _inspectorOpen = true; + _inspectorIndex = FindDefaultInspectorIndex(); + _inspectorBlockKey = null; + _inspectorCopyStatus = null; + _lastEscapeTimestamp = null; + Focus.ClearFocus(); + InvalidateLayout(); + } + + private void CloseInspector() + { + _inspectorOpen = false; + _inspectorBlockKey = null; + _inspectorCopyStatus = null; + InvalidateLayout(); + if (_state.PendingApproval is not null) + Focus.SetFocus(EnsureApprovalList()); + else if (ShowsComposer(_state)) + Focus.SetFocus(_promptInput); + else + Focus.ClearFocus(); + + foreach (var block in _deferredInspectorCommits) + QueueCommit(block); + _deferredInspectorCommits.Clear(); + } + + private void SelectInspectorEvent(int delta) + { + var index = Math.Clamp(_inspectorIndex + delta, 0, _state.Transcript.Count - 1); + if (index == _inspectorIndex) + return; + + _inspectorIndex = index; + _inspectorBlockKey = null; + _inspectorCopyStatus = null; + _liveRegion.Invalidate(); + } + + private void CopyInspectorSelection(bool completeTurn) + { + if (_inspectorCopyNode is null) + return; + + var semanticText = completeTurn + ? ChatPresentationRenderer.BuildSemanticTurn(_state.Transcript, _inspectorIndex) + : ChatPresentationRenderer.SemanticCopyText(_state.Transcript[_inspectorIndex].SemanticText); + _inspectorCopyNode.WithSemanticContent(semanticText); + var success = _inspectorCopyNode.TryCopy(); + _inspectorCopyStatus = success + ? completeTurn ? "Turn copied" : "Event copied" + : "Copy failed. The selected event remains available."; + _liveRegion.Invalidate(); + } + + private ILayoutNode BuildSessionHeader() + { + var session = ViewModel.SessionIdDisplay.Value; + var sessionPart = string.IsNullOrWhiteSpace(session) + ? "new session" + : ChatPresentationRenderer.CompactIdentity(session, 30); + var modelPart = _terminal.Width >= 72 ? $" model {ViewModel.ModelId}" : string.Empty; + return new TextNode($" NETCLAW {sessionPart}{modelPart}") + .WithForeground(Color.BrightBlue) + .Bold(); + } + + private ILayoutNode BuildActivityDeck() + { + var rows = new List(); + var lineWidth = Math.Max(20, _terminal.Width - 2); + if (!string.IsNullOrWhiteSpace(_state.ThoughtText)) + { + rows.Add(new TextNode(ChatPresentationRenderer.OneLine( + $" ◌ THOUGHT {_state.ThoughtText}", + lineWidth)) + .WithForeground(Color.Yellow)); + } + + var agents = _state.SubAgents.Values + .OrderBy(value => value.StartedAtMs) + .ThenBy(value => value.RunId, StringComparer.Ordinal) + .ToList(); + var renderedAgents = new HashSet(StringComparer.Ordinal); + foreach (var tool in _state.Tools.Values + .OrderBy(value => value.StartedAtMs) + .ThenBy(value => value.CallId, StringComparer.Ordinal)) + { + var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; + rows.Add(new TextNode(ChatPresentationRenderer.OneLine( + $" ◌ TOOL {tool.ToolName} {tool.Phase}{summary}", + lineWidth)) + .WithForeground(ActivityColor(tool.Phase))); + + foreach (var run in agents.Where(value => value.ParentCallId == tool.CallId)) + { + rows.Add(BuildAgentActivity(run, lineWidth)); + renderedAgents.Add(run.RunId); + } + } + + foreach (var run in agents.Where(value => !renderedAgents.Contains(value.RunId))) + rows.Add(BuildAgentActivity(run, lineWidth)); + + if (rows.Count == 0 && _state.IsProcessing) + rows.Add(new TextNode(" ◌ WORKING").WithForeground(Color.Yellow)); + + return rows.Count == 0 ? Layouts.Empty() : Layouts.Vertical([.. rows]); + } + + private ILayoutNode BuildComposer() => new PanelNode() + .WithTitle("Composer") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Cyan) + .WithContent(_promptInput) + .HeightAuto(min: 3, max: Math.Max(3, Math.Min(10, _terminal.Height / 3))); + + private ILayoutNode BuildDecisionGate(ToolInteractionRequest approval) + { + var width = Math.Max(20, _terminal.Width - 4); + var gate = Layouts.Vertical() + .WithChild(new TextNode($" APPROVAL {approval.ToolName.Value}") + .WithForeground(Color.Yellow) + .Bold()); + if (ViewModel.IsApprovalDetailVisible.Value) + { + gate.WithChild(EnsureApprovalDetail(approval) + .Height(Math.Clamp(_terminal.Height / 4, 3, 10))); + } + else + { + gate.WithChild(new TextNode(ChatPresentationRenderer.OneLine( + approval.DisplayText, + Math.Max(20, width - 4))) + .WithForeground(Color.White)); + } + + gate + .WithChild(new TextNode( + ViewModel.IsApprovalDetailVisible.Value + ? " PgUp/PgDn scroll · Ctrl+O collapse · Escape deny" + : " Ctrl+O expand · Escape deny") + .WithForeground(Color.Gray)) + .WithChild(EnsureApprovalList()); + + return new PanelNode() + .WithTitle("Decision Gate") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Yellow) + .WithContent(gate) + .HeightAuto(min: 6, max: Math.Max(8, _terminal.Height / 2)); + } + + private SelectionListNode EnsureApprovalList() + { + var approval = _state.PendingApproval + ?? throw new InvalidOperationException("An approval list requires a pending approval."); + if (_approvalList is not null && _approvalCallId == approval.CallId.Value) + return _approvalList; + + ClearApprovalList(); + _approvalCallId = approval.CallId.Value; + _approvalList = Layouts.SelectionList(approval.Options.Select(option => option.Label).ToList()) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Yellow); + _approvalList.SelectionConfirmed + .Subscribe(selected => + { + if (selected.Count > 0) + _ = ViewModel.SubmitInteractionOptionAsync(selected[0]); + }) + .DisposeWith(_approvalSubscriptions); + return _approvalList; + } + + private void ClearApprovalList() + { + _approvalSubscriptions.Clear(); + _approvalList = null; + _approvalCallId = null; + _approvalDetail = null; + _approvalDetailCallId = null; + } + + private ScrollableContainerNode EnsureApprovalDetail(ToolInteractionRequest approval) + { + if (_approvalDetail is not null && _approvalDetailCallId == approval.CallId.Value) + return _approvalDetail; + + _approvalDetailCallId = approval.CallId.Value; + _approvalDetail = new ScrollableContainerNode() + .WithAutoScroll(AutoScrollPolicy.None) + .WithScrollbar(true) + .WithContent(new TextNode(BuildApprovalDetail(approval)).WithForeground(Color.White)); + return _approvalDetail; + } + + private ILayoutNode BuildStatusLine() + { + var status = ViewModel.StatusMessage.Value; + var usage = _state.Transcript.LastOrDefault(block => block.Kind == ChatBlockKind.Usage)?.Summary; + var keys = StatusKeys(_terminal.Width, _state.PendingApproval is not null, ShowsComposer(_state)); + var usagePart = usage is null || _terminal.Width < 120 ? string.Empty : $" · {usage}"; + var text = ChatPresentationRenderer.OneLine($" {status} · {keys}{usagePart}", _terminal.Width); + return new TextNode(text).WithForeground(StatusColor(status)); + } + + private static ILayoutNode BuildAgentActivity(SubAgentActivityPresentation run, int lineWidth) + { + var summary = string.IsNullOrWhiteSpace(run.Summary) ? string.Empty : $" {run.Summary}"; + var prefix = run.ParentCallId is null ? " ↳" : " ↳"; + return new TextNode(ChatPresentationRenderer.OneLine( + $"{prefix} AGENT {run.AgentName} {run.Phase}{summary}", + lineWidth)) + .WithForeground(ActivityColor(run.Phase)); + } + + private static string BuildApprovalDetail(ToolInteractionRequest approval) + { + var lines = new List { approval.DisplayText }; + if (approval.Patterns.Count > 0) + lines.Add($"Patterns: {string.Join(", ", approval.Patterns)}"); + if (approval.CandidateVerbs.Count > 0) + lines.Add($"Verbs: {string.Join(", ", approval.CandidateVerbs)}"); + if (!string.IsNullOrWhiteSpace(approval.Cwd)) + lines.Add($"Directory: {approval.Cwd}"); + if (approval.IsMessy) + lines.Add("Complex command: persistent approval is unavailable."); + if (approval.HasAdoptedContext) + { + var source = approval.HasThirdPartyAdoptedContext ? "third-party context" : "adopted context"; + lines.Add($"Context: {source}; persisted={approval.PersistedAdoptedContext}."); + } + + return ChatPresentationRenderer.VisibleControlText( + string.Join('\n', lines), + ChatViewModel.MaxExpandedApprovalBodyChars); + } + + private static string StatusKeys(int width, bool hasApproval, bool hasComposer) + { + if (hasApproval) + return width >= 88 + ? "↑↓ select · Enter confirm · Ctrl+O detail · Esc deny · Ctrl+Q quit" + : "↑↓ select · Enter confirm · Esc deny"; + if (!hasComposer) + return width >= 70 ? "Work active · Ctrl+O inspect · Ctrl+Q quit" : "Work active · Ctrl+Q quit"; + if (width >= 110) + return "Enter send · Shift+Enter newline · Esc Esc clear · Ctrl+O inspect · Ctrl+Q quit"; + return width >= 66 + ? "Enter send · Shift+Enter line · Esc Esc clear · Ctrl+O inspect" + : "Enter send · Shift+Enter line · Esc Esc clear"; + } + + private static Color ActivityColor(string phase) => phase.ToLowerInvariant() switch + { + "queued" => Color.Gray, + "failed" or "error" or "denied" => Color.Red, + "completed" or "complete" => Color.Green, + _ => Color.Yellow + }; + + private int FindDefaultInspectorIndex() + { + for (var index = _state.Transcript.Count - 1; index >= 0; index--) + { + if (_state.Transcript[index].Kind is not ChatBlockKind.Usage and not ChatBlockKind.System) + return index; + } + + return _state.Transcript.Count - 1; + } + + private void HandleEscape() + { + if (_state.PendingApproval is not null) + { + _lastEscapeTimestamp = null; + _ = ViewModel.DenyPendingInteractionAsync(); + return; + } + + if (ViewModel.IsGenerating.Value) + { + _lastEscapeTimestamp = null; + ViewModel.StatusMessage.Value = "Cancel generation is not supported yet."; + _liveRegion.Invalidate(); + return; + } + + var now = _timeProvider.GetTimestamp(); + if (_lastEscapeTimestamp is { } prior + && _timeProvider.GetElapsedTime(prior, now) <= DoubleEscapeWindow) + { + _promptInput.Clear(); + _lastEscapeTimestamp = null; + ViewModel.StatusMessage.Value = "Input cleared"; + _liveRegion.Invalidate(); + return; + } + + _lastEscapeTimestamp = now; + } + + private static Color StatusColor(string status) => status switch + { + "Ready" => Color.Green, + "Approval required" => Color.Yellow, + _ when status.StartsWith("Connected", StringComparison.Ordinal) => Color.Green, + _ when status.StartsWith("Reconnected", StringComparison.Ordinal) => Color.Green, + _ when status.StartsWith("Generating", StringComparison.Ordinal) => Color.Yellow, + _ when status.StartsWith("Output failed", StringComparison.Ordinal) => Color.Red, + _ when status.StartsWith("Connection failed", StringComparison.Ordinal) => Color.Red, + _ => Color.Gray + }; + + private bool ShowsComposer(ChatPresentationState state) => + state.PendingApproval is null + && !ViewModel.IsGenerating.Value + && !state.IsProcessing + && state.Tools.Count == 0 + && state.SubAgents.Count == 0 + && string.IsNullOrWhiteSpace(state.ThoughtText) + && string.IsNullOrWhiteSpace(state.AssistantText); +} + +internal static class ChatPresentationRenderer +{ + public static ILayoutNode BuildStableBlock(ChatPresentationBlock block, int width) + { + var timestamp = block.TimestampMs > 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(block.TimestampMs).ToString("HH:mm") + : string.Empty; + var timePart = string.IsNullOrEmpty(timestamp) ? string.Empty : $" {timestamp}"; + var body = block.Summary; + + return Layouts.Vertical() + .WithChild(new TextNode($"{block.Label}{timePart}") + .WithForeground(LabelColor(block)) + .Bold()) + .WithChild(new TextNode(VisibleControlText(body, 16_000)) + .WithForeground(BodyColor(block))) + .WithChild(new TextNode(string.Empty)); + } + + public static string OneLine(string? text, int maximumLength) + { + var safe = VisibleControlText(text ?? string.Empty, Math.Max(1, maximumLength)); + var oneLine = safe.Replace("\r", "\\r", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal); + return oneLine.Length <= maximumLength + ? oneLine + : string.Concat(oneLine.AsSpan(0, Math.Max(0, maximumLength - 1)), "…"); + } + + public static string VisibleControlText(string text, int maximumLength) + { + var builder = new System.Text.StringBuilder(Math.Min(text.Length, maximumLength)); + foreach (var character in text) + { + if (builder.Length >= maximumLength) + break; + + if (character is '\n' or '\t') + { + builder.Append(character); + continue; + } + + builder.Append(char.IsControl(character) + ? $"\\u{(int)character:X4}" + : character); + } + + if (text.Length > maximumLength) + builder.Append('…'); + return builder.ToString(); + } + + public static string SemanticCopyText(string text) => VisibleControlText(text, int.MaxValue); + + public static string BuildSemanticTurn( + IReadOnlyList transcript, + int selectedIndex) + { + if (transcript.Count == 0) + return string.Empty; + + var boundedIndex = Math.Clamp(selectedIndex, 0, transcript.Count - 1); + var start = boundedIndex; + while (start > 0 && transcript[start].Kind != ChatBlockKind.User) + start--; + if (transcript[start].Kind != ChatBlockKind.User) + start = boundedIndex; + + var end = boundedIndex + 1; + while (end < transcript.Count && transcript[end].Kind != ChatBlockKind.User) + end++; + + return string.Join( + "\n\n", + transcript.Skip(start).Take(end - start) + .Select(block => SemanticCopyText(block.SemanticText))); + } + + public static string CompactIdentity(string identity, int maximumLength) => + identity.Length <= maximumLength + ? identity + : $"…{identity[^Math.Max(1, maximumLength - 1)..]}"; + + private static Color LabelColor(ChatPresentationBlock block) => block.Kind switch + { + ChatBlockKind.User => Color.Cyan, + ChatBlockKind.Assistant => Color.BrightBlue, + ChatBlockKind.Thought => Color.Yellow, + ChatBlockKind.Tool when block.IsFailure => Color.Red, + ChatBlockKind.Tool => Color.Green, + ChatBlockKind.SubAgent when block.IsFailure => Color.Red, + ChatBlockKind.SubAgent => Color.Green, + ChatBlockKind.Approval => Color.Yellow, + ChatBlockKind.File => Color.Cyan, + ChatBlockKind.Error => Color.Red, + ChatBlockKind.Usage => Color.Gray, + ChatBlockKind.Compaction => Color.Gray, + ChatBlockKind.Diagnostic => Color.Red, + _ => Color.Gray + }; + + private static Color BodyColor(ChatPresentationBlock block) => block.Kind switch + { + ChatBlockKind.Error or ChatBlockKind.Diagnostic => Color.Red, + ChatBlockKind.Usage or ChatBlockKind.Compaction => Color.Gray, + _ => Color.White + }; +} diff --git a/src/Netclaw.Cli/Tui/SessionsViewModel.cs b/src/Netclaw.Cli/Tui/SessionsViewModel.cs index 1497ac603..2195b4bd9 100644 --- a/src/Netclaw.Cli/Tui/SessionsViewModel.cs +++ b/src/Netclaw.Cli/Tui/SessionsViewModel.cs @@ -102,8 +102,8 @@ public bool HandleKey(ConsoleKeyInfo keyInfo) // N starts a new chat (no resume) if (keyInfo.Key == ConsoleKey.N && !keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) { - _navigationState.ResumeSessionId = null; - Navigate?.Invoke("/chat"); + _navigationState.RequestChatLaunch(null); + Shutdown(); return true; } @@ -112,8 +112,8 @@ public bool HandleKey(ConsoleKeyInfo keyInfo) // Enter on empty state starts a new chat if (keyInfo.Key == ConsoleKey.Enter) { - _navigationState.ResumeSessionId = null; - Navigate?.Invoke("/chat"); + _navigationState.RequestChatLaunch(null); + Shutdown(); return true; } return false; @@ -149,8 +149,8 @@ public bool HandleKey(ConsoleKeyInfo keyInfo) case ConsoleKey.Enter: var selected = Sessions[SelectedIndex.Value]; - _navigationState.ResumeSessionId = selected.SessionId; - Navigate?.Invoke("/chat"); + _navigationState.RequestChatLaunch(selected.SessionId); + Shutdown(); return true; default: diff --git a/src/Netclaw.Cli/Tui/TerminalRuntimeProfiles.cs b/src/Netclaw.Cli/Tui/TerminalRuntimeProfiles.cs new file mode 100644 index 000000000..7a1ca25fd --- /dev/null +++ b/src/Netclaw.Cli/Tui/TerminalRuntimeProfiles.cs @@ -0,0 +1,29 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Termina.Hosting; +using Termina.Input; +using Termina.Terminal; + +namespace Netclaw.Cli.Tui; + +internal static class TerminalRuntimeProfiles +{ + public static void ConfigureFullScreenSelection(TerminaRuntimeOptions options) + { + options.PresentationMode = TerminalPresentationMode.FullScreen; + options.PreferRawInput = true; + options.ScrollInputMode = ScrollInputMode.AlternateScroll; + options.CtrlCHandlingMode = CtrlCHandlingMode.DoublePressWhenRawInput; + } + + public static void ConfigureInlineChat(TerminaRuntimeOptions options) + { + options.PresentationMode = TerminalPresentationMode.Inline; + options.PreferRawInput = true; + options.ScrollInputMode = ScrollInputMode.NativeTerminal; + options.CtrlCHandlingMode = CtrlCHandlingMode.DoublePressWhenRawInput; + } +} diff --git a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs index 04dc511b9..770c602fb 100644 --- a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs +++ b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs @@ -109,6 +109,9 @@ public sealed record SubAgentNotificationInfo public required SubAgentRunId RunId { get; init; } public required string AgentName { get; init; } public required bool IsStarted { get; init; } + public bool IsActivity { get; init; } + public string? ActivityPhase { get; init; } + public string? ActivitySummary { get; init; } public int ToolCount { get; init; } public bool Success { get; init; } public SubAgentRunOutcome? Outcome { get; init; } diff --git a/tests/smoke/tapes/chat-inline.tape b/tests/smoke/tapes/chat-inline.tape new file mode 100644 index 000000000..7a4e7b1da --- /dev/null +++ b/tests/smoke/tapes/chat-inline.tape @@ -0,0 +1,49 @@ +# chat-inline.tape — drive the primary-buffer chat view through a real turn. + +Output "/tmp/tape-chat-inline.gif" + +Type "mkdir -p $NETCLAW_HOME/config" +Enter +Type "provider=ollama endpoint=http://localhost:11434 model=qwen2:0.5b role=smoke jq -n '{configVersion:1,Providers:{($ENV.provider):{Type:$ENV.provider,Endpoint:$ENV.endpoint}},Models:{Definitions:{($ENV.role):{Provider:$ENV.provider,ModelId:$ENV.model}},Roles:{Main:$ENV.role}}}' > $NETCLAW_HOME/config/netclaw.json" +Enter +Wait+Screen@5s /TAPE\$/ + +Type "netclaw daemon start" +Enter +Wait+Screen@30s /Daemon started|already running/ +Wait+Screen@5s /TAPE\$/ +Type "until netclaw status >/dev/null 2>&1; do sleep 1; done; echo DAEMON_READY" +Enter +Wait+Screen@90s /DAEMON_READY/ +Wait+Screen@5s /TAPE\$/ + +Type "netclaw chat" +Enter +Wait+Screen@60s /Composer/ + +Type "Say hello" +Shift+Enter +Type "in one word" +Enter +Wait+Screen@15s /Generating/ +Wait+Screen@120s /USAGE/ +Wait+Screen@20s /Ready/ +Wait+Screen@20s /Composer/ + +Up +Escape +Escape +Wait+Screen@10s /Input cleared/ + +Ctrl+O +Wait+Screen@10s /INSPECTOR/ +Escape +Ctrl+Q +Sleep 1s +Wait+Screen@10s /TAPE\$/ + +Type "echo CHAT_INLINE_EXIT=$?" +Enter +Wait+Screen@5s /CHAT_INLINE_EXIT=0/ +Type "exit" +Enter From 15588d422559db9418194df06646577e3b92c4a8 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 18:05:52 -0500 Subject: [PATCH 03/39] Polish the chat status and Inspector --- src/Netclaw.Cli/Tui/InlineChatPage.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 76306c550..78c48998b 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -299,11 +299,15 @@ private ILayoutNode BuildInspector() content.WithChild(new TextNode(help) .WithForeground(Color.Gray)); - return new PanelNode() + var panel = new PanelNode() .WithBorder(BorderStyle.Single) .WithBorderColor(Color.BrightBlue) .WithContent(content) - .Height(Math.Max(8, _terminal.Height - 1)); + .Height(Math.Max(8, _terminal.Height - 4)); + + return Layouts.Vertical() + .WithChild(Layouts.Empty().Height(1)) + .WithChild(panel); } private static string RemoveDuplicateInspectorLabel(string text, string label) @@ -552,10 +556,8 @@ private ScrollableContainerNode EnsureApprovalDetail(ToolInteractionRequest appr private ILayoutNode BuildStatusLine() { var status = ViewModel.StatusMessage.Value; - var usage = _state.Transcript.LastOrDefault(block => block.Kind == ChatBlockKind.Usage)?.Summary; var keys = StatusKeys(_terminal.Width, _state.PendingApproval is not null, ShowsComposer(_state)); - var usagePart = usage is null || _terminal.Width < 120 ? string.Empty : $" · {usage}"; - var text = ChatPresentationRenderer.OneLine($" {status} · {keys}{usagePart}", _terminal.Width); + var text = ChatPresentationRenderer.OneLine($" {status} · {keys}", _terminal.Width); return new TextNode(text).WithForeground(StatusColor(status)); } From c3c57129d7a8a7dbeea80bf69fdf2e718dc1c62a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 20:22:20 -0500 Subject: [PATCH 04/39] Complete rich chat event presentation --- .../SPEC-010-testing-and-smoke-strategy.md | 14 +- .../redesign-netclaw-chat-tui/design.md | 17 +- .../redesign-netclaw-chat-tui/proposal.md | 8 +- .../specs/netclaw-testing/spec.md | 23 +- .../redesign-netclaw-chat-tui/tasks.md | 6 +- scripts/smoke/run-smoke.sh | 2 +- .../Protocol/SerializationRoundTripTests.cs | 51 +++- .../Sessions/ApprovalRehydrationTests.cs | 43 +++ .../Protocol/SessionOutputDto.cs | 7 + .../Protocol/SessionOutputDtoMapper.cs | 28 +- .../Protocol/SessionTranscriptEntry.cs | 7 + .../Serialization/NetclawProtoMapper.cs | 8 +- .../Protos/netclaw_messages.proto | 3 + .../Sessions/LlmSessionActor.cs | 31 +- .../Sessions/SessionProtocol.Outputs.cs | 25 ++ .../Sessions/SessionTranscriptEntryFactory.cs | 13 + .../Cli/DaemonClientSessionTests.cs | 44 +++ .../Tui/ChatPresentationReducerTests.cs | 163 +++++++++++ .../Tui/InlineChatPageTests.cs | 169 ++++++++++- src/Netclaw.Cli/Tui/ChatPresentation.cs | 224 ++++++++++++-- src/Netclaw.Cli/Tui/ChatViewModel.cs | 6 +- src/Netclaw.Cli/Tui/InlineChatPage.cs | 273 +++++++++++++++--- tests/smoke/tapes/chat-inline.tape | 49 ---- 23 files changed, 1055 insertions(+), 159 deletions(-) delete mode 100644 tests/smoke/tapes/chat-inline.tape diff --git a/docs/spec/SPEC-010-testing-and-smoke-strategy.md b/docs/spec/SPEC-010-testing-and-smoke-strategy.md index 8e5531dc3..8686fb27a 100644 --- a/docs/spec/SPEC-010-testing-and-smoke-strategy.md +++ b/docs/spec/SPEC-010-testing-and-smoke-strategy.md @@ -31,8 +31,8 @@ tests can validate real provider integrations. ## Chat TUI Proof Matrix -The chat redesign requires deterministic headless proof and native terminal -proof. Unit tests alone cannot satisfy this contract. +The chat redesign requires deterministic headless proof and visual review. +Unit tests alone cannot validate the visual grammar. Headless tests SHALL cover: @@ -44,9 +44,13 @@ Headless tests SHALL cover: - `Shift+Enter`, history draft restore, and double Escape with virtual time - approval priority, `Ctrl+O`, detail scroll, paste, and semantic copy -The native Netclaw tape SHALL use the published CLI. It SHALL cover startup, -typed input, paste, resize, approval detail, prompt recall, interruption, and -clean exit. Each action SHALL use a semantic anchor and no fixed delay. +Development review SHALL use three disposable video checkpoints outside the +repository. The checkpoints SHALL cover the core chat, rich activity with +approval, and the Inspector with responsive layout. + +Each checkpoint SHALL produce a temporary video and selected lossless frame +images. A developer SHALL review these files and record material visual defects. +The tapes SHALL not enter CI or the permanent smoke suite. The Termina package SHALL provide separate primary-buffer proof for Linux, macOS, Windows Terminal, and tmux. The proof SHALL cover resize, paste, diff --git a/openspec/changes/redesign-netclaw-chat-tui/design.md b/openspec/changes/redesign-netclaw-chat-tui/design.md index 51f5afba4..469c7073e 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/design.md +++ b/openspec/changes/redesign-netclaw-chat-tui/design.md @@ -32,7 +32,7 @@ It also affects users who depend on terminal scrollback, keyboard input, approva - Add safe prompt history cancellation and semantic copy. - Restore structured settled history after a session resume. - Preserve released API, wire, persistence, and full-screen behavior. -- Prove the terminal contract with deterministic and native tests. +- Prove behavior with deterministic tests and disposable visual checkpoints. **Non-Goals:** @@ -290,7 +290,7 @@ The inspector will use the same redaction and control-character policy as semant This choice keeps the inline transcript quiet without data loss. It also avoids a large bordered panel in the primary scrollback. -### D12: Prove both compatibility and terminal behavior +### D12: Prove compatibility and review the visual grammar Termina tests will approve the public API surface for the released baseline and the new surface. They will verify the numeric values of all changed enums. @@ -306,9 +306,14 @@ They will cover structured resume and old payload conversion. Typed-key tests will cover `Shift+Enter`, history draft restoration, double Escape, and approval priority. They will use `TimeProvider` and will not use time delays. -The native smoke harness will cover the inline chat path. -The validation matrix will include Linux, macOS, Windows Terminal, and tmux. -The matrix will cover mouse wheel scroll, resize, selection, paste, `Ctrl+O`, cancellation, and process exit. +Three disposable video checkpoints will cover the inline chat path. +They will cover the core chat, rich activity with approval, and the Inspector. +The last checkpoint will also cover narrow width and resize behavior. + +Each tape will stay under `/tmp` and outside the repository. +Each review will use the video and selected lossless frame images. +The reviewer will record material visual defects before the next checkpoint. +The tapes will not become CI assets or permanent smoke tests. ## Actor Boundaries and Persistence @@ -365,7 +370,7 @@ The actor will rebuild a bounded transcript from journal events and snapshots. 10. Add new session output records and complete SignalR mappings. 11. Add structured timeline read support and legacy fixtures. 12. Enable structured timeline writes after the read tests pass. -13. Add typed-key, headless, responsive, and native smoke proof. +13. Add typed-key, headless, responsive, and disposable visual proof. 14. Run Slopwatch, file-header verification, and the required smoke suite. 15. Verify the OpenSpec change before archive. diff --git a/openspec/changes/redesign-netclaw-chat-tui/proposal.md b/openspec/changes/redesign-netclaw-chat-tui/proposal.md index 2081265dd..d902e6ab5 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/proposal.md +++ b/openspec/changes/redesign-netclaw-chat-tui/proposal.md @@ -26,7 +26,7 @@ the current defects. - Add prompt draft restoration and double-Escape prompt clearance. - Preserve complete event detail through an inspector and semantic copy path. - Preserve structured event chronology after session resume. -- Add deterministic headless tests and a native chat smoke tape. +- Add deterministic headless tests and disposable visual checkpoint videos. - Update `PRD-004` and the old TUI wireframe before implementation begins. - Reuse Netclaw issues `#577` and `#1338` for their original defects. - Reuse Termina issues `#45` and `#240` where their scopes match this work. @@ -57,8 +57,8 @@ the current defects. activity output. - `session-resume`: Restores structured settled events instead of role and text content alone. -- `netclaw-testing`: Requires event-contract coverage and native terminal proof - for the chat surface. +- `netclaw-testing`: Requires event-contract coverage and disposable visual + proof for the chat surface. ## Impact @@ -68,7 +68,7 @@ the current defects. - Session output records and SignalR DTOs gain additive correlation and detail fields. - Session resume gains a structured history representation. -- Native smoke gains an interactive chat tape. +- Development review gains temporary chat videos and selected frame images. - `PRD-004`, engineering specifications, and TUI wireframes change. ### Termina diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md index 85583339e..dd87704ea 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md @@ -55,21 +55,22 @@ multiline paste. Time-based key sequences SHALL use `TimeProvider`. window - **THEN** the input clears without `Task.Delay` or `Thread.Sleep` -### Requirement: Native chat smoke proves the terminal contract +### Requirement: Disposable visual checkpoints prove the chat grammar -The native smoke harness SHALL run the real published CLI and SHALL prove chat -startup, typed input, multiline input, paste, approval detail, prompt recall, -double Escape, resize, interruption, and clean shutdown. +Development review SHALL use temporary video tapes outside the repository. +These tapes SHALL not enter CI or the permanent smoke suite. -The Termina dependency SHALL provide separate native proof for primary-buffer -scrollback, native selection, Linux, macOS, Windows Terminal, and tmux. +The review SHALL cover the core chat, rich activity with approval, and the +Inspector with responsive layout. Each review SHALL retain a video and selected +frame images as temporary proof. -#### Scenario: Native inline chat flow +#### Scenario: Visual checkpoint review -- **WHEN** the `netclaw chat` native tape runs against a deterministic daemon -- **THEN** the tape proves the primary chat flow through semantic anchors -- **AND** each nontrivial action has an assertion -- **AND** the tape uses no fixed sleep +- **WHEN** a developer reaches one of the three visual checkpoints +- **THEN** a temporary tape runs the real published CLI against a test daemon +- **AND** the developer reviews the video and selected frame images +- **AND** the developer records material visual defects before the next checkpoint +- **AND** no checkpoint tape becomes a CI or repository asset #### Scenario: Full-screen regression suite diff --git a/openspec/changes/redesign-netclaw-chat-tui/tasks.md b/openspec/changes/redesign-netclaw-chat-tui/tasks.md index 7c8241ed1..0c7b5d91a 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/tasks.md +++ b/openspec/changes/redesign-netclaw-chat-tui/tasks.md @@ -126,12 +126,12 @@ ## 11. Verification and Completion -- [ ] 11.1 Add headless tests that inject every `SessionOutput` subtype. +- [x] 11.1 Add headless tests that inject every `SessionOutput` subtype. - [x] 11.2 Add typed-key tests for prompt, paste, history, Escape, approval, inspector, and copy flows. -- [ ] 11.3 Add a native chat smoke tape with typed input, paste, wheel input, resize, approval, and exit recovery. +- [x] 11.3 Record and review the three disposable visual checkpoint videos outside the repository. - [x] 11.4 Run `./scripts/smoke/run-smoke.sh light` and retain the result. - [x] 11.5 Run the focused Netclaw actor, protocol, CLI, and TUI test suites. -- [ ] 11.6 Run `dotnet slopwatch analyze` in each repository that contains code changes. +- [x] 11.6 Run `dotnet slopwatch analyze` in each repository that contains code changes. - [x] 11.7 Run `./scripts/Add-FileHeaders.ps1 -Verify` for Netclaw C# changes. - [ ] 11.8 Verify each issue acceptance criterion against tests or native evidence. - [ ] 11.9 Run OpenSpec verification and resolve every mismatch. diff --git a/scripts/smoke/run-smoke.sh b/scripts/smoke/run-smoke.sh index d15f61baf..2043f5416 100755 --- a/scripts/smoke/run-smoke.sh +++ b/scripts/smoke/run-smoke.sh @@ -54,7 +54,7 @@ SMOKE_LOG_DIR="${SMOKE_LOG_DIR:-${ROOT_DIR}/smoke-logs}" # Cheapest harness checks first so a harness-level break fails fast # before paying for the wizard + probe tapes. -LIGHT_TAPES=(help init-wizard init-existing init-redo-identity provider-add provider-rename config-search config-exposure config-posture config-features config-audience config-channels config-mention-thread config-surfaces config-ops-surfaces config-workspaces-picker config-skill-picker config-back-nav tui-cleanup mcp-permissions approvals model-manager sessions-tui chat-inline) +LIGHT_TAPES=(help init-wizard init-existing init-redo-identity provider-add provider-rename config-search config-exposure config-posture config-features config-audience config-channels config-mention-thread config-surfaces config-ops-surfaces config-workspaces-picker config-skill-picker config-back-nav tui-cleanup mcp-permissions approvals model-manager sessions-tui) FULL_TAPES=("${LIGHT_TAPES[@]}") LIGHT_SCENARIOS=( diff --git a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs index 88e703f49..ffe690c81 100644 --- a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs @@ -151,13 +151,25 @@ public void TurnRecorded_round_trips_structured_transcript_entries() CallId = "call-1", ToolName = "shell_execute", ArgumentsJson = "{\"command\":\"dotnet test\"}", - Result = "Passed" + Result = "Passed", + BatchId = "batch-1", + BatchSize = 2 }, new SessionTranscriptEntry { - Type = SessionTranscriptEntryTypes.Usage, + Type = SessionTranscriptEntryTypes.Approval, TurnId = "turn-1", TimestampMs = 1_700_000_000_002, + CallId = "call-approval", + ParentCallId = "call-1", + ToolName = "shell_execute", + ApprovalSelectedKey = ApprovalOptionKeys.Deny + }, + new SessionTranscriptEntry + { + Type = SessionTranscriptEntryTypes.Usage, + TurnId = "turn-1", + TimestampMs = 1_700_000_000_003, InputTokens = 10, OutputTokens = 4, TotalTokens = 14, @@ -172,7 +184,7 @@ public void TurnRecorded_round_trips_structured_transcript_entries() { Type = SessionTranscriptEntryTypes.Error, TurnId = "turn-1", - TimestampMs = 1_700_000_000_003, + TimestampMs = 1_700_000_000_004, ErrorMessage = "Failed", ErrorDetail = "detail", ErrorCorrelationId = "correlation", @@ -186,6 +198,39 @@ public void TurnRecorded_round_trips_structured_transcript_entries() Assert.Equal(original.TranscriptEntries, result.TranscriptEntries); } + [Fact] + public void Session_output_DTO_round_trips_parallel_and_approval_fields() + { + var tool = new ToolCallOutput + { + SessionId = new SessionId("test/wire"), + TimestampMs = 1, + CallId = new ToolCallId("call-1"), + ToolName = new ToolName("search"), + ArgumentsJson = "{}", + BatchId = "batch-1", + BatchSize = 2 + }; + var toolResult = Assert.IsType( + SessionOutputDtoMapper.FromDto(SessionOutputDtoMapper.ToDto(tool))); + Assert.Equal("batch-1", toolResult.BatchId); + Assert.Equal(2, toolResult.BatchSize); + + var approval = new ApprovalOutcomeOutput + { + SessionId = new SessionId("test/wire"), + TimestampMs = 2, + CallId = new ToolCallId("call-approval"), + ToolName = new ToolName("shell_execute"), + ParentCallId = "call-1", + SelectedKey = ApprovalOptionKeys.DenyKey + }; + var approvalResult = Assert.IsType( + SessionOutputDtoMapper.FromDto(SessionOutputDtoMapper.ToDto(approval))); + Assert.Equal("call-1", approvalResult.ParentCallId); + Assert.Equal(ApprovalOptionKeys.DenyKey, approvalResult.SelectedKey); + } + [Fact] public void Old_TurnRecorded_proto_reads_with_an_empty_transcript() { diff --git a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs index 68923a476..cb037f7c3 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs @@ -134,6 +134,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("local-user") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, callId, ApprovalOptionKeys.ApproveOnce); + // The parked batch re-drives: the tool executes successfully (the // ApprovedOnce pre-seed bypassed the gate without a duplicate prompt) // and the follow-up LLM call produces a final text response. @@ -226,6 +228,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("local-user") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, callId, ApprovalOptionKeys.ApproveOnce); + await subscriberB.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriberB.ExpectMsgAsync( @@ -297,6 +301,7 @@ await subscriber.ExpectMsgAsync( }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.IsType(validReply); + await ExpectApprovalOutcomeAsync(subscriber, callId, ApprovalOptionKeys.ApproveOnce); await subscriber.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriber.ExpectMsgAsync( @@ -361,6 +366,7 @@ await sessionManager.Ask(new JoinSession(subscriberB) }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.IsType(reply); + await ExpectApprovalOutcomeAsync(subscriberB, callId, ApprovalOptionKeys.ApproveOnce); await subscriberB.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriberB.ExpectMsgAsync( @@ -447,6 +453,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("local-user") }); + await ExpectApprovalOutcomeAsync(subscriberB, shellCallId, ApprovalOptionKeys.ApproveOnce); + var shellResult = await subscriberB.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(shellCallId, shellResult.CallId.Value); @@ -529,6 +537,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("local-user") }); + await ExpectApprovalOutcomeAsync(subscriberB, readCallId, ApprovalOptionKeys.ApproveOnce); + // One sibling approval is still pending, so the recovered session must // not advance the LLM with a half-closed assistant tool-call batch. await subscriberB.ExpectNoMsgAsync( @@ -543,6 +553,8 @@ await subscriberB.ExpectNoMsgAsync( SenderId = new SenderId("local-user") }); + await ExpectApprovalOutcomeAsync(subscriberB, shellCallId, ApprovalOptionKeys.ApproveOnce); + var resultCallIds = new HashSet(StringComparer.Ordinal); await AwaitAssertAsync(async () => { @@ -620,6 +632,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("local-user") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, callId, ApprovalOptionKeys.ApproveOnce); + await _toolExecutor.BlockedExecutionStarted.Task.WaitAsync( TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -711,6 +725,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("local-user") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, callId, ApprovalOptionKeys.ApproveOnce); + await _toolExecutor.BlockedExecutionStarted.Task.WaitAsync( TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -779,6 +795,8 @@ await subscriber.ExpectMsgAsync( SenderId = new SenderId("local-user") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriber, callId, ApprovalOptionKeys.ApproveOnce); + await _toolExecutor.BlockedExecutionStarted.Task.WaitAsync( TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); @@ -858,6 +876,8 @@ await subscriber.ExpectMsgAsync( SenderId = new SenderId("local-user") }); + await ExpectApprovalOutcomeAsync(subscriber, callId, ApprovalOptionKeys.ApproveOnce); + await subscriber.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriber.ExpectMsgAsync( @@ -1019,6 +1039,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("U-requester") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, callId, ApprovalOptionKeys.ApproveOnce); + await subscriberB.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriberB.ExpectMsgAsync( @@ -1102,6 +1124,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("U-requester") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, parkedCallId, ApprovalOptionKeys.ApproveOnce); + // Drain through the redriven shell_execute result, the LLM continuation // call that produces the read_file batch, and the read_file result. // TurnCompleted comes last when the model returns a plain text reply. @@ -1188,6 +1212,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("U-requester") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, parkedCallId, ApprovalOptionKeys.ApproveOnce); + await subscriberB.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriberB.ExpectMsgAsync( @@ -1207,6 +1233,8 @@ await subscriberB.ExpectMsgAsync( SenderId = new SenderId("U-requester") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, continuationCallId, ApprovalOptionKeys.Deny); + await subscriberB.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriberB.ExpectMsgAsync( @@ -1268,6 +1296,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("local-user") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, callId, ApprovalOptionKeys.Deny); + var toolResult = await subscriberB.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriberB.ExpectMsgAsync( @@ -1403,6 +1433,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("U-requester") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, callId, ApprovalOptionKeys.ApproveOnce); + await subscriberB.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriberB.ExpectMsgAsync( @@ -1557,6 +1589,17 @@ private async Task ColdRespawnAsync(SessionId sessionId) await ExpectTerminatedAsync(child, cancellationToken: TestContext.Current.CancellationToken); } + private static async Task ExpectApprovalOutcomeAsync( + Akka.TestKit.TestProbe subscriber, + string callId, + string selectedKey) + { + var outcome = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(callId, outcome.CallId.Value); + Assert.Equal(selectedKey, outcome.SelectedKey.Value); + } + private MessageSource RequesterSource(string senderId) => new() { ChannelType = ChannelType.Slack, diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index e5c68f888..ace267615 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -33,6 +33,7 @@ public static class SessionOutputTypes public const string Compaction = "compaction"; public const string SessionJoined = "session_joined"; public const string ToolInteraction = "tool_interaction"; + public const string ApprovalOutcome = "approval_outcome"; public const string Unknown = "unknown"; } @@ -71,6 +72,8 @@ public sealed record SessionOutputDto public string? TurnId { get; init; } public string? ActivityPhase { get; init; } public string? ActivitySummary { get; init; } + public string? ToolBatchId { get; init; } + public int? ToolBatchSize { get; init; } // Usage public long? InputTokens { get; init; } @@ -129,6 +132,10 @@ public sealed record SessionOutputDto public List? InteractionAdoptedSpeakerIds { get; init; } public bool? InteractionPersistedAdoptedContext { get; init; } + // Approval Outcome + public string? ApprovalSelectedKey { get; init; } + public string? ApprovalParentCallId { get; init; } + // SubAgent public string? AgentName { get; init; } public string? Phase { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index 3dbd1f7ac..160a9fd13 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -59,7 +59,9 @@ public static class SessionOutputDtoMapper TimestampMs = msg.TimestampMs, CallId = msg.CallId.Value, ToolName = msg.ToolName.Value, - ArgumentsJson = msg.ArgumentsJson + ArgumentsJson = msg.ArgumentsJson, + ToolBatchId = msg.BatchId, + ToolBatchSize = msg.BatchSize }, ToolActivityOutput msg => new SessionOutputDto @@ -228,6 +230,17 @@ public static class SessionOutputDtoMapper InteractionPersistedAdoptedContext = msg.PersistedAdoptedContext }, + ApprovalOutcomeOutput msg => new SessionOutputDto + { + Type = SessionOutputTypes.ApprovalOutcome, + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + CallId = msg.CallId.Value, + ToolName = msg.ToolName.Value, + ApprovalSelectedKey = msg.SelectedKey.Value, + ApprovalParentCallId = msg.ParentCallId + }, + _ => new SessionOutputDto { Type = SessionOutputTypes.Unknown, @@ -268,7 +281,9 @@ public static SessionOutput FromDto(SessionOutputDto dto) TimestampMs = dto.TimestampMs, CallId = new Netclaw.Tools.ToolCallId(dto.CallId ?? string.Empty), ToolName = new Netclaw.Tools.ToolName(dto.ToolName ?? "unknown"), - ArgumentsJson = dto.ArgumentsJson + ArgumentsJson = dto.ArgumentsJson, + BatchId = dto.ToolBatchId ?? string.Empty, + BatchSize = dto.ToolBatchSize ?? 1 }, SessionOutputTypes.ToolActivity => new ToolActivityOutput { @@ -394,6 +409,15 @@ public static SessionOutput FromDto(SessionOutputDto dto) Options = dto.InteractionOptions ?? [], PersistedAdoptedContext = dto.InteractionPersistedAdoptedContext ?? false }, + SessionOutputTypes.ApprovalOutcome => new ApprovalOutcomeOutput + { + SessionId = sessionId, + TimestampMs = dto.TimestampMs, + CallId = new ToolCallId(dto.CallId ?? string.Empty), + ToolName = new ToolName(dto.ToolName ?? "unknown"), + SelectedKey = new ApprovalOptionKey(dto.ApprovalSelectedKey ?? ApprovalOptionKeys.Deny), + ParentCallId = dto.ApprovalParentCallId ?? string.Empty + }, _ => new ErrorOutput { SessionId = sessionId, diff --git a/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs b/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs index d677c9e13..345aab204 100644 --- a/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs +++ b/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs @@ -18,6 +18,7 @@ public static class SessionTranscriptEntryTypes public const string Error = "error"; public const string Usage = "usage"; public const string Compaction = "compaction"; + public const string Approval = "approval"; public const string Legacy = "legacy"; public const string Diagnostic = "diagnostic"; } @@ -44,6 +45,10 @@ public sealed record SessionTranscriptEntry public string? ArgumentsJson { get; init; } + public string? BatchId { get; init; } + + public int? BatchSize { get; init; } + public string? Result { get; init; } public string? RunId { get; init; } @@ -56,6 +61,8 @@ public sealed record SessionTranscriptEntry public string? OutcomeReason { get; init; } + public string? ApprovalSelectedKey { get; init; } + public double? DurationMs { get; init; } public int? FindingsCount { get; init; } diff --git a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs index cb9404fb2..39537723b 100644 --- a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs +++ b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs @@ -178,6 +178,9 @@ internal static Proto.SessionTranscriptEntryProto ToProto(SessionTranscriptEntry if (entry.PreCompactionInputTokens is not null) proto.PreCompactionInputTokens = entry.PreCompactionInputTokens.Value; if (entry.KeepCountUsed is not null) proto.KeepCountUsed = entry.KeepCountUsed.Value; + if (entry.BatchId is not null) proto.BatchId = entry.BatchId; + if (entry.BatchSize is not null) proto.BatchSize = entry.BatchSize.Value; + if (entry.ApprovalSelectedKey is not null) proto.ApprovalSelectedKey = entry.ApprovalSelectedKey; return proto; } @@ -223,7 +226,10 @@ internal static Proto.SessionTranscriptEntryProto ToProto(SessionTranscriptEntry ToolResultsCleared = proto.HasToolResultsCleared ? proto.ToolResultsCleared : null, Summarized = proto.HasSummarized ? proto.Summarized : null, PreCompactionInputTokens = proto.HasPreCompactionInputTokens ? proto.PreCompactionInputTokens : null, - KeepCountUsed = proto.HasKeepCountUsed ? proto.KeepCountUsed : null + KeepCountUsed = proto.HasKeepCountUsed ? proto.KeepCountUsed : null, + BatchId = proto.HasBatchId ? proto.BatchId : null, + BatchSize = proto.HasBatchSize ? proto.BatchSize : null, + ApprovalSelectedKey = proto.HasApprovalSelectedKey ? proto.ApprovalSelectedKey : null }; // ── SendUserMessage ── diff --git a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto index 2f72364bd..11b4c3d23 100644 --- a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto +++ b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto @@ -118,6 +118,9 @@ message SessionTranscriptEntryProto { optional string error_detail = 38; optional double prompt_ms = 39; optional double predicted_per_second = 40; + optional string batch_id = 41; + optional int32 batch_size = 42; + optional string approval_selected_key = 43; } // ── Commands ── diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index b4db965d5..8dd76db54 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -1951,6 +1951,9 @@ private void EmitAndDispatchToolBatch( EmitOutput(new BufferFlush { SessionId = _sessionId }, OutputFilter.TextStreaming); } + // One batch id lets clients group concurrent calls without changing each call id. + var batchId = toolCalls.Count > 1 ? toolCalls[0].CallId : string.Empty; + // Emit tool call outputs to subscribers and track for duplicate detection foreach (var tc in toolCalls) { @@ -1962,7 +1965,9 @@ private void EmitAndDispatchToolBatch( SessionId = _sessionId, CallId = new ToolCallId(tc.CallId), ToolName = new ToolName(tc.Name), - ArgumentsJson = argsJson + ArgumentsJson = argsJson, + BatchId = batchId, + BatchSize = Math.Max(1, toolCalls.Count) }, OutputFilter.ToolCalls); // Duplicate tool call detection: hash tool name + args @@ -3836,6 +3841,8 @@ private void EmitResponseOutputs( bool includeText = true, bool includeThinking = true) { + var toolCalls = message.Contents.OfType().ToList(); + var batchId = toolCalls.Count > 1 ? toolCalls[0].CallId : string.Empty; foreach (var content in message.Contents) { switch (content) @@ -3853,6 +3860,8 @@ private void EmitResponseOutputs( SessionId = _sessionId, CallId = new ToolCallId(toolCall.CallId), ToolName = new ToolName(toolCall.Name), + BatchId = batchId, + BatchSize = Math.Max(1, toolCalls.Count), ArgumentsJson = toolCall.Arguments is not null ? JsonSerializer.Serialize(toolCall.Arguments) : null @@ -4252,6 +4261,7 @@ private async Task HandleProcessingApprovalResponseAsync(ToolInteractionResponse // Live-only prompts should release the blocked child task, not // journal ToolApprovalResolved. After restart the child actor is // gone, so a durable redrive would be misleading. + EmitApprovalOutcome(pending, msg); approvalWait.Complete(decision); TryReplyAck(); return; @@ -4259,6 +4269,7 @@ private async Task HandleProcessingApprovalResponseAsync(ToolInteractionResponse PersistApprovalResolved(msg, decision, () => { + EmitApprovalOutcome(pending, msg); approvalWait.Complete(decision); TryReplyAck(); }); @@ -4355,6 +4366,7 @@ private async Task HandleToolInteractionResponseWhenIdle(ToolInteractionResponse PersistApprovalResolved(msg, decision, () => { + EmitApprovalOutcome(pending, msg); var outcome = TryRedriveToolBatchAfterApproval(callId); if (outcome == ApprovalRedriveOutcome.Failed) { @@ -4723,12 +4735,29 @@ private void CaptureSettledTranscriptEntry(SessionOutput output) case SubAgentOutput { Phase: SubAgents.SubAgentPhase.Completed } subAgent: _settledTurnEntries.Add(SessionTranscriptEntryFactory.SubAgent(subAgent, turnId)); break; + case ApprovalOutcomeOutput approval: + _settledTurnEntries.Add(SessionTranscriptEntryFactory.Approval(approval, turnId)); + break; case FileOutput file: _settledTurnEntries.Add(SessionTranscriptEntryFactory.File(file, turnId)); break; } } + private void EmitApprovalOutcome(PendingToolInteraction pending, ToolInteractionResponse response) + { + const string subAgentMarker = "/subagent-approval/"; + var markerIndex = response.CallId.Value.IndexOf(subAgentMarker, StringComparison.Ordinal); + EmitOutput(new ApprovalOutcomeOutput + { + SessionId = _sessionId, + CallId = response.CallId, + ToolName = new ToolName(pending.ToolName), + SelectedKey = response.SelectedKey, + ParentCallId = markerIndex > 0 ? response.CallId.Value[..markerIndex] : string.Empty + }); + } + private async Task PersistApprovalCandidatesAsync( PendingToolInteraction pending, ApprovalDecision decision, diff --git a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs index b8466fbd1..8a6020dc5 100644 --- a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs +++ b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs @@ -70,6 +70,12 @@ public sealed record ToolCallOutput : SessionOutput public required ToolName ToolName { get; init; } + /// Stable identity for the model tool-call batch. + public string BatchId { get; init; } = string.Empty; + + /// Number of calls in the model tool-call batch. + public int BatchSize { get; init; } = 1; + /// /// Tool arguments as a JSON string. Kept opaque at the protocol level — /// tool executors parse based on their schema. @@ -472,6 +478,25 @@ public sealed record ToolInteractionRequest : SessionOutput public bool PersistedAdoptedContext { get; init; } } + /// + /// A tool approval decision that the session accepted. + /// Lifecycle — always delivered so interactive clients can settle the gate. + /// + public sealed record ApprovalOutcomeOutput : SessionOutput + { + public required ToolCallId CallId { get; init; } + + public required ToolName ToolName { get; init; } + + public required ApprovalOptionKey SelectedKey { get; init; } + + /// + /// Parent tool call for a relayed sub-agent approval. + /// An empty value identifies a direct session tool request. + /// + public string ParentCallId { get; init; } = string.Empty; + } + /// /// An option presented to the user in a . /// diff --git a/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs b/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs index a2728a05f..3b359204d 100644 --- a/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs +++ b/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs @@ -21,9 +21,22 @@ public static SessionTranscriptEntry Tool( CallId = result.CallId.Value, ToolName = result.ToolName.Value, ArgumentsJson = call?.ArgumentsJson, + BatchId = call?.BatchId, + BatchSize = call?.BatchSize, Result = result.Result }; + public static SessionTranscriptEntry Approval(ApprovalOutcomeOutput output, string? turnId) => new() + { + Type = SessionTranscriptEntryTypes.Approval, + TurnId = turnId, + TimestampMs = output.TimestampMs, + CallId = output.CallId.Value, + ParentCallId = output.ParentCallId, + ToolName = output.ToolName.Value, + ApprovalSelectedKey = output.SelectedKey.Value + }; + public static SessionTranscriptEntry SubAgent(SubAgentOutput output, string? turnId) => new() { Type = SessionTranscriptEntryTypes.SubAgent, diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index 43bd3aa70..f295c611c 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -9,8 +9,11 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.AI; using Netclaw.Actors.Protocol; using Netclaw.Cli.Daemon; +using Netclaw.Cli.Tui; +using Netclaw.Configuration; using Netclaw.Daemon.Gateway; using R3; using Xunit; @@ -54,6 +57,39 @@ public async Task ResumeSessionAsync_reattaches_to_existing_session_via_EnsureSe await outputReceived.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); } + [Fact] + public async Task ChatViewModel_initial_resume_uses_one_session_attach() + { + using var host = await StartFakeHubAsync(); + var port = TestNetworkHelpers.GetBoundPort(host); + var state = host.Services.GetRequiredService(); + await using var seedClient = new DaemonClient($"http://127.0.0.1:{port}"); + var sessionId = await seedClient.CreateSessionAsync( + Netclaw.Actors.Channels.ChannelType.Tui, + TestContext.Current.CancellationToken); + state.ResetEnsureCount(); + + await using var client = new DaemonClient($"http://127.0.0.1:{port}"); + var navigation = new ChatNavigationState { ResumeSessionId = sessionId }; + using var viewModel = new ChatViewModel( + client, + TimeProvider.System, + new ModelCapabilities { ModelId = "test-model" }, + navigation, + new NetclawPaths()); + var attached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = viewModel.SessionIdDisplay.Subscribe(value => + { + if (string.Equals(value, sessionId, StringComparison.Ordinal)) + attached.TrySetResult(); + }); + + viewModel.OnActivated(); + await attached.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.Equal(1, state.EnsureCount); + } + [Fact] public async Task RespondToInteractionAsync_invokes_hub_method() { @@ -110,11 +146,13 @@ private sealed class FakeSessionState private readonly HashSet _sessions = []; private readonly Dictionary _connectionSessions = []; public (string CallId, string SelectedKey)? LastInteractionResponse { get; private set; } + public int EnsureCount { get; private set; } public SessionEnsureResultDto Ensure(string connectionId, string? sessionId) { lock (_gate) { + EnsureCount++; if (!string.IsNullOrWhiteSpace(sessionId) && _sessions.Contains(sessionId)) { _connectionSessions[connectionId] = sessionId; @@ -128,6 +166,12 @@ public SessionEnsureResultDto Ensure(string connectionId, string? sessionId) } } + public void ResetEnsureCount() + { + lock (_gate) + EnsureCount = 0; + } + public bool IsAttached(string connectionId, string sessionId) => _connectionSessions.TryGetValue(connectionId, out var attached) && string.Equals(attached, sessionId, StringComparison.Ordinal); diff --git a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs index d6e64d99d..ad2bdca7b 100644 --- a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs @@ -6,6 +6,7 @@ using Netclaw.Actors.Protocol; using Netclaw.Actors.SubAgents; using Netclaw.Cli.Tui; +using Netclaw.Media; using Netclaw.Tools; using Xunit; using static Netclaw.Actors.Sessions.SessionProtocol; @@ -38,6 +39,20 @@ public void Parallel_tool_results_update_only_their_call_rows() first.State.Transcript.Select(block => block.Key).ToArray()); } + [Fact] + public void Parallel_tool_batch_commits_one_durable_group() + { + var first = ToolCall("call-a", "search", 1) with { BatchId = "batch-1", BatchSize = 2 }; + var second = ToolCall("call-b", "fetch", 2) with { BatchId = "batch-1", BatchSize = 2 }; + + var state = Apply(ChatPresentationState.Empty, first); + state = Apply(state, second); + + var group = Assert.Single(state.Transcript, block => block.Kind == ChatBlockKind.Parallel); + Assert.Contains("2 tool calls", group.Summary, StringComparison.Ordinal); + Assert.Contains("batch-1", state.CommittedToolBatches); + } + [Fact] public void Parallel_same_name_subagents_keep_distinct_run_rows() { @@ -57,6 +72,41 @@ public void Parallel_same_name_subagents_keep_distinct_run_rows() Assert.Contains(state.Transcript, block => block.Key == "subagent:run-a"); } + [Fact] + public void Subagent_tool_identity_survives_the_approval_phase() + { + var state = Apply(ChatPresentationState.Empty, SubAgent("run-a", SubAgentPhase.Started, 1)); + state = Apply(state, SubAgent("run-a", SubAgentPhase.Activity, 2, "running tools: shell_execute")); + state = Apply(state, SubAgent("run-a", SubAgentPhase.Activity, 3, "awaiting human approval")); + + Assert.Equal("shell_execute", state.SubAgents["run-a"].ActiveToolName); + Assert.Equal("awaiting human approval", state.SubAgents["run-a"].Phase); + } + + [Fact] + public void Approval_outcome_removes_only_its_request_and_commits_the_decision() + { + const string firstCallId = "parent-a/subagent-approval/approval-a"; + const string secondCallId = "parent-b/subagent-approval/approval-b"; + var state = Apply(ChatPresentationState.Empty, Approval(firstCallId, 1)); + state = Apply(state, Approval(secondCallId, 2)); + + state = Apply(state, new ApprovalOutcomeOutput + { + SessionId = SessionId, + TimestampMs = 3, + CallId = new ToolCallId(firstCallId), + ToolName = new ToolName("shell_execute"), + ParentCallId = "parent-a", + SelectedKey = new ApprovalOptionKey(ApprovalOptionKeys.Deny) + }); + + Assert.Equal(secondCallId, state.PendingApproval?.CallId.Value); + var decision = Assert.Single(state.Transcript, block => block.Kind == ChatBlockKind.Approval); + Assert.True(decision.IsFailure); + Assert.Contains("denied", decision.Summary, StringComparison.Ordinal); + } + [Fact] public void Transient_thought_and_tool_activity_stay_out_of_settled_transcript() { @@ -141,6 +191,35 @@ public void Session_resume_prefers_structured_transcript_over_legacy_messages() block.Kind == ChatBlockKind.Tool && block.SemanticText.Contains("healthy", StringComparison.Ordinal)); } + [Fact] + public void New_session_join_does_not_add_a_redundant_transcript_block() + { + var reduction = ChatPresentationReducer.Reduce(ChatPresentationState.Empty, new SessionJoined + { + SessionId = SessionId, + TimestampMs = 10, + TurnCount = 0 + }); + + Assert.True(reduction.State.HasJoined); + Assert.Empty(reduction.State.Transcript); + Assert.Empty(reduction.Effects.OfType()); + } + + [Fact] + public void Session_title_updates_the_header_and_uses_a_title_block() + { + var reduction = ChatPresentationReducer.Reduce(ChatPresentationState.Empty, new SessionTitleOutput("Review the release") + { + SessionId = SessionId, + TimestampMs = 10 + }); + + Assert.Equal("Review the release", reduction.State.SessionTitle); + var block = Assert.Single(reduction.Effects.OfType()).Block; + Assert.Equal("TITLE", block.Label); + } + [Fact] public void Unsupported_output_commits_a_visible_diagnostic() { @@ -174,6 +253,79 @@ public void Failed_turn_settles_incomplete_activity_as_diagnostics() Assert.Equal(2, state.Transcript.Count(block => block.Kind == ChatBlockKind.Diagnostic)); } + [Fact] + public void Every_session_output_subtype_has_a_defined_reduction() + { + SessionOutput[] outputs = + [ + new SessionJoined { SessionId = SessionId }, + new TextOutput("answer") { SessionId = SessionId }, + new TextDeltaOutput("part") { SessionId = SessionId }, + new ThinkingOutput("reason") { SessionId = SessionId }, + new ThinkingDeltaOutput("step") { SessionId = SessionId }, + ToolCall("call-a", "search", 1), + ToolResult("call-a", "result", 2), + new ToolActivityOutput + { + SessionId = SessionId, + CallId = new ToolCallId("call-a"), + ToolName = new ToolName("search"), + TurnId = new TurnId("turn-a"), + Phase = "running" + }, + new UsageOutput { SessionId = SessionId }, + new TurnCompleted + { + SessionId = SessionId, + TurnNumber = new TurnNumber(1) + }, + new SessionTitleOutput("title") { SessionId = SessionId }, + new ErrorOutput { SessionId = SessionId, Message = "error" }, + new FileOutput + { + SessionId = SessionId, + FilePath = "/tmp/report.txt", + FileName = "report.txt", + MimeType = new MimeType("text/plain") + }, + SubAgent("run-a", SubAgentPhase.Started, 3), + new BufferFlush { SessionId = SessionId }, + new ProcessingStateOutput(true) { SessionId = SessionId }, + new CompactionOutput + { + SessionId = SessionId, + MessagesBefore = 10, + MessagesAfter = 4 + }, + Approval("approval-a", 4), + new ApprovalOutcomeOutput + { + SessionId = SessionId, + CallId = new ToolCallId("approval-a"), + ToolName = new ToolName("shell_execute"), + SelectedKey = ApprovalOptionKeys.ApproveOnceKey + } + ]; + + var discoveredTypes = typeof(SessionOutput).Assembly.GetTypes() + .Where(type => !type.IsAbstract && typeof(SessionOutput).IsAssignableFrom(type)) + .Select(type => type.Name) + .Order(StringComparer.Ordinal) + .ToArray(); + var coveredTypes = outputs.Select(output => output.GetType().Name) + .Order(StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(discoveredTypes, coveredTypes); + foreach (var output in outputs) + { + var reduction = ChatPresentationReducer.Reduce(ChatPresentationState.Empty, output); + Assert.DoesNotContain(reduction.State.Transcript, block => + block.Kind == ChatBlockKind.Diagnostic + && block.Summary.StartsWith("Unsupported session output", StringComparison.Ordinal)); + } + } + private static ChatPresentationState Apply(ChatPresentationState state, SessionOutput output) => ChatPresentationReducer.Reduce(state, output).State; @@ -195,6 +347,17 @@ private static ChatPresentationState Apply(ChatPresentationState state, SessionO Result = result }; + private static ToolInteractionRequest Approval(string callId, long timestamp) => new() + { + SessionId = SessionId, + TimestampMs = timestamp, + Kind = "approval", + CallId = new ToolCallId(callId), + ToolName = new ToolName("shell_execute"), + DisplayText = "dotnet test", + Options = [new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel)] + }; + private static SubAgentOutput SubAgent( string runId, SubAgentPhase phase, diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index c4062eb0e..c73089e7e 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -163,6 +163,8 @@ public async Task Escape_DeniesAnApproval() await using var harness = CreateHarness(approval: BuildApproval()); var runTask = harness.StartAsync(); await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + Assert.Contains("This chat — until this chat ends", harness.Terminal.ToString(), StringComparison.Ordinal); + Assert.Contains("Deny — do not run", harness.Terminal.ToString(), StringComparison.Ordinal); harness.Input.EnqueueKey(ConsoleKey.Escape); @@ -239,8 +241,7 @@ public async Task Generation_HidesTheComposerAndClearsFocus() harness.ViewModel.IsGenerating.Value = true; harness.ViewModel.StatusMessage.Value = "Generating..."; await harness.WaitUntilAsync(() => - harness.Terminal.Contains("Generating") - && harness.Terminal.Contains("Work active") + harness.Terminal.Contains("Working…") && !harness.Terminal.Contains("Composer") && harness.Focus.CurrentFocus is null); @@ -269,6 +270,23 @@ public async Task StableTranscript_UsesPrimaryScrollbackWithoutAnOuterBorder() await harness.StopAsync(runTask); } + [Fact] + public async Task Wide_transcript_caps_the_assistant_line_measure() + { + var output = new TextOutput(string.Join(' ', Enumerable.Repeat("readable", 40))) + { + SessionId = SessionId, + TimestampMs = 0 + }; + await using var harness = CreateHarness(outputs: [output], width: 160); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("readable")); + + Assert.InRange(harness.Terminal.GetLine(1).TrimEnd().Length, 1, 120); + Assert.NotEmpty(harness.Terminal.GetLine(2).TrimEnd()); + await harness.StopAsync(runTask); + } + [Fact] public async Task ActivityDeck_ShowsParallelToolsThoughtAndSubAgent() { @@ -319,6 +337,106 @@ await harness.WaitUntilAsync(() => await harness.StopAsync(runTask); } + [Fact] + public async Task ActivityDeck_NestsTheActiveToolUnderItsSubagent() + { + var outputs = new SessionOutput[] + { + new SubAgentOutput + { + SessionId = SessionId, + TimestampMs = 1, + AgentName = new AgentName("interface-reviewer"), + Phase = SubAgentPhase.Started, + RunId = new SubAgentRunId("run-a"), + ParentCallId = new ToolCallId("parent-a") + }, + new SubAgentOutput + { + SessionId = SessionId, + TimestampMs = 2, + AgentName = new AgentName("interface-reviewer"), + Phase = SubAgentPhase.Activity, + RunId = new SubAgentRunId("run-a"), + ParentCallId = new ToolCallId("parent-a"), + ActivityPhase = "running tools: shell_execute" + }, + new SubAgentOutput + { + SessionId = SessionId, + TimestampMs = 3, + AgentName = new AgentName("interface-reviewer"), + Phase = SubAgentPhase.Activity, + RunId = new SubAgentRunId("run-a"), + ParentCallId = new ToolCallId("parent-a"), + ActivityPhase = "awaiting human approval" + } + }; + await using var harness = CreateHarness(outputs: outputs); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("TOOL shell_execute")); + + var screen = harness.Terminal.ToString(); + Assert.True(screen.IndexOf("AGENT interface-reviewer", StringComparison.Ordinal) + < screen.IndexOf("TOOL shell_execute", StringComparison.Ordinal)); + Assert.Contains("awaiting human approval", screen, StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task SubagentApproval_ShowsRequesterPathAndCapsTheGateWidth() + { + var output = new SubAgentOutput + { + SessionId = SessionId, + TimestampMs = 1, + AgentName = new AgentName("interface-reviewer"), + Phase = SubAgentPhase.Started, + RunId = new SubAgentRunId("run-a"), + ParentCallId = new ToolCallId("parent-a") + }; + var approval = BuildApproval() with + { + CallId = new ToolCallId("parent-a/subagent-approval/approval-a") + }; + await using var harness = CreateHarness(outputs: [output], approval: approval, width: 160); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("interface-reviewer › shell_execute")); + + var longestLine = Enumerable.Range(0, harness.Terminal.Height) + .Select(index => harness.Terminal.GetLine(index).TrimEnd().Length) + .Max(); + Assert.InRange(longestLine, 1, 120); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task AssistantMarkdown_UsesPlainDisplayAndKeepsSemanticCopy() + { + const string markdown = "# Result\n\n**Passed** with `dotnet test`."; + var output = new TextOutput(markdown) + { + SessionId = SessionId, + TimestampMs = 1 + }; + await using var harness = CreateHarness(outputs: [output]); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Passed with dotnet test.")); + + var screen = harness.Terminal.ToString(); + Assert.DoesNotContain("# Result", screen, StringComparison.Ordinal); + Assert.DoesNotContain("**Passed**", screen, StringComparison.Ordinal); + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); + Assert.DoesNotContain("# Result", harness.Terminal.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain("**Passed**", harness.Terminal.ToString(), StringComparison.Ordinal); + harness.Input.EnqueueKey(ConsoleKey.Y); + await harness.WaitUntilAsync(() => harness.Clipboard.LastCopiedText is not null); + Assert.Contains(markdown, harness.Clipboard.LastCopiedText!, StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + [Fact] public async Task Inspector_ShowsTheCompleteSemanticToolResult() { @@ -340,13 +458,39 @@ public async Task Inspector_ShowsTheCompleteSemanticToolResult() Assert.DoesNotContain("complete hidden line", harness.Terminal.ToString(), StringComparison.Ordinal); harness.Input.EnqueueKey(ConsoleKey.O, control: true); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("complete hidden line")); + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("RAW DETAIL") + && harness.Terminal.Contains("complete hidden line")); + Assert.Contains("RAW DETAIL", harness.Terminal.ToString(), StringComparison.Ordinal); + Assert.Contains("complete hidden line", harness.Terminal.ToString(), StringComparison.Ordinal); Assert.Contains("Arguments: {\"call\":\"call-a\"}", harness.Terminal.ToString(), StringComparison.Ordinal); await harness.StopAsync(runTask); } + [Fact] + public async Task Inspector_WrapsAssistantProseAtWordBoundaries() + { + var output = new TextOutput( + "Accessibility reviewers verify every expandable control before release.") + { + SessionId = SessionId, + TimestampMs = 1 + }; + await using var harness = CreateHarness(outputs: [output], width: 40); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("expandable")); + + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); + + var screen = harness.Terminal.ToString(); + Assert.Contains("every\nexpandable", screen, StringComparison.Ordinal); + Assert.DoesNotContain("expanda\nble", screen, StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + [Fact] public async Task Inspector_DefersNewStableBlocksUntilItCloses() { @@ -491,12 +635,29 @@ public async Task CommonWidths_KeepTheComposerAndStatusVisible(int width) await harness.WaitUntilAsync(() => harness.Terminal.Contains("Composer")); var screen = harness.Terminal.ToString(); - Assert.Contains("NETCLAW", screen, StringComparison.Ordinal); + Assert.Contains("netclaw", screen, StringComparison.Ordinal); Assert.Contains("Composer", screen, StringComparison.Ordinal); Assert.Contains("Enter", screen, StringComparison.Ordinal); await harness.StopAsync(runTask); } + [Fact] + public async Task NarrowHeader_KeepsACompactConnectionCue() + { + var joined = new SessionJoined + { + SessionId = SessionId, + TimestampMs = 1, + TurnCount = 0 + }; + await using var harness = CreateHarness(outputs: [joined], width: 40); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("●")); + + Assert.Contains("netclaw ●", harness.Terminal.ToString(), StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + private static ToolCallOutput ToolCall(string callId, string name, long timestamp) => new() { SessionId = SessionId, diff --git a/src/Netclaw.Cli/Tui/ChatPresentation.cs b/src/Netclaw.Cli/Tui/ChatPresentation.cs index f2cfbee50..cbb0ce11d 100644 --- a/src/Netclaw.Cli/Tui/ChatPresentation.cs +++ b/src/Netclaw.Cli/Tui/ChatPresentation.cs @@ -17,6 +17,7 @@ internal enum ChatBlockKind Assistant, Thought, Tool, + Parallel, SubAgent, Approval, File, @@ -44,7 +45,9 @@ internal sealed record ToolActivityPresentation( string Phase, string? Summary, long StartedAtMs, - string? TurnId); + string? TurnId, + string BatchId, + int BatchSize); internal sealed record SubAgentActivityPresentation( string RunId, @@ -52,7 +55,8 @@ internal sealed record SubAgentActivityPresentation( string AgentName, string Phase, string? Summary, - long StartedAtMs); + long StartedAtMs, + string? ActiveToolName); internal sealed record ChatPresentationState { @@ -66,6 +70,12 @@ internal sealed record ChatPresentationState public ImmutableDictionary SubAgents { get; init; } = ImmutableDictionary.Empty.WithComparers(StringComparer.Ordinal); + public ImmutableHashSet CommittedToolBatches { get; init; } = + ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal); + + public ImmutableQueue PendingApprovals { get; init; } = + ImmutableQueue.Empty; + public string AssistantText { get; init; } = string.Empty; public string ThoughtText { get; init; } = string.Empty; @@ -74,11 +84,16 @@ internal sealed record ChatPresentationState public string? CurrentTurnId { get; init; } + public string? SessionTitle { get; init; } + + public double? ContextUsagePercent { get; init; } + public bool HasJoined { get; init; } public bool IsProcessing { get; init; } - public ToolInteractionRequest? PendingApproval { get; init; } + public ToolInteractionRequest? PendingApproval => + PendingApprovals.IsEmpty ? null : PendingApprovals.Peek(); } internal abstract record ChatPresentationEffect @@ -116,24 +131,19 @@ public static ChatReduction Reduce(ChatPresentationState state, SessionOutput ou ThoughtText = state.ThoughtText + thoughtDelta.Delta }, ThinkingOutput thought => CommitThought(state, thought, effects), - ToolCallOutput toolCall => StartTool(state, toolCall), + ToolCallOutput toolCall => StartTool(state, toolCall, effects), ToolActivityOutput activity => UpdateTool(state, activity), ToolResultOutput toolResult => CompleteTool(state, toolResult, effects), SubAgentOutput subAgent => ReduceSubAgent(state, subAgent, effects), - UsageOutput usage => Commit(state, UsageBlock(usage, state.CurrentTurnId), effects), + UsageOutput usage => CommitUsage(state, usage, effects), ErrorOutput error => Commit(state, ErrorBlock(error, state.CurrentTurnId), effects), FileOutput file => Commit(state, FileBlock(file, state.CurrentTurnId), effects), CompactionOutput compaction => Commit(state, CompactionBlock(compaction, state.CurrentTurnId), effects), ToolInteractionRequest approval => ShowApproval(state, approval, effects), + ApprovalOutcomeOutput approval => ResolveApproval(state, approval, effects), TurnCompleted completed => CompleteTurn(state, completed, effects), ProcessingStateOutput processing => state with { IsProcessing = processing.IsProcessing }, - SessionTitleOutput title => Commit(state, new ChatPresentationBlock( - $"title:{title.TimestampMs}", - ChatBlockKind.System, - "SESSION", - title.Title, - $"Session title: {title.Title}", - title.TimestampMs), effects), + SessionTitleOutput title => CommitTitle(state, title, effects), BufferFlush => FlushAssistant(state, output.TimestampMs, effects), _ => Commit(state, DiagnosticBlock( $"unsupported:{output.GetType().Name}:{output.TimestampMs}", @@ -181,20 +191,32 @@ private static ChatPresentationState ReduceJoined( return state; } - var sessionSummary = joined.Title ?? (joined.TurnCount > 0 ? "Resumed session" : "New session"); - var sessionBlock = new ChatPresentationBlock( - $"session:{joined.SessionId.Value}", - ChatBlockKind.System, - "SESSION", - sessionSummary, - $"Session: {sessionSummary}", - joined.TimestampMs); - state = Commit(state, sessionBlock, effects); + state = state with { SessionTitle = joined.Title }; if (joined.RecentTranscript is { Count: > 0 }) { for (var index = 0; index < joined.RecentTranscript.Count; index++) - state = Commit(state, ResumeBlock(joined.RecentTranscript[index], index), effects); + { + var entry = joined.RecentTranscript[index]; + if (entry is + { + Type: SessionTranscriptEntryTypes.Tool, + BatchSize: > 1, + BatchId.Length: > 0 + } + && !state.CommittedToolBatches.Contains(entry.BatchId)) + { + state = CommitParallelGroup( + state, + entry.BatchId, + entry.BatchSize.Value, + entry.TimestampMs, + entry.TurnId, + effects); + } + + state = Commit(state, ResumeBlock(entry, index), effects); + } } else if (joined.RecentMessages is { Count: > 0 }) { @@ -224,6 +246,36 @@ private static ChatPresentationState ReduceJoined( }; } + private static ChatPresentationState CommitTitle( + ChatPresentationState state, + SessionTitleOutput output, + List effects) + { + var block = new ChatPresentationBlock( + $"title:{output.TimestampMs}", + ChatBlockKind.System, + "TITLE", + output.Title, + $"Session title: {output.Title}", + output.TimestampMs); + return Commit(state with { SessionTitle = output.Title }, block, effects); + } + + private static ChatPresentationState CommitUsage( + ChatPresentationState state, + UsageOutput output, + List effects) + { + var usagePercent = output.UsagePercent; + if (usagePercent is null && output.InputTokens is { } inputTokens && output.ContextWindowTokens > 0) + usagePercent = (double)inputTokens / output.ContextWindowTokens; + + return Commit( + state with { ContextUsagePercent = usagePercent }, + UsageBlock(output, state.CurrentTurnId), + effects); + } + private static ChatPresentationState CommitAssistant( ChatPresentationState state, TextOutput output, @@ -286,8 +338,24 @@ private static ChatPresentationState CommitThought( return Commit(state with { ThoughtText = string.Empty }, block, effects); } - private static ChatPresentationState StartTool(ChatPresentationState state, ToolCallOutput output) + private static ChatPresentationState StartTool( + ChatPresentationState state, + ToolCallOutput output, + List effects) { + if (output.BatchSize > 1 + && output.BatchId.Length > 0 + && !state.CommittedToolBatches.Contains(output.BatchId)) + { + state = CommitParallelGroup( + state, + output.BatchId, + output.BatchSize, + output.TimestampMs, + state.CurrentTurnId, + effects); + } + var tool = new ToolActivityPresentation( output.CallId.Value, output.ToolName.Value, @@ -295,7 +363,9 @@ private static ChatPresentationState StartTool(ChatPresentationState state, Tool "queued", null, output.TimestampMs, - state.CurrentTurnId); + state.CurrentTurnId, + output.BatchId, + output.BatchSize); return state with { Tools = state.Tools.SetItem(tool.CallId, tool) }; } @@ -311,7 +381,9 @@ private static ChatPresentationState UpdateTool(ChatPresentationState state, Too output.Phase, output.Summary, output.TimestampMs, - output.TurnId.Value); + output.TurnId.Value, + string.Empty, + 1); return state with { CurrentTurnId = output.TurnId.Value, @@ -363,13 +435,18 @@ private static ChatPresentationState ReduceSubAgent( output.AgentName.Value, output.Phase.ToString().ToLowerInvariant(), output.ActivitySummary, - output.TimestampMs); + output.TimestampMs, + null); + var activeToolName = ActiveSubAgentTool(output.ActivityPhase) ?? current.ActiveToolName; + if (output.ActivityPhase is "processing tool results" or "calling the model") + activeToolName = null; return state with { SubAgents = state.SubAgents.SetItem(key, current with { Phase = output.ActivityPhase ?? output.Phase.ToString().ToLowerInvariant(), - Summary = output.ActivitySummary + Summary = output.ActivitySummary, + ActiveToolName = activeToolName }) }; } @@ -391,6 +468,17 @@ private static ChatPresentationState ReduceSubAgent( return Commit(state with { SubAgents = state.SubAgents.Remove(key) }, block, effects); } + private static string? ActiveSubAgentTool(string? phase) + { + const string prefix = "running tools: "; + if (phase is null || !phase.StartsWith(prefix, StringComparison.Ordinal)) + return null; + + return phase[prefix.Length..] + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault(); + } + private static ChatPresentationState ShowApproval( ChatPresentationState state, ToolInteractionRequest approval, @@ -399,7 +487,43 @@ private static ChatPresentationState ShowApproval( effects.Add(new ChatPresentationEffect.ShowApproval(approval)); effects.Add(new ChatPresentationEffect.SetStatus("Approval required")); effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); - return state with { PendingApproval = approval }; + return state with { PendingApprovals = state.PendingApprovals.Enqueue(approval) }; + } + + private static ChatPresentationState ResolveApproval( + ChatPresentationState state, + ApprovalOutcomeOutput output, + List effects) + { + var requester = state.SubAgents.Values.FirstOrDefault(run => + output.ParentCallId.Length > 0 + && string.Equals(run.ParentCallId, output.ParentCallId, StringComparison.Ordinal)); + var path = requester is null + ? output.ParentCallId.Length > 0 + ? $"sub-agent › {output.ToolName.Value}" + : output.ToolName.Value + : $"{requester.AgentName} › {output.ToolName.Value}"; + var decision = ApprovalDecisionText(output.SelectedKey.Value); + var detail = $"Tool: {output.ToolName.Value}\nCall: {output.CallId.Value}" + + (output.ParentCallId.Length == 0 ? string.Empty : $"\nParent call: {output.ParentCallId}") + + $"\nDecision: {decision}"; + var block = new ChatPresentationBlock( + $"approval:{output.CallId.Value}:{output.TimestampMs}", + ChatBlockKind.Approval, + "APPROVAL", + $"{path} · {decision}", + $"Approval: {path}\n{detail}", + output.TimestampMs, + state.CurrentTurnId, + detail, + string.Equals(output.SelectedKey.Value, ApprovalOptionKeys.Deny, StringComparison.Ordinal)); + var remaining = ImmutableQueue.CreateRange(state.PendingApprovals.Where(request => + !string.Equals(request.CallId.Value, output.CallId.Value, StringComparison.Ordinal))); + state = Commit(state with { PendingApprovals = remaining }, block, effects); + effects.Add(new ChatPresentationEffect.SetStatus( + remaining.IsEmpty ? "Generating..." : "Approval required")); + effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); + return state; } private static ChatPresentationState CompleteTurn( @@ -442,7 +566,7 @@ private static ChatPresentationState CompleteTurn( { Tools = state.Tools.Clear(), SubAgents = state.SubAgents.Clear(), - PendingApproval = null, + PendingApprovals = ImmutableQueue.Empty, IsProcessing = false, TurnNumber = Math.Max(state.TurnNumber + 1, completed.TurnNumber.Value + 1), CurrentTurnId = null @@ -465,6 +589,7 @@ private static ChatPresentationBlock ResumeBlock(SessionTranscriptEntry entry, i SessionTranscriptEntryTypes.User => ChatBlockKind.User, SessionTranscriptEntryTypes.Assistant => ChatBlockKind.Assistant, SessionTranscriptEntryTypes.Tool => ChatBlockKind.Tool, + SessionTranscriptEntryTypes.Approval => ChatBlockKind.Approval, SessionTranscriptEntryTypes.SubAgent => ChatBlockKind.SubAgent, SessionTranscriptEntryTypes.File => ChatBlockKind.File, SessionTranscriptEntryTypes.Error => ChatBlockKind.Error, @@ -477,6 +602,7 @@ private static ChatPresentationBlock ResumeBlock(SessionTranscriptEntry entry, i { ChatBlockKind.User or ChatBlockKind.Assistant => entry.Text ?? string.Empty, ChatBlockKind.Tool => $"✓ {entry.ToolName ?? "unknown"} · {FirstLine(entry.Result)}", + ChatBlockKind.Approval => $"{entry.ToolName ?? "unknown"} · {ApprovalDecisionText(entry.ApprovalSelectedKey)}", ChatBlockKind.SubAgent => $"{entry.AgentName ?? "sub-agent"} · {entry.Outcome ?? "complete"}", ChatBlockKind.File => $"{entry.FileName ?? "file"} · {entry.FilePath}", ChatBlockKind.Error => entry.ErrorMessage ?? "Unknown error", @@ -588,6 +714,11 @@ private static ChatPresentationBlock DiagnosticBlock( SessionTranscriptEntryTypes.Tool => $"Tool: {entry.ToolName ?? "unknown"}\nCall: {entry.CallId ?? "unknown"}" + (entry.ArgumentsJson is null ? string.Empty : $"\nArguments: {entry.ArgumentsJson}") + $"\nResult: {entry.Result ?? string.Empty}", + SessionTranscriptEntryTypes.Approval => $"Tool: {entry.ToolName ?? "unknown"}\nCall: {entry.CallId ?? "unknown"}" + + (string.IsNullOrEmpty(entry.ParentCallId) + ? string.Empty + : $"\nParent call: {entry.ParentCallId}") + + $"\nDecision: {ApprovalDecisionText(entry.ApprovalSelectedKey)}", SessionTranscriptEntryTypes.SubAgent => $"Agent: {entry.AgentName ?? "unknown"}\nRun: {entry.RunId ?? "unknown"}" + $"\nOutcome: {entry.Outcome ?? "unknown"}" + (entry.OutcomeReason is null ? string.Empty : $"\nReason: {entry.OutcomeReason}"), @@ -610,7 +741,9 @@ private static string UsageSummary(SessionTranscriptEntry entry) => ChatBlockKind.Assistant => "NETCLAW", ChatBlockKind.Thought => "THOUGHT", ChatBlockKind.Tool => "TOOL", + ChatBlockKind.Parallel => "PARALLEL", ChatBlockKind.SubAgent => "AGENT", + ChatBlockKind.Approval => "APPROVAL", ChatBlockKind.File => "FILE", ChatBlockKind.Error => "ERROR", ChatBlockKind.Usage => "USAGE", @@ -627,4 +760,37 @@ private static string FirstLine(string? text) var first = end < 0 ? text : text[..end]; return first.Length <= 100 ? first : string.Concat(first.AsSpan(0, 97), "..."); } + + private static ChatPresentationState CommitParallelGroup( + ChatPresentationState state, + string batchId, + int batchSize, + long timestampMs, + string? turnId, + List effects) + { + var block = new ChatPresentationBlock( + $"parallel:{batchId}", + ChatBlockKind.Parallel, + "PARALLEL", + $"{batchSize} tool calls", + $"Parallel tool batch: {batchId}\nCalls: {batchSize}", + timestampMs, + turnId, + $"Batch: {batchId}\nCalls: {batchSize}"); + return Commit( + state with { CommittedToolBatches = state.CommittedToolBatches.Add(batchId) }, + block, + effects); + } + + private static string ApprovalDecisionText(string? selectedKey) => selectedKey switch + { + ApprovalOptionKeys.ApproveOnce => "approved once", + ApprovalOptionKeys.ApproveSession => "approved for this chat", + ApprovalOptionKeys.ApproveAlways => "approved for this directory", + ApprovalOptionKeys.ApproveEverywhere => "approved everywhere", + ApprovalOptionKeys.Deny => "denied", + _ => "resolved" + }; } diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index 43c539841..b50d0beee 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -160,7 +160,11 @@ or DaemonConnectionState.Reconnecting IsGenerating.Value = false; } - if (evt.State is DaemonConnectionState.Connected) + // The initial connect path owns the first session attach. + // A later Connected event restores an existing attached session. + if (evt.State is DaemonConnectionState.Connected + && SessionIdDisplay.Value is not null + && !_sessionReady) { _ = EnsureSessionAndFlushAsync(); } diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 78c48998b..992299800 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -3,8 +3,13 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Netclaw.Actors.Protocol; using R3; using Termina.Clipboard; +using Termina.Components.Streaming; using Termina.Input; using Termina.Layout; using Termina.Reactive; @@ -21,6 +26,7 @@ namespace Netclaw.Cli.Tui; public sealed class InlineChatPage : ReactivePage { private static readonly TimeSpan DoubleEscapeWindow = TimeSpan.FromMilliseconds(500); + private const int MaximumReadableWidth = 120; private readonly IAnsiTerminal _terminal; private readonly IInlineOutput _inlineOutput; @@ -39,6 +45,7 @@ public sealed class InlineChatPage : ReactivePage private string? _approvalDetailCallId; private string? _inspectorBlockKey; private string? _inspectorCopyStatus; + private int _inspectorRenderWidth; private ChatPresentationState _state = ChatPresentationState.Empty; private Task _commitTail = Task.CompletedTask; private readonly List _deferredInspectorCommits = []; @@ -267,14 +274,23 @@ private ILayoutNode BuildLiveRegion() private ILayoutNode BuildInspector() { var block = _state.Transcript[_inspectorIndex]; - if (_inspectorDetail is null || _inspectorBlockKey != block.Key) + var detailWidth = Math.Max(1, ReadableWidth() - 4); + if (_inspectorDetail is null + || _inspectorBlockKey != block.Key + || _inspectorRenderWidth != detailWidth) { _inspectorBlockKey = block.Key; + _inspectorRenderWidth = detailWidth; _inspectorDetail ??= new ScrollableContainerNode() .WithAutoScroll(AutoScrollPolicy.None) .WithScrollbar(true); var semanticText = ChatPresentationRenderer.SemanticCopyText(block.SemanticText); var displayText = RemoveDuplicateInspectorLabel(semanticText, block.Label); + if (block.Kind == ChatBlockKind.Assistant) + { + displayText = ChatPresentationRenderer.MarkdownToPlainText(displayText); + displayText = WordWrapInspectorText(displayText, detailWidth); + } _inspectorCopyNode = new CopyableTextNode(_clipboardService, displayText) .WithSemanticContent(semanticText) .WithHint(null); @@ -282,7 +298,8 @@ private ILayoutNode BuildInspector() _inspectorDetail.ScrollToTop(); } - var heading = $" INSPECTOR {_inspectorIndex + 1}/{_state.Transcript.Count} {block.Label}"; + var detailLabel = block.Kind == ChatBlockKind.Tool ? " RAW DETAIL" : string.Empty; + var heading = $" INSPECTOR {_inspectorIndex + 1}/{_state.Transcript.Count} {block.Label}{detailLabel}"; var content = Layouts.Vertical() .WithChild(new TextNode(heading).WithForeground(Color.BrightBlue).Bold()) .WithChild(_inspectorDetail.Fill()); @@ -295,7 +312,7 @@ private ILayoutNode BuildInspector() } var help = _terminal.Width >= 86 ? " ↑↓ event · PgUp/PgDn detail · Y event · Shift+Y turn · Ctrl+O/Esc close" - : " ↑↓ event · PgUp/PgDn detail · Y copy · Esc close"; + : " ↑↓ event · Pg scroll · Y event · Shift+Y turn · Esc close"; content.WithChild(new TextNode(help) .WithForeground(Color.Gray)); @@ -303,11 +320,15 @@ private ILayoutNode BuildInspector() .WithBorder(BorderStyle.Single) .WithBorderColor(Color.BrightBlue) .WithContent(content) + .Width(ReadableWidth()) .Height(Math.Max(8, _terminal.Height - 4)); - return Layouts.Vertical() + var inspector = Layouts.Vertical() .WithChild(Layouts.Empty().Height(1)) .WithChild(panel); + return Layouts.Horizontal() + .WithChild(inspector) + .WithChild(Layouts.Empty().Fill()); } private static string RemoveDuplicateInspectorLabel(string text, string label) @@ -318,6 +339,10 @@ private static string RemoveDuplicateInspectorLabel(string text, string label) : text; } + private static string WordWrapInspectorText(string text, int width) => string.Join( + '\n', + WordWrapper.WrapLines(text.ReplaceLineEndings("\n").Split('\n'), width)); + private bool HandleInspectorInput(ConsoleKeyInfo keyInfo) { switch (keyInfo.Key) @@ -414,11 +439,18 @@ private void CopyInspectorSelection(bool completeTurn) private ILayoutNode BuildSessionHeader() { var session = ViewModel.SessionIdDisplay.Value; + var connectionCue = _state.HasJoined ? "●" : "○"; var sessionPart = string.IsNullOrWhiteSpace(session) - ? "new session" - : ChatPresentationRenderer.CompactIdentity(session, 30); - var modelPart = _terminal.Width >= 72 ? $" model {ViewModel.ModelId}" : string.Empty; - return new TextNode($" NETCLAW {sessionPart}{modelPart}") + ? "connecting" + : $"session {_state.SessionTitle ?? ChatPresentationRenderer.CompactIdentity(session, 30)}"; + var modelPart = _terminal.Width >= 100 ? $" model {ViewModel.ModelId}" : string.Empty; + var contextPart = _terminal.Width >= 110 && _state.ContextUsagePercent is { } usage + ? $" context {Math.Round(usage * 100, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture)}%" + : string.Empty; + var daemonPart = _terminal.Width >= 140 + ? _state.HasJoined ? " daemon connected" : " daemon connecting" + : string.Empty; + return new TextNode($" netclaw {connectionCue} {sessionPart}{modelPart}{contextPart}{daemonPart}") .WithForeground(Color.BrightBlue) .Bold(); } @@ -426,7 +458,7 @@ private ILayoutNode BuildSessionHeader() private ILayoutNode BuildActivityDeck() { var rows = new List(); - var lineWidth = Math.Max(20, _terminal.Width - 2); + var lineWidth = Math.Max(20, ReadableWidth() - 2); if (!string.IsNullOrWhiteSpace(_state.ThoughtText)) { rows.Add(new TextNode(ChatPresentationRenderer.OneLine( @@ -444,13 +476,20 @@ private ILayoutNode BuildActivityDeck() .OrderBy(value => value.StartedAtMs) .ThenBy(value => value.CallId, StringComparer.Ordinal)) { + var childRuns = agents.Where(value => value.ParentCallId == tool.CallId).ToList(); + var phase = childRuns.Count switch + { + 0 => tool.Phase, + 1 => $"orchestrating {childRuns[0].AgentName}", + _ => $"orchestrating {childRuns.Count} agents" + }; var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; rows.Add(new TextNode(ChatPresentationRenderer.OneLine( - $" ◌ TOOL {tool.ToolName} {tool.Phase}{summary}", + $" ◌ TOOL {tool.ToolName} {phase}{summary}", lineWidth)) .WithForeground(ActivityColor(tool.Phase))); - foreach (var run in agents.Where(value => value.ParentCallId == tool.CallId)) + foreach (var run in childRuns) { rows.Add(BuildAgentActivity(run, lineWidth)); renderedAgents.Add(run.RunId); @@ -466,24 +505,35 @@ private ILayoutNode BuildActivityDeck() return rows.Count == 0 ? Layouts.Empty() : Layouts.Vertical([.. rows]); } - private ILayoutNode BuildComposer() => new PanelNode() - .WithTitle("Composer") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent(_promptInput) - .HeightAuto(min: 3, max: Math.Max(3, Math.Min(10, _terminal.Height / 3))); + private ILayoutNode BuildComposer() + { + var composer = new PanelNode() + .WithTitle("Composer") + .WithBorder(BorderStyle.Rounded) + .WithBorderColor(Color.Cyan) + .WithContent(_promptInput) + .Width(ReadableWidth()) + .HeightAuto(min: 3, max: Math.Max(3, Math.Min(10, _terminal.Height / 3))); + return Layouts.Horizontal() + .WithChild(composer) + .WithChild(Layouts.Empty().Fill()); + } private ILayoutNode BuildDecisionGate(ToolInteractionRequest approval) { - var width = Math.Max(20, _terminal.Width - 4); + var width = Math.Max(20, ReadableWidth() - 4); + var detailHeight = ApprovalDetailHeight(approval, width); + var maximumHeight = ViewModel.IsApprovalDetailVisible.Value + ? detailHeight + approval.Options.Count + 4 + : approval.Options.Count + 5; var gate = Layouts.Vertical() - .WithChild(new TextNode($" APPROVAL {approval.ToolName.Value}") + .WithChild(new TextNode($" APPROVAL {ChatPresentationRenderer.ApprovalPath(_state, approval)}") .WithForeground(Color.Yellow) .Bold()); if (ViewModel.IsApprovalDetailVisible.Value) { gate.WithChild(EnsureApprovalDetail(approval) - .Height(Math.Clamp(_terminal.Height / 4, 3, 10))); + .Height(detailHeight)); } else { @@ -496,17 +546,21 @@ private ILayoutNode BuildDecisionGate(ToolInteractionRequest approval) gate .WithChild(new TextNode( ViewModel.IsApprovalDetailVisible.Value - ? " PgUp/PgDn scroll · Ctrl+O collapse · Escape deny" - : " Ctrl+O expand · Escape deny") + ? " PgUp/PgDn scroll · Ctrl+O close details · Escape deny" + : " Ctrl+O details · Escape deny") .WithForeground(Color.Gray)) .WithChild(EnsureApprovalList()); - return new PanelNode() + var panel = new PanelNode() .WithTitle("Decision Gate") .WithBorder(BorderStyle.Rounded) .WithBorderColor(Color.Yellow) .WithContent(gate) - .HeightAuto(min: 6, max: Math.Max(8, _terminal.Height / 2)); + .Width(ReadableWidth()) + .HeightAuto(min: 6, max: Math.Max(6, Math.Min(maximumHeight, _terminal.Height / 2))); + return Layouts.Horizontal() + .WithChild(panel) + .WithChild(Layouts.Empty().Fill()); } private SelectionListNode EnsureApprovalList() @@ -518,14 +572,19 @@ private SelectionListNode EnsureApprovalList() ClearApprovalList(); _approvalCallId = approval.CallId.Value; - _approvalList = Layouts.SelectionList(approval.Options.Select(option => option.Label).ToList()) + _approvalList = Layouts.SelectionList(approval.Options.Select(ApprovalOptionLabel).ToList()) .WithMode(SelectionMode.Single) .WithHighlightColors(Color.Black, Color.Yellow); _approvalList.SelectionConfirmed .Subscribe(selected => { if (selected.Count > 0) - _ = ViewModel.SubmitInteractionOptionAsync(selected[0]); + { + var option = approval.Options.FirstOrDefault(candidate => + string.Equals(ApprovalOptionLabel(candidate), selected[0], StringComparison.Ordinal)); + if (option is not null) + _ = ViewModel.SubmitInteractionOptionAsync(option.Label); + } }) .DisposeWith(_approvalSubscriptions); return _approvalList; @@ -540,6 +599,16 @@ private void ClearApprovalList() _approvalDetailCallId = null; } + private static string ApprovalOptionLabel(ToolInteractionOption option) => option.Key.Value switch + { + ApprovalOptionKeys.ApproveOnce => $"{option.Label} — only this request", + ApprovalOptionKeys.ApproveSession => $"{option.Label} — until this chat ends", + ApprovalOptionKeys.ApproveAlways => $"{option.Label} — this tool in this folder", + ApprovalOptionKeys.ApproveEverywhere => $"{option.Label} — this tool in any folder", + ApprovalOptionKeys.Deny => $"{option.Label} — do not run", + _ => option.Label + }; + private ScrollableContainerNode EnsureApprovalDetail(ToolInteractionRequest approval) { if (_approvalDetail is not null && _approvalDetailCallId == approval.CallId.Value) @@ -556,19 +625,46 @@ private ScrollableContainerNode EnsureApprovalDetail(ToolInteractionRequest appr private ILayoutNode BuildStatusLine() { var status = ViewModel.StatusMessage.Value; + var displayStatus = status == "Generating..." ? "Working…" : status; var keys = StatusKeys(_terminal.Width, _state.PendingApproval is not null, ShowsComposer(_state)); - var text = ChatPresentationRenderer.OneLine($" {status} · {keys}", _terminal.Width); - return new TextNode(text).WithForeground(StatusColor(status)); - } + var statusLine = Layouts.Horizontal() + .WithChild(new TextNode($" {displayStatus}") + .WithForeground(StatusColor(status)) + .WidthAuto()) + .WithChild(new TextNode($" · {keys}") + .WithForeground(Color.Gray) + .NoWrap() + .Fill()) + .Width(ReadableWidth()) + .Height(1); + return Layouts.Horizontal() + .WithChild(statusLine) + .WithChild(Layouts.Empty().Fill()); + } + + private int ReadableWidth() => Math.Min(_terminal.Width, MaximumReadableWidth); private static ILayoutNode BuildAgentActivity(SubAgentActivityPresentation run, int lineWidth) { var summary = string.IsNullOrWhiteSpace(run.Summary) ? string.Empty : $" {run.Summary}"; var prefix = run.ParentCallId is null ? " ↳" : " ↳"; - return new TextNode(ChatPresentationRenderer.OneLine( - $"{prefix} AGENT {run.AgentName} {run.Phase}{summary}", - lineWidth)) - .WithForeground(ActivityColor(run.Phase)); + var rows = new List + { + new TextNode(ChatPresentationRenderer.OneLine( + $"{prefix} AGENT {run.AgentName} {run.Phase}{summary}", + lineWidth)) + .WithForeground(ActivityColor(run.Phase)) + }; + if (run.ActiveToolName is not null) + { + var toolPrefix = run.ParentCallId is null ? " ↳" : " ↳"; + rows.Add(new TextNode(ChatPresentationRenderer.OneLine( + $"{toolPrefix} TOOL {run.ActiveToolName} {run.Phase}", + lineWidth)) + .WithForeground(ActivityColor(run.Phase))); + } + + return Layouts.Vertical([.. rows]); } private static string BuildApprovalDetail(ToolInteractionRequest approval) @@ -593,14 +689,23 @@ private static string BuildApprovalDetail(ToolInteractionRequest approval) ChatViewModel.MaxExpandedApprovalBodyChars); } + private static int ApprovalDetailHeight(ToolInteractionRequest approval, int width) + { + var contentWidth = Math.Max(1, width - 2); + var lineCount = BuildApprovalDetail(approval) + .Split('\n') + .Sum(line => Math.Max(1, (line.Length + contentWidth - 1) / contentWidth)); + return Math.Clamp(lineCount, 3, 10); + } + private static string StatusKeys(int width, bool hasApproval, bool hasComposer) { if (hasApproval) return width >= 88 - ? "↑↓ select · Enter confirm · Ctrl+O detail · Esc deny · Ctrl+Q quit" + ? "↑↓ select · Enter confirm · Ctrl+O details · Esc deny · Ctrl+Q quit" : "↑↓ select · Enter confirm · Esc deny"; if (!hasComposer) - return width >= 70 ? "Work active · Ctrl+O inspect · Ctrl+Q quit" : "Work active · Ctrl+Q quit"; + return width >= 70 ? "Ctrl+O inspect · Ctrl+Q quit" : "Ctrl+Q quit"; if (width >= 110) return "Enter send · Shift+Enter newline · Esc Esc clear · Ctrl+O inspect · Ctrl+Q quit"; return width >= 66 @@ -680,7 +785,7 @@ state.PendingApproval is null && string.IsNullOrWhiteSpace(state.AssistantText); } -internal static class ChatPresentationRenderer +internal static partial class ChatPresentationRenderer { public static ILayoutNode BuildStableBlock(ChatPresentationBlock block, int width) { @@ -688,17 +793,27 @@ public static ILayoutNode BuildStableBlock(ChatPresentationBlock block, int widt ? DateTimeOffset.FromUnixTimeMilliseconds(block.TimestampMs).ToString("HH:mm") : string.Empty; var timePart = string.IsNullOrEmpty(timestamp) ? string.Empty : $" {timestamp}"; - var body = block.Summary; + var body = block.Kind == ChatBlockKind.Assistant + ? MarkdownToPlainText(block.Summary) + : block.Summary; + + var bodyNode = new TextNode(VisibleControlText(body, 16_000)) + .WithForeground(BodyColor(block)) + .Width(Math.Min(width, MaximumReadableWidth)); + var bodyRow = Layouts.Horizontal() + .WithChild(bodyNode) + .WithChild(Layouts.Empty().Fill()); return Layouts.Vertical() .WithChild(new TextNode($"{block.Label}{timePart}") .WithForeground(LabelColor(block)) .Bold()) - .WithChild(new TextNode(VisibleControlText(body, 16_000)) - .WithForeground(BodyColor(block))) + .WithChild(bodyRow) .WithChild(new TextNode(string.Empty)); } + private const int MaximumReadableWidth = 120; + public static string OneLine(string? text, int maximumLength) { var safe = VisibleControlText(text ?? string.Empty, Math.Max(1, maximumLength)); @@ -735,6 +850,31 @@ public static string VisibleControlText(string text, int maximumLength) public static string SemanticCopyText(string text) => VisibleControlText(text, int.MaxValue); + public static string MarkdownToPlainText(string markdown) + { + var output = new StringBuilder(markdown.Length); + var inCodeFence = false; + foreach (var sourceLine in markdown.ReplaceLineEndings("\n").Split('\n')) + { + var trimmed = sourceLine.TrimStart(); + if (trimmed.StartsWith("```", StringComparison.Ordinal) + || trimmed.StartsWith("~~~", StringComparison.Ordinal)) + { + inCodeFence = !inCodeFence; + continue; + } + + var line = inCodeFence + ? $" {sourceLine}" + : MarkdownLineToPlainText(sourceLine); + if (output.Length > 0) + output.Append('\n'); + output.Append(line); + } + + return output.ToString(); + } + public static string BuildSemanticTurn( IReadOnlyList transcript, int selectedIndex) @@ -764,6 +904,23 @@ public static string CompactIdentity(string identity, int maximumLength) => ? identity : $"…{identity[^Math.Max(1, maximumLength - 1)..]}"; + public static string ApprovalPath( + ChatPresentationState state, + ToolInteractionRequest approval) + { + const string subAgentMarker = "/subagent-approval/"; + var markerIndex = approval.CallId.Value.IndexOf(subAgentMarker, StringComparison.Ordinal); + if (markerIndex <= 0) + return approval.ToolName.Value; + + var parentCallId = approval.CallId.Value[..markerIndex]; + var requester = state.SubAgents.Values.FirstOrDefault(run => + string.Equals(run.ParentCallId, parentCallId, StringComparison.Ordinal)); + return requester is null + ? $"sub-agent › {approval.ToolName.Value}" + : $"{requester.AgentName} › {approval.ToolName.Value}"; + } + private static Color LabelColor(ChatPresentationBlock block) => block.Kind switch { ChatBlockKind.User => Color.Cyan, @@ -771,6 +928,7 @@ public static string CompactIdentity(string identity, int maximumLength) => ChatBlockKind.Thought => Color.Yellow, ChatBlockKind.Tool when block.IsFailure => Color.Red, ChatBlockKind.Tool => Color.Green, + ChatBlockKind.Parallel => Color.BrightCyan, ChatBlockKind.SubAgent when block.IsFailure => Color.Red, ChatBlockKind.SubAgent => Color.Green, ChatBlockKind.Approval => Color.Yellow, @@ -785,7 +943,44 @@ public static string CompactIdentity(string identity, int maximumLength) => private static Color BodyColor(ChatPresentationBlock block) => block.Kind switch { ChatBlockKind.Error or ChatBlockKind.Diagnostic => Color.Red, + ChatBlockKind.Approval when block.IsFailure => Color.Red, ChatBlockKind.Usage or ChatBlockKind.Compaction => Color.Gray, _ => Color.White }; + + private static string MarkdownLineToPlainText(string sourceLine) + { + var line = HeadingPrefixRegex().Replace(sourceLine, string.Empty); + line = QuotePrefixRegex().Replace(line, "│ "); + line = BulletPrefixRegex().Replace(line, "$1• "); + line = MarkdownLinkRegex().Replace(line, "$1 <$2>"); + line = InlineCodeRegex().Replace(line, "$1"); + line = StrongRegex().Replace(line, "$2"); + line = StrikeRegex().Replace(line, "$1"); + return EmphasisRegex().Replace(line, "$2"); + } + + [GeneratedRegex(@"^[ \t]{0,3}#{1,6}[ \t]+")] + private static partial Regex HeadingPrefixRegex(); + + [GeneratedRegex(@"^[ \t]{0,3}>[ \t]?")] + private static partial Regex QuotePrefixRegex(); + + [GeneratedRegex(@"^([ \t]*)[-+*][ \t]+")] + private static partial Regex BulletPrefixRegex(); + + [GeneratedRegex(@"\[([^\]\r\n]+)\]\(([^)\r\n]+)\)")] + private static partial Regex MarkdownLinkRegex(); + + [GeneratedRegex(@"`([^`\r\n]+)`")] + private static partial Regex InlineCodeRegex(); + + [GeneratedRegex(@"(\*\*|__)(?=\S)(.+?\S)\1")] + private static partial Regex StrongRegex(); + + [GeneratedRegex(@"~~(?=\S)(.+?\S)~~")] + private static partial Regex StrikeRegex(); + + [GeneratedRegex(@"(? $NETCLAW_HOME/config/netclaw.json" -Enter -Wait+Screen@5s /TAPE\$/ - -Type "netclaw daemon start" -Enter -Wait+Screen@30s /Daemon started|already running/ -Wait+Screen@5s /TAPE\$/ -Type "until netclaw status >/dev/null 2>&1; do sleep 1; done; echo DAEMON_READY" -Enter -Wait+Screen@90s /DAEMON_READY/ -Wait+Screen@5s /TAPE\$/ - -Type "netclaw chat" -Enter -Wait+Screen@60s /Composer/ - -Type "Say hello" -Shift+Enter -Type "in one word" -Enter -Wait+Screen@15s /Generating/ -Wait+Screen@120s /USAGE/ -Wait+Screen@20s /Ready/ -Wait+Screen@20s /Composer/ - -Up -Escape -Escape -Wait+Screen@10s /Input cleared/ - -Ctrl+O -Wait+Screen@10s /INSPECTOR/ -Escape -Ctrl+Q -Sleep 1s -Wait+Screen@10s /TAPE\$/ - -Type "echo CHAT_INLINE_EXIT=$?" -Enter -Wait+Screen@5s /CHAT_INLINE_EXIT=0/ -Type "exit" -Enter From 63bf6fd42027b123ea71d042ea99c3a5fb9be70f Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 20:46:33 -0500 Subject: [PATCH 05/39] Report modified Enter capability in chat --- Directory.Packages.props | 2 +- .../redesign-netclaw-chat-tui/tasks.md | 2 +- .../Tui/InlineChatPageTests.cs | 41 ++++++++++++++ src/Netclaw.Cli/Tui/InlineChatPage.cs | 55 +++++++++++++++++-- 4 files changed, 94 insertions(+), 6 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0de9666ba..fdd1ad28c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -76,7 +76,7 @@ - + diff --git a/openspec/changes/redesign-netclaw-chat-tui/tasks.md b/openspec/changes/redesign-netclaw-chat-tui/tasks.md index 0c7b5d91a..6b6e3e672 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/tasks.md +++ b/openspec/changes/redesign-netclaw-chat-tui/tasks.md @@ -43,7 +43,7 @@ - [ ] 4.1 Add a text-history cancellation API that restores the saved draft. - [ ] 4.2 Add typed-key tests for Up, Down, draft restoration, and history cancellation. - [ ] 4.3 Verify `Shift+Enter` across legacy, Kitty, and native raw input paths. -- [ ] 4.4 Add a visible capability result when a terminal cannot distinguish `Shift+Enter`. +- [x] 4.4 Add a visible capability result when a terminal cannot distinguish `Shift+Enter`. - [ ] 4.5 Add dimension-free scroll operations that use the measured viewport. - [ ] 4.6 Preserve mouse coordinates on wheel input and test route selection. - [x] 4.7 Add semantic copy data that remains separate from display glyphs. diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index c73089e7e..5ea3bac53 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -45,6 +45,47 @@ public async Task ShiftEnter_AddsNewline_AndEnterSubmitsExactText() await harness.StopAsync(runTask); } + [Theory] + [InlineData(40)] + [InlineData(60)] + [InlineData(80)] + [InlineData(120)] + public async Task ModifiedEnterUnavailable_ShowsAVisibleCapabilityResult(int width) + { + await using var harness = CreateHarness(width: width); + var runTask = harness.StartAsync(); + + harness.Events.Enqueue(new TerminalInputCapabilitiesChanged( + new TerminalInputCapabilities( + TerminalCapabilityAvailability.Unavailable, + TerminalInputCapabilitySource.LegacyTerminal))); + + try + { + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Shift+Enter unavailable")); + } + catch (OperationCanceledException) + { + Assert.Fail($"Capability result was not visible. Screen:\n{harness.Terminal}"); + } + await harness.StopAsync(runTask); + } + + [Fact] + public async Task ModifiedEnterAvailable_ShowsTheNewlineShortcut() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + + harness.Events.Enqueue(new TerminalInputCapabilitiesChanged( + new TerminalInputCapabilities( + TerminalCapabilityAvailability.Available, + TerminalInputCapabilitySource.KittyKeyboardProtocol))); + + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Shift+Enter newline")); + await harness.StopAsync(runTask); + } + [Fact] public async Task HistoryDown_RestoresTheSavedDraft() { diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 992299800..2ae9016c8 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -52,6 +52,8 @@ public sealed class InlineChatPage : ReactivePage private long? _lastEscapeTimestamp; private int _inspectorIndex; private bool _inspectorOpen; + private TerminalCapabilityAvailability _modifiedEnterKeySupport = + TerminalCapabilityAvailability.Unknown; public InlineChatPage( IAnsiTerminal terminal, @@ -108,6 +110,13 @@ protected override void OnBound() ViewModel.Input.OfType() .Subscribe(_ => _liveRegion.Invalidate()) .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(capabilities => + { + _modifiedEnterKeySupport = capabilities.Capabilities.ModifiedEnterKeySupport; + _liveRegion.Invalidate(); + }) + .DisposeWith(Subscriptions); } public override ILayoutNode BuildLayout() => _liveRegion; @@ -625,8 +634,17 @@ private ScrollableContainerNode EnsureApprovalDetail(ToolInteractionRequest appr private ILayoutNode BuildStatusLine() { var status = ViewModel.StatusMessage.Value; - var displayStatus = status == "Generating..." ? "Working…" : status; - var keys = StatusKeys(_terminal.Width, _state.PendingApproval is not null, ShowsComposer(_state)); + var displayStatus = status switch + { + "Generating..." => "Working…", + "Connecting..." when _terminal.Width < 48 => "Connect", + _ => status + }; + var keys = StatusKeys( + _terminal.Width, + _state.PendingApproval is not null, + ShowsComposer(_state), + _modifiedEnterKeySupport); var statusLine = Layouts.Horizontal() .WithChild(new TextNode($" {displayStatus}") .WithForeground(StatusColor(status)) @@ -698,7 +716,11 @@ private static int ApprovalDetailHeight(ToolInteractionRequest approval, int wid return Math.Clamp(lineCount, 3, 10); } - private static string StatusKeys(int width, bool hasApproval, bool hasComposer) + private static string StatusKeys( + int width, + bool hasApproval, + bool hasComposer, + TerminalCapabilityAvailability modifiedEnterKeySupport) { if (hasApproval) return width >= 88 @@ -706,11 +728,36 @@ private static string StatusKeys(int width, bool hasApproval, bool hasComposer) : "↑↓ select · Enter confirm · Esc deny"; if (!hasComposer) return width >= 70 ? "Ctrl+O inspect · Ctrl+Q quit" : "Ctrl+Q quit"; + + if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unavailable) + { + if (width >= 110) + return "Enter send · Shift+Enter unavailable · Esc Esc clear · Ctrl+O inspect · Ctrl+Q quit"; + + return width >= 66 + ? "Enter send · Shift+Enter unavailable · Esc Esc clear" + : width >= 48 + ? "Enter send · Shift+Enter unavailable" + : "Shift+Enter unavailable"; + } + + if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unknown) + { + if (width >= 110) + return "Enter send · Shift+Enter status pending · Esc Esc clear · Ctrl+O inspect · Ctrl+Q quit"; + + return width >= 66 + ? "Enter send · Shift+Enter pending · Esc Esc clear" + : width >= 48 + ? "Enter send · Shift+Enter pending" + : "Shift+Enter pending"; + } + if (width >= 110) return "Enter send · Shift+Enter newline · Esc Esc clear · Ctrl+O inspect · Ctrl+Q quit"; return width >= 66 ? "Enter send · Shift+Enter line · Esc Esc clear · Ctrl+O inspect" - : "Enter send · Shift+Enter line · Esc Esc clear"; + : "Enter send · Shift+Enter line"; } private static Color ActivityColor(string phase) => phase.ToLowerInvariant() switch From 96fb424becd71faa5f3b5c165b80e85da5b969b6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 21:11:49 -0500 Subject: [PATCH 06/39] Use Termina 0.17.0-beta.4 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index fdd1ad28c..bcdd23aae 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -76,7 +76,7 @@ - + From 06c552a84afa431add7f2fa83e9f6055ee91481c Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 21:12:14 -0500 Subject: [PATCH 07/39] Record completed input and scroll tasks --- .../changes/redesign-netclaw-chat-tui/tasks.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openspec/changes/redesign-netclaw-chat-tui/tasks.md b/openspec/changes/redesign-netclaw-chat-tui/tasks.md index 6b6e3e672..d37631394 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/tasks.md +++ b/openspec/changes/redesign-netclaw-chat-tui/tasks.md @@ -40,12 +40,12 @@ ## 4. Termina Input, Scroll, and Copy Primitives -- [ ] 4.1 Add a text-history cancellation API that restores the saved draft. -- [ ] 4.2 Add typed-key tests for Up, Down, draft restoration, and history cancellation. -- [ ] 4.3 Verify `Shift+Enter` across legacy, Kitty, and native raw input paths. +- [x] 4.1 Add a text-history cancellation API that restores the saved draft. +- [x] 4.2 Add typed-key tests for Up, Down, draft restoration, and history cancellation. +- [x] 4.3 Verify `Shift+Enter` across legacy, Kitty, and native raw input paths. - [x] 4.4 Add a visible capability result when a terminal cannot distinguish `Shift+Enter`. -- [ ] 4.5 Add dimension-free scroll operations that use the measured viewport. -- [ ] 4.6 Preserve mouse coordinates on wheel input and test route selection. +- [x] 4.5 Add dimension-free scroll operations that use the measured viewport. +- [x] 4.6 Preserve mouse coordinates on wheel input and test route selection. - [x] 4.7 Add semantic copy data that remains separate from display glyphs. - [x] 4.8 Add clipboard failure output that preserves the selected semantic data. - [x] 4.9 Add headless tests that exclude borders, control bytes, and truncated display text from copied data. @@ -87,7 +87,7 @@ - [x] 7.7 Add concise live forms and immutable settled forms for each event lifecycle. - [x] 7.8 Add responsive layout rules and snapshots at 40, 60, 80, and 120 columns. - [ ] 7.9 Add tail-follow state, a new-event count, and an explicit return-to-tail action. -- [ ] 7.10 Replace fixed scroll dimensions with the actual measured viewport. +- [x] 7.10 Replace fixed scroll dimensions with the actual measured viewport. - [x] 7.11 Route all chat output through the inline output owner. - [x] 7.12 Add a visible diagnostic for an unsupported output type or invalid lifecycle transition. @@ -112,7 +112,7 @@ - [x] 9.2 Keep init, config, provider, model, and session picker applications in `FullScreen` mode. - [x] 9.3 Exit the session picker before a selected inline chat application starts. - [ ] 9.4 Show a visible error when the selected chat application cannot start. -- [ ] 9.5 Restore cursor, input, mouse, paste, and terminal modes on normal, canceled, and failed exits. +- [x] 9.5 Restore cursor, input, mouse, paste, and terminal modes on normal, canceled, and failed exits. - [x] 9.6 Add command tests that prove each application selects its required presentation mode. ## 10. Package and Cross-Repository Integration From 2fed07cab368c0f8f4839d3f92799ee3f1da0ce7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 21:24:57 -0500 Subject: [PATCH 08/39] Prove prompt history recall --- Directory.Packages.props | 2 +- .../Tui/InlineChatPageTests.cs | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index bcdd23aae..11ed03e02 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -76,7 +76,7 @@ - + diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index 5ea3bac53..a8585bab1 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -86,6 +86,26 @@ public async Task ModifiedEnterAvailable_ShowsTheNewlineShortcut() await harness.StopAsync(runTask); } + [Fact] + public async Task HistoryUp_RecallsThePreviousPrompt() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + + harness.Input.EnqueueString("previous prompt"); + harness.Input.EnqueueKey(ConsoleKey.Enter); + Assert.Equal("previous prompt", + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + + harness.Input.EnqueueString("saved draft"); + harness.Input.EnqueueKey(ConsoleKey.UpArrow); + harness.Input.EnqueueKey(ConsoleKey.Enter); + + Assert.Equal("previous prompt", + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + await harness.StopAsync(runTask); + } + [Fact] public async Task HistoryDown_RestoresTheSavedDraft() { From 293d872dc47091aefb55017993dbd26ca99029e9 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 11 Aug 2026 22:23:36 -0500 Subject: [PATCH 09/39] Stabilize approval detail screen proof --- src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index a8585bab1..ec2240450 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -252,9 +252,17 @@ public async Task CtrlO_ShowsApprovalSecurityContext() await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); harness.Input.EnqueueKey(ConsoleKey.O, control: true); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Directory: /work/netclaw")); + var screen = string.Empty; + await harness.WaitUntilAsync(() => + { + screen = harness.Terminal.ToString(); + return screen.Contains("Patterns: dotnet", StringComparison.Ordinal) + && screen.Contains("Verbs: dotnet", StringComparison.Ordinal) + && screen.Contains("Directory: /work/netclaw", StringComparison.Ordinal) + && screen.Contains("Complex command", StringComparison.Ordinal) + && screen.Contains("third-party context", StringComparison.Ordinal); + }); - var screen = harness.Terminal.ToString(); Assert.Contains("Patterns: dotnet", screen, StringComparison.Ordinal); Assert.Contains("Verbs: dotnet", screen, StringComparison.Ordinal); Assert.Contains("Complex command", screen, StringComparison.Ordinal); From b28a4ede77cffcdb44b209a95e73bb345c7a9e67 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 05:09:18 -0500 Subject: [PATCH 10/39] Define a quiet chat visual grammar --- docs/ui/TUI-002-chat-visual-grammar.md | 131 +++++++++++++++++++++++ docs/ui/mockups/chat-quiet-active.png | Bin 0 -> 71717 bytes docs/ui/mockups/chat-quiet-active.svg | 74 +++++++++++++ docs/ui/mockups/chat-quiet-approval.png | Bin 0 -> 66304 bytes docs/ui/mockups/chat-quiet-approval.svg | 65 +++++++++++ docs/ui/mockups/chat-quiet-inspector.png | Bin 0 -> 92489 bytes docs/ui/mockups/chat-quiet-inspector.svg | 80 ++++++++++++++ docs/ui/mockups/chat-quiet-normal.png | Bin 0 -> 80661 bytes docs/ui/mockups/chat-quiet-normal.svg | 60 +++++++++++ 9 files changed, 410 insertions(+) create mode 100644 docs/ui/TUI-002-chat-visual-grammar.md create mode 100644 docs/ui/mockups/chat-quiet-active.png create mode 100644 docs/ui/mockups/chat-quiet-active.svg create mode 100644 docs/ui/mockups/chat-quiet-approval.png create mode 100644 docs/ui/mockups/chat-quiet-approval.svg create mode 100644 docs/ui/mockups/chat-quiet-inspector.png create mode 100644 docs/ui/mockups/chat-quiet-inspector.svg create mode 100644 docs/ui/mockups/chat-quiet-normal.png create mode 100644 docs/ui/mockups/chat-quiet-normal.svg diff --git a/docs/ui/TUI-002-chat-visual-grammar.md b/docs/ui/TUI-002-chat-visual-grammar.md new file mode 100644 index 000000000..4587760d6 --- /dev/null +++ b/docs/ui/TUI-002-chat-visual-grammar.md @@ -0,0 +1,131 @@ +# TUI-002: Chat Visual Grammar + +Source PRDs: `PRD-004`, `PRD-009` + +## Design Intent + +Netclaw chat uses a quiet console grammar. +The design gives content priority over application chrome. +Color, whitespace, weight, and cell background create the hierarchy. +Decorative glyph borders do not create the hierarchy. + +This grammar has four goals: + +- Make each turn easy to scan. +- Keep parallel work easy to follow. +- Keep approval context visible and calm. +- Keep native terminal selection clean. + +## Selection Rule + +The settled transcript contains no corner, border, rail, or divider glyphs. +The live region also avoids these glyphs where practical. +Cell background can create a visual surface because terminal selection does not copy its color. + +Visible text must carry useful meaning when a user selects it. +Labels such as `Tool`, `Agent`, `Approval`, and `Failed` are semantic content. +Spinners, tree rails, corner glyphs, and ornamental rules are not semantic content. + +## Visual Tokens + +The application maps these roles to the active terminal palette. +The application does not require one fixed theme. + +| Role | Purpose | +|------|---------| +| Canvas | The normal terminal background | +| Surface | A quiet cell background for a prompt or activity group | +| Surface strong | The selected event or the Composer | +| Primary | Netclaw identity and active controls | +| Human | User identity and user prompts | +| Success | Completed work | +| Warning | Thoughts and approval context | +| Danger | Failures and denied actions | +| Muted | Time, metadata, and inactive controls | + +The design uses bold text only for identity, state, and a selected action. +The design uses dim text for metadata and shortcut hints. + +## Spacing Rhythm + +- The viewport uses a two-cell left margin at 60 columns or more. +- A turn uses one blank line before each speaker. +- A speaker label and message share a compact group. +- An activity group uses one blank line before and after the group. +- A completed tool group collapses to one summary row. +- The Composer uses two text rows plus one hint row. + +## Region Grammar + +### Session Bar + +The Session Bar uses one row. +It shows the product, session, model, context, and connection state. +It uses a cell background or underline style without divider glyphs. + +### Turn + +The user prompt uses a quiet surface background. +The Netclaw response uses the canvas background. +The user label and time appear above the prompt. +The Netclaw label and time appear above the response. + +### Activity Group + +An active group uses one quiet surface background. +Each row uses a semantic type label and a state word. +Indentation shows ownership without tree-rail glyphs. +Parallel rows keep a stable order. + +A settled group collapses to one receipt row: + +`Tools 3 complete · 2.8s · Ctrl+O for details` + +### Thought + +An active thought uses one short warning-colored line. +A settled thought collapses to a token and duration receipt. +Raw thought text remains available in the Inspector when policy allows it. + +### Approval + +An approval uses an amber cell background at the top of the Decision Gate. +The gate has no border glyphs. +The command uses a separate code surface. +Each decision uses a text label with an inverse selected state. + +### Composer + +The Composer uses a strong surface background and no border glyphs. +The first cell uses a primary background as a focus cue. +The prompt text starts after that cell. +The hint row lists only controls that work in the current terminal. + +### Inspector + +The Inspector uses two background surfaces at wide widths. +The left surface lists semantic events. +The right surface shows complete safe detail. +A blank background gutter separates both surfaces. +The Inspector uses no vertical divider glyph. + +## Content Rules + +- Do not show raw JSON in the main transcript. +- Do not put a complete command on a status row. +- Do not join a tool result to the next agent event. +- Do not repeat a tool name on every progress update. +- Do not show an empty fixed-height transcript panel. +- Do not use uppercase for complete sentences. +- Do not use more than three emphasis colors in one region. +- Do not truncate a security decision target. + +## Mockup Set + +- `mockups/chat-quiet-normal.svg` +- `mockups/chat-quiet-active.svg` +- `mockups/chat-quiet-approval.svg` +- `mockups/chat-quiet-inspector.svg` + +These files show the intended hierarchy at 120 columns. +They are design targets, not raster product screenshots. diff --git a/docs/ui/mockups/chat-quiet-active.png b/docs/ui/mockups/chat-quiet-active.png new file mode 100644 index 0000000000000000000000000000000000000000..5cfafb79792c506490ae0d3c6a61daf1238cf7aa GIT binary patch literal 71717 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 + + + + + NETCLAW + incident-review + deepseek-v4-flash + + connected + 39% + + You + 09:56 + + + Audit the failed release and ask two reviewers for independent diagnoses. + + Thought + I will compare the release job with the package manifest. + 4s + + + + ACTIVITY + 2 active + 1 complete + 12s + + + DONE + Tool + read release workflow + 1.2s + + + LIVE + Tool + spawn_agent + two reviewers + 8.4s + + Agent + release-reviewer + checks package order + model + + Agent + compatibility-reviewer + checks feed compatibility + model + + + LIVE + Tool + compare package manifest + 3.1s + + Working + Ctrl+C interrupt + Ctrl+O inspect + Ctrl+Q quit + diff --git a/docs/ui/mockups/chat-quiet-approval.png b/docs/ui/mockups/chat-quiet-approval.png new file mode 100644 index 0000000000000000000000000000000000000000..a92f4441fbc33fec55148902faa5a517c125b670 GIT binary patch literal 66304 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

qyHAbow;DXs14YZ{5?~Tj)nZc zb$eTW3_(1j8+ooLfk+X~_b_aBgNo=)_`eR!I0 z*EF+#Co>Vu{@QOV90A?DMN-gQRrQ{z3;6-LXgt_1V zC=0dzkl}=IZoSPgZ#0$1ryS31y4hy((rxTJg=)KcB_R9<lGK8ZiAr{CU_Pp9*}nAy(j$6rp|S@5k+>1~hE{Z0T=JSX1DF50rp1t;P7NxF zcsB5cc9PTIjYM@e*K78n{Gg9D1_kY9U7Wq`6fS+I%bCgP^3?WI>aL8zLdd%IDAh?S zM+wo30z?k2NFa$4k<_FHNxnR6N?JD6fYZY`KvPb{_A;#xAsCZdYoc`!!+E40f(x9f zxad+C`Gfflh`Prit*2!fzyj;JuP5=|mZ&lBNxJ5L@5zsSOD|PGH(9PQ`F%^@|BP)5 zn&OC@rVq=^nEWSOA*iO@eM=wy$x+*0{_g%e6F}wLqe31EcV3sjx>;s8-(UJIsPBE* zmdSFD$lE+g<;b@XutEW9VKKcmviDCsjmJzY7gI7LJ);n-;86LpQmHv&u*2hLR_@8( zXG@y_h)Lx7E4zA1%`(nEpcWZKK5x>>l2C(SsSfVOdrVW6eU^BeKS$GTVc7NBk>eAT z+lW^TIh(5;U(JAURc$XMSrfefbej92fv~>Oh|maoZOYu2$&6$4p$ez22bFNc55~F8 z!xi|LFMjB!@X+_iB~p8%OVRduPuwVh!xcrLnxd7{nbgg_M=Ir+cj_~66ocT(dTliIhm#E}pU9OM-!mSxKBOS&3>Q zSI*rcHz=y~SQR#~Aj#qtQ10T3!qn3$BJ_O^FVA-_Tyjo_Ev%T9LjYT(CV=I8xgyvieV{dq^7G zve~0YYpLF7uz7A{Vp8;&^LT^PvybCmjd5MZ$gXEd;?#UL+0#1^`75P(Z94joQ_L;o zh$K&+|0?96usopyw)^dY$40jJt^Lg2YXyv+(}0XrJ$tlLy-#BN8A%cfTBJ;uKsSrO z#l8^|axMheXC%O)Ho^Gy@o8oegusBYrQO*geJd)GtQr4>6_B>_dJMfQ{5EpSb^vb& z1pncuK_L>?XZmM_BSB5Lw(YUOKF~U|hsAyYmz3sa^Fi{W*W4I;z5eVeWCZE!`l{R0 zb{106nc=TNL}}CYqBXwI=hmO;7cDg9@s&EtyHIGEHf;@fJG@f(C7CHp4ereprwF2- zCn}i^O${Xr;YbS}8F`E^TGc~s)o5B0nK65B@ZD{W_Uo`U#QRLDBoD!6&oH2ru`UTY zKZ!WJm1|zHoF&B!y*Ipw%%cy)Et0PkF$+T_A-jVgUtbgF+nmxGiB{2O$6RObWZxkp zrld)slho=KW*RC+@KnodWhj`v=*+H#aF~m(B#kyPly`Oz8X@5%Nr)oRFL1AOCoND> z<1ZdxsG6=J2lq9ROOWKK?K@d$Qw#7!;FO=B`L*?F10Kmp_2t%lH`!}~qOW*5M(0U1 zT*OK%oR;le-Lt?VSI76&}O>1(Im#7s7F6Fgd7)8N!)Vg;h2%di{H+MQRci$a5g~(E_EqL`rCMlRg2f8K*)LJTy6P*h$s|;o)j!PTc z+#RV6W54S?RCvIkd99Vq+DO^7w5_^q$)rB(;V8Xr=J!jF4rOV%uWjz!c}aM2;o_9Z zK=6~llJv)J+ln5^umn`rxU}0e`?Z3@O9Jk88O5bn`Zhc6^QtUNz6kto$+$Vvkn?t8 zZSvhrI3>3leDVOwKQD6j9zG+o-ys|v(uYswfiWlZlpl>~P)^_9|JRn8lj55gGnX11 zwTw1n?u}ov4eKgv{_tTCa|P+o*NsToBa7sfx0)L7q;wz&k&_z)qG~q4{({c7-DdYW z=MQVYxd5T@V^y-4=ix~MRJ1eGGQWac?$%{89%}QxdeDiD{d3LdaFTK82!WCRiL1dG z9^qw)fM%{31>6imatdF}{dH|ekDZD{FDbd<_OR0oCzvG8Q=QlR7*vn5VgPjMmLy;| z1)y%A5NmpiOQ&gEL^^h?bj8z|^;lrrN7>gaPCO~5SJ>B6iqku(rMMmt|aJw_e!zmp(h!Kle5p;`t#G9|8 z89oJz*%R1m7lj(x6`yyQJb-BrFn3q+?N)ox0t9dWXbjCt#?C}rHt7v=8@>MVp$5>T zyQ&G#w_^};^8A{dB%9H{q2+1Gwz~hVE;bt!bceUURXk>L={PF9O0) z_qXem64jA2<{#U%eExPVI=?Y2j~CXFs~D~=oH3G8 z?@W4NrRg<3z>N{qruz(E4}PFiP&huG{IhLMZ7ztjh&I_mYTo^-zy%_nmNLf)C#4AE);Eg z!ShMiJN_3Tlw0QMFb;Ikae%ptwO{{JkKh?7l{F`|D1?IX(+EEcg@ht2?5TIQ8Y3Q* zUE>PopuN@WmgxrbFO=VEIN_st^B3ib5AaJnhy1jip1xiCafmZ%cTfF`v4fNA%DBij zqW~O|aKb-obE;PwN8^Mb!iMZMrw|8~vv|$WS*!CbYNM(N?B&KV%&w;?OJc{dheBu*+-^eGT2l1kWYh5|M9rN4W{!L3S{O&s z6jy_-oUlsTo{@30@%vD-dDs-mk)ihWV^fb~RmPH(K&n3PFs4#r{P>uv^Gz$Z_&L9@ z&A>&!8oR0NBP&X_Fy5>TEvaB^>=VX}$?+}D2Gf%FWh|dB>C|NX*G=fkgDFob7k4fj zjotV9ZA*luHP^P(-~|)GgBZVem416770<}R(gBLb9PN^@cel`EgrfIk_I>H-x9&Uj zV431NsZ7Iz@kez)2atxgf1FmDgyUt)BXU$@wPG%MYqfI<5HyTLha2xBKJHtx%9iY# zP;dfNM)rgm0xHGzaH*&EClqP23?KXmb>#|9x$$$TS&SkXZCX7PBnDnBZX9YWGqwEq zmg7&oepqrf$h^W{sd-4=Km9@k+e>}c=y#ye?`ozWWBDaNBINOE^8UrQ>GP&l!ob+$ zgucM;i8883`I-l;U2KO_{erR*o8|6}bbcr($Itm{KpnxCR`<6~UJ*fk- ztm?6A+4_c}CiwALsXVAT_e3tx^;?d6_En^u51{h(tsA=>G%iR<$Sq{p$Jo@TGW#x6#9f~D2G;pglB-6#%X_{6aus}0=L zK)he;>XI$@JL$kV#8)s5w+m|UFAnACg12sD;N&iwsZ@VI@Hv^^O$m#!BHJR&y=>pb z98fG0P13RM{)o}WUROSA(RvnBqCR<~0#zAn^!QQWKCiHfm4slH_|271AyTdW z9JL&MtM}cFtq)iYDNb=D$JcDaLSe3 zW@HGbM`i8tAL zHYB!Na>Q$W=)$puD?vY-IF&sj_?Lt*?hAYuL)cG&%Sx>w4Eg z;9wiw{quFKzSI>;i{I2(84a-h6Q=&x}b>=GE*QlkZpKXx$$%DqO{3BmP#yOHD)JzCI1!^oWVeE-(zqk||x(**n zLA##sxA&pB1w{QaSvSKG%Cz}KUaag`Z4)P9TcBScou z*tyxQ`5M>uCMAk|M>}_C*z>um!l!KUV)gd?K{be$w{Syp!LA$Y-t3U;1bEadwap3W9IUFXGK^C$IS{?4!(MS_blG2ptsXgds~0qa6{iiOuDSb zOp1%iynF3<1*rlsj1376xRE{GQWL!7(M47Cb)X3CWUcn{&?M${aDi6BJs!n%aZSxyE=4Nohv@A`^T{ zyxY+l_Qj^iU4t)-ze%(+HoqA(!yJYEHYW<3O3O#Yth9T^OZ|E?WGwG}f~~xb846B& zb{8hH@zBA#=v>*W$8>zYJNy_djDyftTe1ZX=VNNK?&Qp!Ld#&^C1xqP zMDEg_wBnQC&!)^n=^__-#R4mbX8DHWE*wmyM)*2#4#6p{xH-7nZaa@tLBmz85aO*# zPGK%!s`1~5^s8KrbNx8Cvd5#t`mHYOnEpR{n=1P*>H0sk*gqoNYeZDfyBGhpDJ`lu zLy5ghSo$Z!_*Wgfb+4d8V%K=*kNncLiQh~u;E4R+6@kCzSV!hi{=do|py14JjQ|k; zhE+Y&x(_zT0nnVsvx_4o>T41Ab&b71F@hVAsFR z@k_1SymN;5=4zg1Z+C3Hq0Mik^M=B%M@2^bfc*WMDXrgt{8&xMx0(1|Gx2HJO>Mxg z2Whzd4r3NBm~1dzEi-=)z{Jh{Y}=bnG&sBdw{ST_J^HU|k`W8{Oux|QQh2A3wg0uh z{#i`Q~E}Iu)^Hlxv?XLRU|6l@MPmY;n_aWsS zD(q4dZcW1w@boCA-8B&B+%ujl34AeA!&r%*>Kgh=#~Ixm7Hwi^WPzSU z{7Mf#v&mYWMvPMzDI_-76c%xH^B4G^lK(yN53;T|JOvW0-Yzk zV<51!DBSbCrKf z&p+Df16}@dwjMHhWGg%S@h5tLIH56gWu$R`eLu?O`U4nS+r{N7tfd8i>HWl&f_^AE z>dV0TkY12bdf`>ED*q9K2INQrr4&?^WAD*HV>1(tLffE>Z*B`b{--XJ7APi-{htrz zqr)L+^7x3Le66&3TVta@134L-ZCMjThG!`;Jl+!_p*Xz1cGS98k9l|Yae)ooGEpUn z{$3EKIFV9C=x8pEz;}{qnT5E>5mklNbwsXuMG=rN#4V!?+?{xE|GVZ z0>Pxk({^t#p|q8w3ybTLTVo_ttrqD7UjjC$E9A0v4Cyth`taA}Hx5U2@NfFJJZ&|Q zen3g~hKmPne^iV2NS9z8`B45``*cySzy5TJudh9Nz%hzVa;U3t=l8_VU04YT4fcg= z7t&Dz5Ig&X^dfit*69KM>X>voPbbSkyY1kO&8I5Us^N3r1b9hg-JqX5*r2aSv+x0KI&^O{hsox#K}CTTPv@>9 zmdF2W^Iu3J&2VxfypjRx4vv-Sql0FzhT<3Sk~vCHjshM(zI^jBD9neZ>`!NZp;w0R zSD}|qgpg(ypHLmFo^)`B1{Eh8Msi3g2+jt$<@pNOE84HxK|cozKbjQpgzzoM)PPi+ z#2}naAty&$&3)B``m*MY`dXIfjETwLq^23crLM;Z@*Y~tJKay9fxY#&!Qiilmvct_ z`u;kMOu`*r8@g7Lli;zrv~%ZV(sAqNT0M2?hLgaLiQuj7M!AJaQLP5HsEOhV{gv3n zv>~l!0aydt&tSLZ`eaY08sNaCbr*2QSxBZjww8=ASw$vOB6{A(yyz9j3?S8N*fotY zb3kX5WfW;G zL{|7@U&>O#`W33()0aLasTZY+=p74MNuf5AQjoczlEKqpL5bd<%)IE;Phd55-TOVDUsTSzv3oB_m#z~hOqM)>Oi}6bimwUw~5HS`S z;IOb&Z=yGGrT!+&i3O={^C2t_=DgnA>hvo-tl|cHR!Clatu?rPZ9*R=YM+2~-)LP~ z3b$)~*rs`>5Ry~gA3v+dF{!T^!?O~}-PK-5Si0{)#lBg0IAit|ykALotBiX73W zcpo8+aN||4y|n-QM?U!4-qd07Qn){8VVmmn2o&{6RgAkb#KK!h)DYCLjadr;JAkFe zsejRjLM15C`tG*M)@KkA=nvfiMZI9Zzrig< zi9*3Wj6$P#nCL7y#TI{O=%VKWjt5=WZm7zTQtPxV!_qBHR2C3}Lr8!06h+OLGv?n8TMeS+!N}R6jQ;1|V z^}^N|4{;&{daE~yV#ieis;;pkr<~RRPlqqcZ4HaNWTmw2l}y$Y&+x_a2%bVfEO&fS z^Zi=r9l9uQuk}n3#mcq}OdIGzr;tffoXugrQ4s-hfxGzGeHU~Useo)S(GwxVS3WZ9 z0?Anw>C${gM8*#X4R%Rh1l|vBpxE}h#x&9tug@IP3Ss_)DTlAV$XNXuL@7#HZ5s+z z58k*ryeXWO2VE^!V=o0E+12Q+pypN)zm4$?kU#pQjihsMx>b+ff!8_4b5&^6nQ`j9}27x zx>r55_X_hfv%$rUiMbwp%#1O?jzO-E^Q26w;zI3#6q5^Q9}%oMVgGlfiD=yC@94cB z<{D<)QCZX}DLL|-WJ~6b+Z5*{S~TI1M_L@F+gkKgb3{`NYs3CV(!HeDp@ndfaQY&d zL#&PkaCyBZW$0gMer+|d2Ah{RbNyH>Kr-)2{t&|TskooQUEW(Po&4Tnrty)rVtp7{ zCg{PUFbM@T81)$qu2FR5(2d_lupu$apM4ZJtaM1ljE?UD{5%uhd{J=>oq|^W$RL4y zrenU-JAgeSX885OMf{H5eBk~p;nj?Z&;X`pvwwOnb+ZsOSBQnuKiSwgy1T@%cQac! zCs1?rt2sviPs{EN1&@2+(<@amL502|Ik{@I;4ybOWs3<(d%4RNmxETeg{B+i#eoy< zNfM_7i$tcIUUwmey!4*BDHvPd|9Ic}MuWHjhek%7Ua8kLc^cRrBT{nUnf`s*a6^^h zN4p=?R1b`Fn=fUa$Vd3`8V6KsGYt<(2fz4(BSOa$+>(Mrq~6|>7+hYcCQlK8XK~ya?GYE1 zt%xb7VkVNC#+4<)Sg#T13A@py=PF9 z+uJt^D55B00TF4s6;TinkzQ@El`e!{MS2rL4in_Y$$oqD@AcU07`Opb(%%C4S+u zhNp%*Nb6pHyMvMDbzyVs*9gS^q?$$(oT@K~YaHEoA;?EQ?A%%tA>zgrg3hzMRT-TX zi)LIj1gze*0h*G2pmSo=M=N>xO`|R=2#(Xw?D7N0uwx(^fF9t6Ilms2X+n)s~ zNLpOWfN8S2t6;-+KG!{7+gtaYPaC2BFnc{AHH2_;hUbG-_wf{mnu0OL5C5X+y7y6# zzk6I2Uk(O?E|>NnW)k_+Js(5iYX=Q)~>S3 zI3|Qa3mopSG3yv>Nx5T-7XE(MOUN~0pTdQdORqlBvG$_L_cZ(Yv3b-uH+TjoR#z9= z5r^u%-qq1ca?N~f*~lny()Ak0IG}HeJEt?Z9slCRy%zL9%7cTf#~OAdrmH33m}E{~ zhP`X0Fz2%5i`=6t+y!Doby7}0AFPBL3TVq_X$^>?MsTV>a($4R^~d+bUpJMOFwFsM zp=^b)$I`FwbwrF_63T}byq>D-^pB%-{m}ws0q2gILW94iGUru~<|FouPESj8k5KiC zTrLFSzMZyMn8Id=J8v*~s->T?scs3rqgL!DPqzImI*7eW=ODF7Q2XNF;6h2wml(qVpzD%WBi7y1!th7`vs(uk zK>HDnu0UvzQ!%aRji4{Bg8UXUV<0sCJ!46BJI|2@YYOjYIs_#G9f0sxg@Qh}Dos>Z-b<-?sB?G5QnAwNx=f)g+cI#tB>w$IyQf1*=gVPu31#gacP6OYL zXywiYeV^}Q3)*2OxD*n$o#U@Dd58f>j8jamzSZ# zklecKFxYO3lQm_-1kZff+E3QbEuMrBH?yo{^$G&ec4qxI+Osx;R%Sz4CG%k>?r@VP z89s|G6vcw<2(yWK!hFpv1sqn<-n#g)Wo{jY1e^D>#6wEQI)UZ|NW{@ap5rMpf2%eH zwSI*)F2JAYcNjzBW~nJ>`{t(12#9kW?%2arBOCP)&+88^En!~452MFqD#A+4p1{(5 zFO}lm&(%O1??>+VKk1Ri+FSgcv#>563ci`d(dS=yiWXe8zQBKb-PB+e5N8N)MLY}M zg6e#w04VcEbOT>4@sgH(sg9BGPkepQvU$7KaS*XSmkNx&E@Z#^v*I!B z`^Y|-cewr5N@H4ZRlq(93hF^+5hOh7>Mj75cE*AFnT58;%o#<&p@bJWX&=T+|v9 zYt#*`^^;)#g1Z0b;-l4qnf%R#tnuH_yRr0g3>|Vd9@7V%Lo%B)5AJmE6O~z@N)aa`N;L^M{L&DJe|&3 zR36CX6;=23JDfra_ed<%G0+Uy{2*wM#hu(2gO2>5U=fB40p z(w<^LH?DE!)s^Mk5godF=AusALTW!4t^nBY{d>yGL(9$7EY<~aY89dl-#tIqm=?do zm)w8WT=wjOa-b4wy+#_iZfF7C6*Ap2`GG+*jEJ$tjM_zwpqBh$>*2uc^^@x2PYRE*K4^eKWxl~G+AaETS~}2YWD(C5F7y^ zl!paxtxm;>jf#Pb6ATDq*fTvYP&3KD^PokF>C|bX#>{{W5+<>WU!0Ap1)(xhzN9U~ zc3{dbA(d@UI8K-?MsYm<>p9O+e(wwBiz56LxgO~y&!qb{K3^R`%*)9YxqmP6QE6~9 zA_N3e7qiDS5|&Z;6l#ooHxxXuZ{yTAQY`T-wbND}+%>AS_La_D5oFt91^{{Dr<+X;FjEEpf`V7y<3wnY9}ScWJUlG-tEn#>O(HQD`)KV15ORwj?KC zaJu>~T%5gX#61FB#~D+SvcB6OoGW#6yr$GiVXTz#!g-0H=61^bb{J?<^$FaoC%aq` zwmV}Qc{%Gs6pnFcqiM7OcDrER&rpxU9Td4c^=X>9ZN|A%Nlns@Cpg?XKfBx}e?N ziR(^3Hk=|A8U!Lq&GtPc5)kLPRmdTa4l;gU+|;T^JTc6MR#;yNSCP{0m*aa}YOQcm zo0Jy`F9BJW-SN<1Gr8t_Im-MiAYEBN4uTZntsv<=e2QVb2fP4p+i`7LcGunfk_?(Z z1xfZohAr57isi#?D7VaWi%#qtPgUETaOy!mzYX=s=bE{A=@8&cms7|m%!fTmTgHaa zC=9ogT>_1bYuU+b?ng|x(O{)@ULm{_1HTUy4IB&i!^+%Pj(rpw6Z>q$?W)UidDbS> zjKIg4&21Hw@W{vPCa8p_Uov9*qbjRshHA(AeGqCggGk2u**M6Qko8j8tcm8mC|Sqv z=M&x&Jq;XuFiNyx6WM!X@|XnLn_D^b_v}^WHco)fC0;$HwiO0K1&>u0t``qO@^kiJ zLRw_^T;+=Sun?q9UvsmsW%Ov?5QOR&uF?3jm(**J$ej( z8j5yfcVQZG3P{=YL{r5d!bedqY{)Rgb+wZbkahDMf6kuqp$tU>D&h0o3l2)`rf7B; zW^eDNswZ{G*rWx;p#U<5!)p}Qjh`00w{pt0{;NcH%`PO{eZ^55M1nIO&6Z7W3}T9f z%oHW>-~GEtPBnS<9o${E%o`Y$k}eus#)K-P9ftb>0@vmbw*@URXWB6H6E`=8%x%E` zH))Hafrhy(dknGFGcjsQ)Ymxr(b1ydz1Ap44N=`d=--A>Y3bDLr|1d z)jAEr1(~T%Y&9UG9@I5aS7d#RbKDo>;c<$c19V@h_ijcTH5c~|st4r+m5pR61m%oP z#Bk@{;-6elEAL6`E~LIwz!z>xrN_<|kOqj?b1HzrcfCi~u-U!&XKzGcgkMGzoAE~< zn9Rb`k1rj>7kI1&d7RE z&d;*Vk>9FYR>zG%KJBoFeYR{!FEdI_e4ZEH5+n>EE2-0t-Qw@2HHI&HMEH>w;5>@& zsBLqhE#F;0l2YgJO$%|z-GwHup2AnwTJkM&syg}G$AMawDBOeJ6QkF;liFTZX9}z( z_!8n6$JZR$$mkhk$Ww)x0ur-W6>T?rSJ7X4tgb!v`|z+FH0o3T?)?-aAy1TUdoZH| zB_23|O}=;Ii)CeQsAU1w98*I=A(*gUp@X?D-HF#We?$+oDKamYA`Ft2q-K8(k@|_} znH{euDWv)ngBZW^i5Jzz3hy2r{h6%2F0%fj3)$706d0cMs6mD0O? z6oyB9`ECCEbibV&^zwZmd&vu?GcFPQ{tXT`W0bic&uDM*r13>;MpWILglb{khnVa0 zIP*#eX&T4%bq}AQ2bP7VA7rSP425ywX6(qGza##ZqWyJ?5Lkv#0op3q2Q^7n5Dj{~ zrZJ-KC`?@#q33JnH&-2}DOd_64c82UfA=fHW4iO;^tSjjmFL~;Mc>xW8~|vtR~-;b zyD7%Lp>Rx0Tj{(BTil$xWRy<(yIvEF8%B|f`=i->oeMdjc%9=x(B3_)tiaVCmi4O^*OSJV}=XOH{_cLs{&c6(9cM&`gzn?oB)udw&sT#43 z2x<d)U}=3ruFO@8`)CSM_Eyn34bWyN_7#C!VSwY`u-Z#~YZs|5WAW zo;BsK`X9PS|6>p5|I-IP+K<+A8$g7J4IsTzyaJjMf114=)cCd?G);F}+L>`Bw~gU4 z!}PLxh~Y{e-w$0+XGqJRiA$QFN z&im!1kb~vD>Sa~WU~&U~aR^+Krdino*ui%JD++zg*MksIcy3Ac6^w*;R+{fQ>VV(N2oo1Gw%VsWBf(jP5g z%+s~U#)C%D^P@oHbKZuO*<->9WDJCgY{&^o_vgIDvuRg*|81#lo&2nQYn%cpB;Drl zsF%GuJt=H%H6#*B%>JG#eBMLx{_=h;*7E9S3HOP!g!iToeGQkC zVVza;Gm6Hz&6C5<47s$ly5Q#gsW3%-(5rgz@mCs;E+ZIkWVm|A zKeYbbS`V5JC=ihk*Uu|0)$r(foj%gBua$H9Mc3nJ;=vH>W+qd!KqsN5Jwpwa)GQ+< zg^%wL0Xf!qOl*O}oT&Z$YMpR2`aJk^Nxj!a#SRN>>Owt4G4$(-`55upTeo4b%l+9Q zNS2aozE#7jtI$n^r>*lHSD7mywWTW>Z_3RA-R}P!VpJuN9KP00;nbESqI|iwr_WoL z6IqksB)#kRlt|D9*p91TomYxb*Q^g$*P~lPJ3%Am{stntNv$Nsu@eox9-z*8+}_^t zfWKZOgVNTXQ>5!P(HwoN+!BgA@-W$`C4v&NkHN1kYt_`{V7}kJF1&`P&<31?VXG(2 z9I-R=C=c4x&qZ+I?14YZHvf>zw9Qf*_9Vx=P*T}-89lnOG)8!N%5L||9UYzIG>3}* zYS`Qe8r)f%Y2r-MbU<|eER{F76VUUx-Sak^#=V~xAo2MgE=3lep3!6|ZA0$X7nh=Y ztJd=9lB-3nS{FhBo6?#e#9Yj%9J+r$PG8w|==~wJqR_U{`X2CvF;!Pfs$T2j#c#pB zo^n?Z-g$P1^c zer>7VaoE-wNW4ULvtxG?9lSo?zUvZw-%tO?yL;eOQgb>`<(8(x7^KgVd0{@?P1G0r z@)jO`;w7kLN7DAJZ*-(A%1uMgFAsr-%C**8C1wkiniYsz^k|JpujPbRi?JLm$+MF# z0CO8}T3MdmDV)fdrfMuFm>fKoMH;ryR00}r$6zeDgE3_4=7sYh7j25lhk2$o8xqil4+CCt*s2wQB`Pa7-=&$WYt0UE8}q*p zXZHkl6v)yFEw9*q%YY7Y-*6s8SwBQtM_vtv0!8yL^iF)v)B~@yo#W2Me0M<)xX(j3 zf!+YM-C|1E@d5aPn!$YLjNG&trEI|y6pGNQu}-gL-WoLtW#1@p>)uX6!XKCimD~G` z2fyGE1=TG=gO{3?Z-EznSO^ItO4+O*RzDhOl$$1h;Fyor6_ZcU0FR3mz#TxN;T^Oc zE#_kEY;RvXBj{7C+sO6_!}M8{>EFU-syKyD2n1AN)+s@RJ3PtWkHYK?B?wrlzJla&Qid-prEp#tPBia9%z> zsg=W`#8v^=)jj*(5iUD`v7{|1=79tFlzM1tRu3+HPb4qpjo>=DA_x{u2Sj6IM?S}=N@%^b#Ddbg9FBBQ09&ODaaB>U<*3uqF5qZoluX-p45JRevQdl}=FL6di$ie0|%UWAzl*Gh-))g=KB;PEpa zv5gDdJ|RmQ$0~jA^j)$s5SkDz0PZ@dr$#j5wCh-E#qq_ufqvSTGQ2gW$J$%oM&U|Bq-2#taQB?~m>6)ckL0*F%ocM`P1cKQytxOD zdkfjDI#X_)8tTk9DW_SZHnff2u9?btrV(mrO z{=_SlLG5;~w3p9Q<+{+5#+Tp4a z?1}WCn*1#+JOPv*eq4{E!uY|$=(1k_#^=_QbLI$c&;p$CWW?lxE$H%sh;Gh|T6|_T z{h}}^s0tnuO}q=?Mt|R*t@i`5X(HzAmnr#h8MeXWkbi;?cyOBi&&o-865|!%L)m(P zJf2=taRZsl&@BbJH8eHIp*X4Yc4%iC$DEpxy33x~EpTai*Z?kroJto}@q&kleaNe^ zb}0#Zg3GDh_Ts@_bM&xYNde22*j7E9=p*n=qO`NeK zcP{XW2!~#_)C<@48;-2K=BVm)51N{pX)Cq`Dj)Tjm9-R*ca8WFsMdz4_4Q#RuL7_j zSlHrhl=-BHw{-upXB_U!OA!EQVVvyt3>{G zX5S!(XnN8gNT{aZgKu1`y6$h+L5>xxkk%g@x#?6TYF#47o$sJbfBU%ofF_?w;PP?K zUnojd^EZX^jlRiy#>{Vq->ZDD95C?-$PzrIUPY5mTpDr2`HhQJmU)sUPon8VP3x)6 z!irJdr&MSGY-4J!&<3tv02OeZE*}d8BW6>8*K@83iZ?M;cXo0q@(!t7d3T zmUmyp#(Py%*Nw*5oCu|&6!2fN%@n(6a{;)%A)j`;m<_gNe9caxJC#ceg0NdM0s}3I zZDVFvVJ=6Ew7?4ok-a;wi^t`a$XeLBAz)|(Wq6VIoX((yXk)TL-^!*_x~#mHi$%Yl zT;xHr+49dnqZzgTlY(z*{&$W#25bu;|H0W>-<=*mCb38hUHAf*8&=s47UTT7;N?Zo z8_d2M^VXHt4tl{WqT5c!ArZHY#O^D4t2jn`_`L*tMdNs_lyC#D&dv{oMUJh3HV?~= zgnW#;RxMUVlMFfB_vi-l`czM$772e6NJ315j+VztnqNYG;JrVXiW_y4jS{`;ULvUc zpalBLDeTJ=9h(P7v}>AYIp%KZ;WLbyXfL2&vmf;=1?8Afd<_SWTVZ0c;rDQh+%`c% zWo+gYB<(L*`|1{RLy~++>?~-x@sn7A=RJ+Y_ZFV;u_o^?t~Lt7hjOudta--=PD&$= zS?75_&^*rlVzF+JYKgqHv95gYW;xGSOiov_Cr~37%^n0BtiO)M(yrVB-^JESmPI>z z`PC7sKlhF{a-9kfbU-(k*gXmD7%7qq|NG~*UUN-S*Ej|wbd>Y+_@_M-c{pT~`DJ1l z@-9|f@|C&T@nu`jp#00WXKDoQkcJj`vNF`pyW0Qi<@S-9@-KkOXR|ghA9I$pSguB) z#~?!vjrOG$%ubl+s=AOWgfO@az*?y@srEhTm`koNImQwb`C!1km&l(qcOD!mfYZ8D zeUcEt!>3i5s(>ynksoF0%m?U4*Ze^gC!u7io4#b$I^C08zfEd+mUi|S^)=AX8wTtN z0bc|`xGAFMwPg?13uKvtlMf$_TgEglLw6iDQ$3@Pjy6TioB%HkOpLIZw{LGrz!Y_t zB1b05A6Pvs?k+I4AfMl0`wSF_r6`SW#^P2fj?H=#%(2b5rQrhI&bp0P*U?$M$|8D1 zQ&3<;L(1$`0c|}K|1=f)QV#<>Pa8aSZfNPoTD!pp#G>6ozefsHML5<6`6=eSb57_U zgxC+mHd|e*QqrLs_$W70Z&LNhYLAGfTPSn!mT7ktDJcn8;a@#pRv0tC>Ce&rvlqYZ z`@;Pml(a~J9lje>Dyk2(bc)UUPGk`*$%UIlw${7+<#4F{2c7DAe*ITXJH`qh9vLz~ z+WF+Pv|#{ezdR2#X#4`l#i6bozKgD$MTFJ78@p+9VGhLq;rhnlU?L)1M)1o0+Hc>! zdG*>CqX+#O>gfAfn2Y}HHyt_qHz?fzw8cUMY-~3l{{JGxe@H|BebUtbqWS^l30lTs zS&S`^+`kRaF4jvCB8LTP;_-)UdFyeEPW|+|_aslC(6&9r7#tuBtaG-9&75`~*Eyh5F5J5uFvW!cScH@T(-s$mKw(u)IwX z`QS&xjpSsnD?LZg1{hGvql;|tGTqB8AuTHTx7Iip{63taS?NKzR_Qx{jCU;paM);ER9GclT!?`64z?vzDLTUDmH#0(A0Qm|r zn6M8d%9?Z!kwO>W)5f-!QR2$ zLptpnP=V+j@G#g0jp)$_Fo9QplvrZKF)%aJIJ8K=3*{|}V6X6GQvw3-8!whm%+RJzRB*S(hl2|Z1 zfZD~|SI%Clt8=~=UwC9|K1DX|pu^yPc4by^cj@XX6yc|}^Awgz)nv+omp`Gl2dY^1RRR|(wkU!QdL%SdS2dds*w z(_aIt`4Pk*dZ%M#ka#y6s$+ZhC!Y997dmoNnC4BQ1!eK;;kF6I&eprQZ3lv!+XSOdWSKU>>N)fAk7nmSzebK!i~CTQ zR1`4Nol52|6u2}#l<$mIh$!__DH$15J|zey2UuHc3EA4moe-|KkNK(QwY-K12aT1h zz(>Y}icc$_K4(Ddd z_=w51x*YC5nfIz521rh~ z^e>1wJZnbJMup9H`8kWqWRzI5Ua?x-pJ|aN{G9kO7bEVwR8_~qw=&UA0K70$$!ZB{ zAL^v60~-}NS-ho=ugCIa<@muA`6~+ueGHI^X~&6#3{&>@x<9|J%J~ZZ)l8v86j#iR zceKRyvyGT@a)~|40W;ZCGOCF(xkvHjq4Eqs$P__s z9+Kf#mFJ!&z|^ypL_Gm7~LpTn(nV{}iwGzkq;;Fv2+ z%J9b4H0|AAZV|LO!qc$h60@$m6jszy8wx0kfTD1s(w={wJ3X4TQ8xhPuD-Q;K^X6U z-XP6?MiU=r`k<_*qGoCCo0|`Rr1QGTe$+JI%ba7C%pykdJ<;I0x)|t_mITQ=Qw-Ak?+H=jg9Wl?c)PR5KxO<4=5Xi5pDjA7CC92T!)D1j8r-9 zDKm}H^z92L6Eh(0#^L3~`L;;{l-l5^0>jSRVVtbe%<8-`fqTZTWDzR^;Upl~R+Cz4zlodJYU^OiUIjNZ(%iVyS$`nrR&XPJ-%Tvi`fzpTQx*-+ncQNt9>iVe{j9eA zv13!yVp;R2Ujym0Af)*b@7|3PN^}TpaP3T}J_>p>l|BGSJawhdmHQ$#j(nQ5v}=HW z=AV>&X)gA2YZ`6AhH;;#rBW8sgb&5vVy|jCcRNrRq)F(xsbk(rD|Xl!H2(tWs78M! zQx#Nm0Fi%F`M89U-vii`&8rghleT%c+YZ_#_|To`{Xx(L1Ei;K%n)s=uoUhA+_FjQ zKN)~m^}+Mr1TXy@6DQin?R9=5StV?PQL1kWJ@ABKrqib}02_9RRwCI0w_7Q$1Gu5s zFJ0L+{hLpg0_vV$IjQ|Z^i4?<7I<~_^J9;|Tg`b1>&Z&q}=1H^I{katFnyuK; zd38X@8HbQGs&gK^=eA;`@E?L`K(P?3j+X4E)yAgIXfED${CxI#?%e1>B`e-j8_AT9 zYF!dT%yZdGm6D&Lk>Z=90e=OuA@BHbwmqvZ?YERxe$*1KcVW<+6B0pxQF^N|ID56l zOD~MQO0qJV#5cPz&e;xV^@wy}V=@5pIN&RFd2NP~J@qw)()Z223GYPgEq$)il%%US z*OEEn*ZMhMr@chg#yPO3b(9rU%*FgRuLJ*H$SUg%?#*?QBXPasttRQd{94%o|4A?l zYOtycPT&dU-&;ump?>xo-z;rBN!n*%a{A;{9YtB=E12G=&;gi$&M+z7`0H+sKWWT&ri`cnsgND=d{nIl>l0w-L za5KuJ&X}%GPlu4-xauwm2v1!Lo>}lI0w8Zn3O!9;HsW3aInE@vG_nU?kCnqpS(6lf zhl#NSZX^DsaIh!=3XXOLMo5C}rQ1VG&fPmh_Qiloo|djb9r9T)va_$yEV90$8D&0H z0nioz3`hbwDI?9@*F29X*4Ge^oMK?yNBGqz!lp_Q$FvcYnCp%)e%wKzXUND}V6-PE zdDRfO9XTJJDi+1qHykv&6JSJ`YRP!pHqYGq$&?aeo{FRwL}Ce^hd*=nGJLeW$Oy<% zmfl0(6mm^yJtm`{yQG3g^pJoN7I{jb%$DaaX9WkL$lCqFQ=HsXG%@r>>|v#0 zl>%qUH_>LV5!3EV_$krSyOu;#@sNCSdhsmc4tWbFMBc75{-_37I<~8EkkI|9K(m*7 zAY(xfDSOIH1f@ASCR^ihCy-HBujf$7?a577iVFMaZbp6}?<9}Rv z_-BiUsQ*mkoKw5$`bP_3``=0C{4dHM|F0+U-*Vuk-1;E$J%Ez?)CZG=X(#O(M%}|H z3qEh^L%@+5Q4-?fUs%%0p_8wcB4o&=jyrsn!PCAuq;1xd)7JYl&kM0K8z9hx2P;_d zyf`HzTLaSe@?xfXhnRqw?T3$Iz_+@Xv@XKo^g^lN)Wy-A)ajSNTxEx*AwXJ93rq3^X^2dbls z?tMGKihPKAjXy#MFAn(wzUDZS`w1FmoYIkpdZZA8SG6}cFTV}wR5xN>hu!As;C&jgmyl(fcmPURIAwnQx>ynXvXwYHI}V7x@zN)?kS*1Mg?bC+)1mqg z)GNJ9hgtrNA;7toCL`gVCZ2^banIg1x>De zn(dP(?#zsi$`u*qXdY-UDk@4fDQMr$v0+%WtmYSz4Em}*JSN-Sz59l&39l8q>i(1! z(?~PF(jJJS@3=$q+3G`3_Q5&!rKJam6~P0d>uvPr=PC{pO;7`1THPf20Lsp$I$&X+ zABBDGJp@E`xNQnElX0@HHmU2My zom9XZ0Zdj_puyC>0=_g67b8cF?48VuvyZ0Q&)3_*p0M7XK;b_!<-S$+szg~^KdHRw z_UiLd$%P#?845zaYbiFYrBMfP2%7(@zi0sLefv5uN~KmtA|z7;wcsudBsBQ%;s?i% z4@^0(4PGHUzmYfmxkFm}&Gbm(Bu(zYq~rQLe%EV{liygHfTkQsZBs(H8Gu3Svs}BL z4kXzwN;SD@N)eEt$<5koBoy)TxaC^Y>+J!Qa;6X5XTi_gK1SgAt%i216p) zva&UMMTuX>jM}T7t(Hi45yY4yW%d@wE1T|pQ(a$8*>&DSNtvK^XA@b`4?e@^jI>_# ze0on~VBfF&1k1BGeQ}3DZeNCsvr-nD;eqOyWR_tK&SWyUQYCVBOPIhe@srC1IdPM< zEJKn!vI{6APKH{PreEO-mK@2~lj>E28hhW-xG`IEfC^fPm?tz7-j7j;tQAGO{ohgh z>H@HjUqNei{!`vo>*LzICog|BKg=an3NUA+=3vKgf%&QN)FdzJkbS3g8pM=+gVsg& zp}(B0clz{Uamz{YEI9zUY9LbOT~`0e>=N?%uo0+-`zN;h2LB-E@#k5)0XIV%HboVB zFto7FltXAGF(s-NExdI#WRy};2ap$fxHHy3F;_&c)r)fFqq+Em|IffqO?x&5kYv%< zncd%DcqSmY+nug@QX4U4ab^*4K|MAeX1qx8c~4d{vl6jI0c>lv6%4XO=x&TrF>?tQ z)2w`N*nl)4BzVZU6Of@<--jgtrh4sepOUw78Qj;;u5V+QC!WQSj==g81(^&yEXY1) z*qSBB5Hm{t!j#l@?ps`a$m;6HRu%LAbh+3oaP1b`iUQh*pjetX0k4)Y%ynwt4R6^f zdvrCf%sS-Oxc}M0Stp z^hNJSqeQER%s7?u4f2ZMKG^4f)iwoo|0T4tfC1IQ75VHfccz3UQfCm=BY5}_DC7BL z6T!>32HV&YWY4AnS$gw$eFX@xfHmpkvWwC5Q*_&liRMA;xb!Emq)E=+miOjPRq#@b z3kan3fYz1D=--0hDgMo;Es%2QJ*mNN$%5n|6J=i=5DT4~{Cr!! z3&<%HdHZ@BW8a(*<}Su+7KnrWB%{nr)N3LPI_vX3=?txg&*dibYoiE(8CjL^bYN!u za>>SIY;MQX(5VdCNDl82U(MB9Cu(bg8 zBG=-37%xi4p%+{oDA#dKEwbK?Z1olPZGhTVeUkgLU-NpO4$)T8t-{CZ=tPYbP(=Gh z_d!>|_`INjWG$w0e-I-^KushkPb zT`x8JmfAM;ou3V^9h){jT8lNZI}Ks8Gd+tmAjUwLWd2$NZT^*C0OAFDUB*TJlHg-- zFuvu+6M~{>nw3Q>9LSih3zW-ekm9i_y`p(?2Afg#7{Vp{Y6VxjV8bR6El2Gs(O%D% z3XPSIEunjR@k;2(P#_y{HA(8Y#gNo~JL8HGb7fiN==~eU8uwRr3U@bcoZVlx4opUR zCit&rG=Hhe0}y#6W8XRT6aj5N1YmZLy>Qq?UjtR^GD@jq!B>7LqPPMd*hR1*?XjL` zrZg-smxL^j%h3VSLY#@K0plXO#z;lLW;{|gsk}1G@t0k)8~D;)@Ji%-(kYVvbh%Uxo*e+T!mog~o`s320TLdCy!zS^6QKW4O4hfi6f{N_kC2 z>+j24iQ{H$cB*c5!`18Vou3ZrxypR-QEdFa1mqVk0W6mgdu~Baj|~bn&ENtygnlV8 zQZD(^hF8;3>K#_k3aJ#ltmcXE#mITO9G#E~Uq(`gzRyk8C-3VWzp)={ef?TU#Fq^q zx9F3!@hVU7K*9j9l?xu*U(FAsI9v_27}r{TN=q*)UO?Gmki*k+>ao`*#u1Rdc4yh=iwkhmerSvQB{(sL64>?AyDpQ8J*5d%Er4g^> zr^R<%m@@y?m0<4PKs|Cz8UmA%1!5+$@4Er8mV#kZJuBb7B=!@OAbHsy6PZKi)Bvm} zA#*Az#dpulR-F>RHiUoZi~NZZf9@)5fof>xP4>WmfDb&S$2q%z0U?VXHGbddLF$T& zx2p1v-dsomDYo-D_c!y>+7+9h1a7E)X2g*YL&Bt9Ii zXp&Sr{sNGu=Xd@YBh&v*A;wWs3TSF701&;OmYMUl$Z^|Y{IAL1L8&`R1C*FMjNenr zeT`%8CXj=gTQ2DJ>g$(Q)ZVN{SymReQ~)n*p<_hFa0UG_?2w2lugbx0Y0an;b}8`U zpq zwV0M7)8>s28V3?!5VC4~n>u5vm?!=$m?*1+!qDZ_&MS+>-~bUbobnJ?4e~+=jmlD> z=0b-d6Ai)DjC{&_9ej>BYo!rM_`TAE7{hf8jl}NVePen<>_&@aIPLU+l#E$BHMVJo z6*&^x@bx{&IlK^4c4q>i;Kxaj5DPCHi!WYLKMQ^=C>M};`}S?$fr<@r6W^tigcOxU zNd8AV@H5!ybF9PIdi|l9xh9^(+|5&QptHKKB_ly&Wgmwqwyclj7W_+DS^s7GKeUYF z|2VSvyKv;cgQF8}5Dm{Opi|~?NUyik^?0AK2g9H2}66Ag=2KQsxKRSu}OyjzN&70PB&){Tr?|cC-ZusM)fn6RTvv8pAv< z@4fXj)&JtR(xS$D7*=S$eX)$b3^34pZ2%{gBZP>c2_cJh1brYGP#?I&R|&Q0GMN~O zQ_290FvXB$^!Ohwpm($Fr+|K(;PJ%7!Le;waa)+L##WRHb>2@XXl2eh*w%oYHQT8t zU=uOQf~{tktcW|bax`E`b@hKY=qCDWeC-l()&!!|TluUg%2VZk^KBjj9bMH~x(2{{ z2@H2RAlRO9(bO#?bJU-iT5hLCFb@B({{LqCeb?EM$-0uz?D??K4oopOSLD#0v4Pg>U2{BNWjP!C}c>>K$~#?Cj8sdoS`W;3kb&}!WrhT_?a zfcvfEX9V=CrmcmvdjMj5Nbt(&HpR}XfhwZFO8#!Emvj9Sm7=u#aSR=uvFxU1>doKe zSiZioGIa8lPP^Hq;d?lrdg&6t0oO5%*a^cVF%nrNAMj0-QcWuj_4piqZ z;NNeU4pdoY003)_;DID;=g#va#h;?8S0jwG zUaPbM_C>3{RGPDm>fV~TPEi+DZoX2c&do@zW^j-XmSqok{w^nEteq3yz8RJOQMXa4 z64^TVc={@@2(`CDqO%eeu{044q%dySr7%^mFUMM^C3ap?!ayi@Ldi4yh(*ZjWSrJ7 z3OJAVi$Z>tIjQxfNy#(siqk!lPUb1otI+C;nOD2AO&x@RyR9*68Q#;*uU}0K+Z+`? zuK{(8CIfY^x0L`FMx>aYI9LAxtV8hnE~fSDSH)MY;lEOf86cy$cG=^8rhe(DNe7sq zXF%sHIo4Y(ZnD0$<6))1d4T!R0SSQA7N2o1pa3$0&P`lk^9IAMRz#Q9-u`IB+u z5u*__%I>cSd*j-sQ5P#h=Y&uKw!9<<$M4}fyotYfI+8Y{$8_Mo@D3-(qK9f?43>%Q zwAS{yTerP8QlM$8AZ)7c;4C@>=E`#Mg%&;OKu~gsgsV@0t-0-Lj_%Hpkdb11@3hti zA)Cr9WjJgbx!f6>v)l5nkh0$HJ^G<%+-x4$x^pVS@ryb0-}NB|a-&gszp(1151L%1DdxkP&bV#R@&BJlJyD9p$c$#@7L& z37xsQ*!M{W!oD%x6X`D1iS`BJ&OT8~(g0e6BS8sZEjarHq9fhLUQy%%i4hM0T)QZ zK-rE@IzsYA5OY!$o%sHt>uE=GcLetjMy%Ke@ZE;zH`@ms_)GYoTHiJU0z~F(;e>rW zr|u;nPMqlKLLh;nrTd_ZvJQ2HeRACXprax?di!JM(hIC@+^yI*;6ZAOfbMTUS4Qn? z@Z-(}+&eD^snpHuM7RjuXTO+wF|hN~a=lhPZ_6YQi|k~k@99J3Vj4xgPrgCvxLqr#KEROc z(o}u2$?PJdu_uIU(}6LT!!m(`xGw?W^*ErE=@Q^mgiSF87Q^hr*KNS6g$^0k24M%rRXJE4;JXLHvpwm0Gd7^!C$oYUv}+=Bn*vrwFHIl z&qMZS+c~8~vv2&EHW~?=N5eCY1Mvb`jO4r{&}87GWF{Vl0hjt!cNg}&NXD>*d-f$Y zuK~PK$$aeo9_C~wCHMNTw%ey^e_|k6rLyarcI^zd`Ug{ogt&_fdE5hY#5-yfLXCd_ zC3Br^@IA~c>{o3YlG&?twRFQTWLZE7cj5V(+mljV@vIyNR69rV1%KoJ#ol{IHPvaxYt_ZSS1rdMMJ=T9H)f^p@Ha+?Fu8Mb)Uqbug_ z0Qf|(QKR1L-Wh(92e?>v0$BjR2R^AX`42$+I^eE#E^+wtq1jdeKb>Rl zE^ZF{pz#@obpKR`Edm1eC}fTP%^1M+w+Dg`HxC2+O23sIv5$Fh;IxsDB{$QVT~vMX zFjjw@t?x(d)IAe=TOiWtKEfvA-8;e7t_P5fWR#Q}fbLYoQ%nJTxBQb!AAHxsf*qSy z`>7`q&V{cNxOkzqfZg3s{GMEpU3t0pqS{Zy!!8#Wnj3~=BVe;_;xH(zb+ie2^1Djg zVy(U#2navFA^hb^fS-rl7a%CztSQhVEQ)L4S`=(7tL<`%fv(H;kg z`4Fuy7Y*-KS#d&s@_Q(ImOzBx)qY+uADyC62dwER&t491!YGX{W>3pMl~@3|(O{Ls z;7@JN4N$YNwyc{f=fAyic0d+ooV<7ENdww+rlvuy(WVFhmT-OCE`j5h%@2UpvS%)} zumPNi&usBamUIhlP0_2Klg4vRiJovEJfpbNb}9S$5sR418F$?*Y@IVdy}xG`3|OBs z_c|m#ls;%T;VORlsOn|S*3{^tLd4Wz0vv!$rl!DsA!i=Q74LUtUki&$J4~LL%k4KS zxd-PL(iGFYSK@qcb-^;NNej1~{eb58tSxZ#v<-OTIJVxe3ZCUBNVZISQ2NVELjT14 zg85<~Zo7?f1-H2g)FW)17miek((-_tmD=yL-Dm1I0b)TJNof^7alsjXtR0SjtewKO zTBB?^3+%-CLgE#|&&0d30AT_gwuUuU)B;Za!Fm5ls^)P2Um?Khb(@SsVSL5!opo|_ z#O!x}V&w1v@_vBRP=R^lN)=z;{-APRo5K7Bxcnu`2zU^sCnptw1$a;kIWp2@xT^O< z)1-5k7e#PpHr8dGdf4oAa{O;s{eSO-{{KIDg8zB76NIs2#P-=Jjoq@HcA>qoy%vo_ zfD=du2rGXnMEj^-4-}G^JVWPuYd30QL+Zru--Wha`sd@j>c8&~y=3Zt4aEKxh=zM@ zd2syC_?L%U5vhjxImxPfJ<7AVaF~{%dL1%;&y*3VE<@-Pt00 zzlG*UV~i~&!QOL}cD)jVj?H`$(CJQNuTF^{oXHRJPFuJ6{`=kd+W$Skhag&?qOZKG z!o-vN#Z4U){l2S;{`xZXQPXV%y%1_;gIVJCKhg z1LQZPCgSKb53c@3XL9lYllySd03Y7P>`&>W*Tt=B*@rNc3VY7e@^QaEC#Uktd(;hgO2H$gb zyil$ND7jA6%?XH#-?Gc-g}-v<#K!=(jw@&*GJUDR>VGnfs!zr#n4hPs$}VNq$~k6! z%Ds2AIAj1|cOH2nA<*2VnOC20xBYm=NLSfbg!lf6B&XDShVi~pExQK96T2SmQ=CY# zPb&H-(^`?#nqfg#`OK|q@3d-2zSXEDNP_TJ*J6gBCEx@6-gooQfo={pH$ehrAagn| zbVF5)`-87V{r8{xc*5OrclowIAu55KxG}5hz$T)ib2tRL+9CvX<ZU91&;mE59d#5en#CSPRIlEB->lifnEH&Vc<|>TleJmI2pQ1LFtd9E#+%VHP?av z-~Bk6ijMHVEe%vsK-6IKC(XJPUVl-&IW--)UkfmzC89i1a_gtuo$r7$Q^B}cqA8T) ze2Ru2`7ml-KZ z||AA*MP`f7JCW`P>8KYTnKcQ48fdd~WFHh<~PZ;qr$j_8b(~16y*&+Qx&n zy_|$p75Ezfg(P}B02O_D4DmQLx=*-?K6z6Xyy4DwiR2dyq8a(E_sjgEcUB#(BS3Ke zW8GOzz3S2f8&Z!C9{^Ih3Y;rnj%n&hhi)QteaQ|&=f(=(2zDs?8kP?a#6bScAMf$E zV)OjF2TcE||7}m5l>>A=8F9H&jeLM;fr>Ieqtl6ge#@BdMl0yAD>qNb*fO+=UwW?W zbkwI;nKU0An-}YKn=m~&ory)KV$oQ%Cw|Ll16;!TCBM$lFPo#_qe{joXCITVpPygE z=TAl`(kCO`_{yZKp-FcY=$+}Eb>l_;<9eR%xMoSkGGoUxQ>ojI%TRlwhV|eh@3A`t z7oD=9@Sa>9adCK-$~PJN?`;+rZFY7`t;q)IUbnDUvx3_$NEktrU8oUuLpRUsylsqL z=zS1Xl)Q+XyVh~1JzD@t#8HKYJtxeVdRPh5RigZ|2NQJ)@}#eM`ZkSE?Ux&dNF(^g z7NaLS6->R#!pb32fghykBa7;7m6oSbir(uox$H|dUD1Pcrt$BqGU+Pcm-A|Jz6Qa@ zcKJlJ_bE*Ye1Ouo$-fa$y7V5acOX0gF9}OPZGwwlyKs*fWg-#8E+wyaqAk2c*6liR zy*$4=MP4y*CnozMQ(vkLpZ_-pXt?c``mSS1|C7}n#mKF;XnfgcvcT2gywWe#crWuN z3D$MmwvQ0wVpuhnsz)SM3oP1ks_z)Ct;QV)&K|S~?OAxw(U)9EfR2Aw=p7Ih|0F>G z+E&X460mrr8XPGwBv6g*wd$GUQ0-%e7uy%|RmsN6DPWm8Dv3B(Ss-e&D-sRe@#CzN6B^o5m$T%$90ad?{40@Y zfFVw`XAG=XN;Rx~sU!5hthaXFso=NM*xs|zw^AH(5=>L0{M=fIRfSF*JbCEkm7#08 z$wrn?Bb7JL+HFUshtr_Vo2KcpTVJ_7Mj@&rstAuv&$aRuMN>3Hg0!8Gj0|qzSJ~rp zb!!~UpCV`aDPDpL=*BPbYU*D}r=y%@%h7?LtWr*WGe>KL8=)vJYfI1&(;t=i{e2Bl zP7RtnvFp}K^DZu+qF6sWS$^h?6HXtuyJFN)%6@Ysa=_oN^hODqvi-nv>(S}PrSDet zyEp^n*u~p(gRaYB_O;BWj3yuhsZOJiHJgrfp*kjhJoV%{3-V--S` zCpEdtu^au>+QyikhVrFU!|^Day`YD4=&A5w*Czsk{uGy7gW@G{0Fl_|EvdBhE~z=M zBFT~)c_UA-{}`BnVac%xT%YS$FWE1pYz+J*S@&IGhticDF%!H|-&DkoAy3-X?QO5Q}~d)6I)% zX(g`iF!64k;gMvK4v@LY4qFa;j=lp3wTTXlGqVlsQ<__-o0195cfxH{tKHkmqVqlA z1N)n-9d43!*&;tt@-9~y8p~X09=J&S*yEV3PL5g2n48Nc*;;mY|K-=~w;8nYi=&MR zYfMFCZk{X~zMCE`?il6^;934aJ!jEmOFtv;-FITzw2_-AouFuIzevV4QPQ0=6 zso4z{zKY}azE{cLeVUbcEJK_=vSL0aIh_&xT10i@i>9Nz9o0w-rnDSaHllqJvf*>09ZMfc zB7Nc$;vlWQ%c7k{H3Z6+@U|7N76onc#zrJg5A439{5qrhvobyW21l_#V zy#0B)9)V+CFv{e6+J~kvZ;rrhyC(JZ3D;(Sjk#y(X(PvDsmH0JGk2N6XpNm@J6JWT z{4EG}fNp2p%U|=@Hdxw0<<&?iet_d<}p8jLI-nP^J@q#>>^}=a)oee4BB`w_ZEDoY1<+|z)LGt zFnj#z1ki1n`*fR&Px?X@wVNo9&v5>3F}vfoC%Eu@&GWO8;I(Uey>SRiw(TAz*jTiD z$WI45pd>Jx;Fpr+k8~1WZ!I;>D|f=#Y>&jXz@{n~y3j=KtI|Lb)|rawR*wUbBECjZ zu%IY@Rh2iHSfT!_)IBKW_@lv=XyV|_8X6UAba337dv^{ViFu){94#zC%G{YfVCX^5 z1d}V?4&?=F?@2BMD5Ogru^$o0b=^dr*5nRZM-eT`){i(pdp(x^lI&pBzIh`wC2Nyg zDrBM(G6&I@y!u^%wk&!O-#}^Wd$J_MXL!Asq5tP`gKANv--lsoO2CpW(ZOiU@Aj(uCLj{U4I zbB)D;&N;7Abki0#EgMp&qjb&HQe85CMV+c$XW$@}#C5Kmb1*WCHp|&uCc8{UXXV_x z#(X9@3Vtyt<6N+ZoI0t&jD7wiu(|rnH`Dto1k9p{ewj4vdCF`vALh*tb9>~yCF!8K z8zvMeD7Sfl2Ule)W=|rjO6pV4r{tl=w$^+D9Gbb65ljb&oA$WxT9P zv&sn-`0oT3stEMn3Sz%!GsP+OVOE2uiWp;mcyBj7qB_*}wx~r=D*o*XW_U<_8%v~S5jqP&U#K| z1@G}Dv#OV%HXPhN5R!V|k+eaQMzKKHHl}c(&h0|z#jj0kkx{NqZ;1Gxi+GTdbMFgV z!PCih7uL0cY(oYKUf00XEe?zfhaLCD8-OwX2MfSD7WA|-HYO9UK%2{~1A59TFz`F^ ziG9Gqg=Pb2AWiNkD>_Pu#Uc8)wv@^jqMy**Ua+C>XT=!lC?BV$6S&(NvJ#4uCMOwU zxxq)OA9f{kOBqHh1?t7=mx#<#U+^^Ad_LN>_njN-RwMA%>RDJ8xLfF{rW)GHgj@knc9#)M|AY z#R*0FG`wtKcBmOTU()jqc-q7#Bk6>Y*NzSPPwn&&CF%P&rLHGc4CB7S!Qs+Z-Z3y? z73OiJNlGTi!P=!o ztyGFEo0&K{PZADag{I@kAzf@5Py4{6qTq%K?T^(c?s6mWiGS!@KaAWzlx>tyR^;h#NVa)>NbI=QFNx=bf}XG1*~yNnDc50-*0 z$j6RaN}V-+08j$Ti2)?2YS)+nyE9C72=~kN$k8KRHNiz1&-zc_zEdY$0)>e^=5*Cc z_192+eZ(2|rQDz#C2dx~AZny`a0k0W6{NZ=Ic}az%da}4>BCR7hd*f5WXVzH@HrNf zRe-ieT8a=(`wkn#QFblW+PGeyEZa9{&)LcBRxeFpy<_Rt$!h$HE$1>%f}kNfe6cKB zQ<`7Q6k2p$Z7gw%c{Q*n9r%y`^&#Pk{2_@8ZK5sDEzM*k2oKh^DO8iX)F!P zxubVv_M$}(C`!}z$+)Yl(-JY(*TRnw1#|DD(ov=}H~;Fo z#yQlmGixt_8A{(_pE=)KC!SGRWrC@C4pP~)HxHTf$rUwM(CpI&8GOV%cyzr~+oRZj z?gcC(qcj#Dl030BL?=gz!61k;twCr|nl*aWe`V2Mcshs4chAinFjPc`y{yO0+V0ak zZ^}GlLULD|hT+zWvIEMVTd&)uJ{T^D71m=@`6$&%a@_dAXA7?4X86FK2ugdMsu`(y z!Q&tXJTpYS1d*eF8m6X<-+jD;!c^X;pFjQMxUbjTb=>NgPDVXY&yC*Gl`{}%vy0ww z2MwM$8GehG4-v(T{eh@3JMu_F|Cb*i6}TwKnZRbGkr&GPk}dBG%-@RpQGwJG@9I+} zd{&;lF9D;x}qtv%S;*$o#|m=VR<{<0AC5ET6%e zXO)D?G9J{iu~jAUCKVg?`px0!-RTFwOWikoj*!~iJv85b z9l3=(!M@%mlc>r#2=$j6GU1%hYI?9ujb6L7*Xtnl<5v7jTaZMd8)N@T)|{O%jgnr; z>UKwZXZP0bP4gE4j|}E*y>E~OFD^0k2h!c&WH{ezVYBvRAcFGvD-M)U`phZbc0b73>)4GRCJ;YC=W7 z9&)NTpWfMeT{>@!AkL>L6g>7!z1jdLK>`H%GkW&jttH&m04QswG4K!T5GL( zVmx5u;+%Ff5Br@3fxT8M-7GQVHJ%8~EtU)VYN`55`^eCmU}GelV;=67_uim_$e9RI zu|iZI>%`a}zT6(ctK%{LPc)sP+UptuIXNY-hV~|%`6XO6mEzmIFepE@u^1g;DWsZ@2HW8FwoYpL*jALd_$>Ch3+;16 zlcQjZfbm3mh}Q$LOFDZ7mq?5THL*6qX{(yXx+XxyY+VQ0&@T@`29vxnRwu)S!j2U7 z9KwqL>(~4qM?bvR*+lJgPtYzvb2RIdVdK_vU(chwG+~8z0QbwCyQ3 zJpY@nS01hsP_DJ~?PUeM1Bz@@g++4b z9x8;!igtxF#vlz8<#Wt9g)_R|TOaet<* z^FFpsNl7^L!!-BclB zsb_aCz)<biz993PKf%3x$Y&0&AH*w-WDq>e#QBn z%*K{OI0psn$G$YySTxb|sSI|c_G?eg#M@zKt2qNEU}n@W3)sfRcndy5)ske{rMUVV z%rk@=(vn|3kirXqgCI=Rcw2Qdu|kSdc)#_bC z4uL&V5y#R$4}W%Rlf9F(sv?+PJ~Thm zN3C)F>8N>D@;+M*i#YF1rSVQ^`*ABZuIdsm01i@)m2tN=2p-qUBS_l%<-J;U0n|mA zFJn7tF$gGVH4h+86OpVBnR-6+Dem9@KtI2s<8~&b1yfuf-FRevd;f;jh*j)N(;4t{ z>3yWPlcnm=hfE)tq&_5dk44wS^`EnjBD3DdjtI>LAh)_H%KhF*k-cxdVI|DYgD6*C z!Jtpm8%eqQ*0`l&Ge9?XNung!@|c`(l}crue1^}zbyy;2{SX1OZ~L^2dL64C68*Hv zGY$O_8!1^H`3a-1RFl?nbbN78w;f@DEu|`HP1sKGx>Dobi~8kd3X9u zV2j+a*MWe%J(6~Lb?Gu6^W^lzAZ5V5m~m*dfN$Tm;S`RR&*ZB@EqaN1)F0c8Kzfd@*AAs9 z5MHmSUo)u)>`C_zek~$wE!Jg`|L(|qsQ{j8#XN3anA%5Atmz*r`T{Q^V?RnlaO56M z_MC5IX`VofLNmcpBLtunekpPmKq|isVcv2nC3$taw=8-UWP((1tF1C3U_qXEf)yJ; z6gFsoE)kptUbVPblRkfYc~=|uJQ}bWp5%}E!fd8;!_lBK;2{SNZYpNB+Y^H3TUQ zOn+0{dusIjdl*AtfTgt*Dj-P7R_xlm_Kik>??ce#OYa75L0e6C`njn*1XlT=4t6bE$j1Iv;(g~t@BtD%L-Je-x|12Mfn-j z>&3NfnaqmqBR$)>n~dBnvMgRhz0Bf&g8wl#*!NExY0Sss6v{5T;~>9eCSMYKj<>QH zS$hmWKl3r;w)a}w3u|jmoc|GL&u3k7JNrsfXMaFWoEp@ALcAf?yyk7vvJHRh?R=8% zn4eDJlropmN>v%WIe(|&@%*vVsrynw5Qf7DUs45 ztT zE;WT=xFi)8Y+GN*fYV(tWfHST`76QhU9nXa=V9j+KaCp1NiW2ddwi!wo|zzeT|$3V z=PPJ$)Sc(jeohI|?X!P&ZrqvwX5p#)%q8385x|YVK{qb=%>c&B(vrUM&XX=Kso z@3-7wX?yw7WHg>XR&~!xE9D_#V&@*#-(yQS+|t>gS~z5MhbnCS1HiC`Otb75c(w>$ zsurDjz_MLBA*FPE!tbVW29Kss#+*C5WZH^4f?O(I#M!*MNZApX)_Q`3n3@#b34D3~ z$b1dQj)e#{y)&YQaul{7@+&2o78HdiG|a;Ti)3d=O}ZNAL!UoNZ3XGM*8dZ#{6&;7()KCjyj^BAFz zTKd)@NJ{Ig^k#(#L+pRRaygXNuaVJ?)kV;k3C4i;Z-KTzn7KEfFr2VJ$v2oXh=;RM zq?gIw65;fed{(NVM%9CHX0RKP4!f)kdi6xC(riQ+l5b{~_VlId38m%I-gE(amZ2Jp zr%OFeF`b3J&)~1&Z(N326ie*mc6m6p?chFB3K%gf+AD%uQ{0r&pcZvjsYRyVe}Wi= z*PDH6-Mk0+*%`L{@;g}rQZnp)LmbiYIpTE|>p+iW+W>3o%yjkprsTJ*-}X=~0%|=f zV$w~kWC2OTjXAvPV#z9bqM`h3{QG@}zuh;HaAu#N=;2=sA=Ra?j(q?12F`LU^oOM zp&S4Hh%rJ{omobW{?|IfAPK(_d`aJAvq3kWk@L^4R|riK(KUJck$M|zw=WT7EutG1 z_s6K^)6_p?vfo!F{^wjn=f!NK4s@;`K2)UeSUKyT0WSBy7M?kHee3tf@>>3XJG%mB z)ZJ#DPGt4}xn1wal^^0iB>&ic=D$DF6}YT=@a8TYkjCLR>MqwJM45QMBumYV-qtEZhboR%YJ{G-}}Wq z9g_`!sr`Lfc-~=7BmNVx(0^b4-$x%x>A!%$RVxP~Nf)}LY+mmX<~T(T$eew;wAnbI z!Q2pXHcDYc2;xV0lYSvR?o6^<*)W64S4?o1SFXX{kd8M8^1&LiNiaT{$6c$+73(!XZY(N z1rAY$CT}J*7-P$gUINSYZpPoM7n@rirpwNOMGlo~ix%Em7NXZdi{1c6bH(sq!!|~L zd(Rlo z`uCPmz3VSs_$?RXZ|*-Ya za9{oUdtc3SycM|p#B&~*nIxd1d&B$Sv{I+~~xQSqPUo z0VkyT?e~$hiuvZM@lDmEN(Z#`0)0-SVU#kJ zl@XPc1fOti{fTwDAetHC)kWw7FI3v&!(t`P#IF-s=9yJCZy;~I?h|HGo# zXX9l_tQG`c;gxxuXa7no%T-JV=l9L2q z-Uy!(&8U>yLJ>}h>#Ea;h-$Us_AKhz+O^#%J*zC7#Djk}8zM4#)Y|uP%A8fc z8_zz~?MbpOKa0f~;E45*5{z3<0f1zVM$fhAs ze+^CQ^3mA(%(?v6wH-WycGse7^PAYH=oUWMf2}!;=iiisC4tEyRJj?8D~FAFrw z>p{Xjea81|2^%^f)XY32fySwR(aaO*^OSL(=b}Yix@C|@9GFw@;Y$1!B zP1BmCz)T zYz?<5@v1fUYHoJu_~tVwYA1TTFvw#q5v*+?RJF0$u~^Hhm5#P8Ks_OQ$U8nB;|O%P zKQJz zS}WaD*?a@t#@!7+AMIKA=7e&?mQYjhyAKZM#?(FW*o8*d={CO6jpfGejJ%eWRB%_* zROI<5gkwJn3O1JYM=ze&aW_#c-WU~nvq$#GW+9uBcjuL5Nw|Yum6rH)N!3CMC=l{j zj**4$jZ0$EPe@BzQa~$>k}iP~3@FW*@>CROsJ{0NA75a-D?(|Y&89bfjHugEU%Tmz zU2FHz&@VAnAAJI%tt5~ymcX}?JA$%l@_`5IO_2w7tyWS$+g{L7h84kQaMVrJCPVez zsV32=Zg8Qyd7RpMNI*8(3sKi11R|pNO)} z;0#&u5T1`FSo-c{+NR=m=Z(PYw5{>oYv`wri14%<-KGkmZIG?T_=eq)5}*;PhwNoG zJ1Tf%u-z81+Z)tWLMk&%;FhECG-OX{kZ>uZ#n z)%XK`tbXvw-k-P+oHd`m{BvyX|8<(hH;Ro{@W{PqGck%drJZp=>P`EUfe_A|IE}~n z6&#Y#E&|1R-Yh3aTRkZu^1?Seq9R8b27{TN`Yj5h(2nUB2<;H;nzsa)up(V7Q9Yqn zOB)}GudUc(D^Wz-CzwAMpm$E%N@F#;6`NXb6kssMU(n(mJrQ7kTHUW;sU?`(SXRml zY+1Xwy`tWd)oAjW4#+F`6BpoQ<~_IJB*Nz=eQqbJgu?)b*x4P_p02tO8nV&>jh}(4 z3+*GYFGUmDvuV=M26uYrh3niagQ_W`;h*p@KuH~P!zG3Cv^`giZrWn&1FXQaN&n>H zx5xPpZ`i z1;F&U*Skvj+~J?*WSa_Oj6mSIkEYE{UZ*!+5^h9Nw#=U8`2lplunZ6Au~CTRb*_s; z^x2~n$3|+d$LNwGL;r2pZ-qSJqjuGq$0bAFV@BRND2m`|lW|k)UZ+vRwVM4deY>CB z|6P|Jjb5dD7XGav3xyY)lD{CfD;t_$z4AJ@r6nj4^?Yg>M)G*bce9Xf|DYSyrNzs- zk7#~Vp>?|VAWq4?Bq{mTWNM$WWY}o8Ea$`9oUZ`=lq!}?Qxw9QrXQR(Y1;1ZjFLXy zU7#PZH(HWyxKAc$vmc0RI%Rl(L(GgE#z53)kQ} z0?_;O$I>Oxp@7_1CG zLbVtMjYMV!?HlAGJ#gDGN9KV;oL}qK!VxGp$MMJIdj(nWz!tB^Ab5YS=+DRj)eN#I zvr(MBrhyAnr!rUUdac_FZu15Ajm0qs^v99X@ zx5;+u>jgq;5Bb#RzPi|st%;o)Ioc18+%q5rS-fUS317+nK zY*n2_Gki>x zJnO5z_|a6G<%QT6#3hEp#7ZT~^9YEPjToJ;3bUHs>b+d?8;4SL=8CNlyj<-`h2z<( zk}>RgMES10MQDFRB;h+qCCL?ZL3>d65?=p@E-y#31FUE8vr0*0HJvXB;NM}hvKdHy z4+kwfn5=2!1=uP^!>o4s!LSfeDfrX{ut4Y6{>H%;=jyU0!QipIQk#6&8xL;$4+b~l zXmo;TY+vSqmz~aN%vKlY@+XuI8Qcc^= zU8=s8IcLX-S4G1Y-cQ$@-((@+VZ)bg`C;d)%WroJP045P&_uU}BZVqIU6i|^44k(e z?E+)cp|NVdajU*({C{qE00g&`c;IJ2CrrIh417lzWt94=U; zXGluv_M|opyq=cUO1W}j{)4Erymw#@-;@PT8@)TI2g(#2aRISEF&i4%#_2S1H_top z;0H$XpT4T+K=VA6#MiGuUwSuZur>LjX*oxQjKX@Umc6wx@8I$`nI4F-qlT zSrm*pCurOcrUGaOPU7@3!}86X>m2ffT{y`bf84A8#&imQb+EldFRW(e^rO^R{Z~wZ zq){X7`%IMyZ`1yK)_Bdq{;PJ^uFWB+dtavDx2sQO2*I1XYjs#bwi zsH>Rj2wUzph=7*Y>{KSt7xwJj&8vj|S9QYt)#8-p1x3Te^!3ytP%t;5V#kK8q zp$O~zzNR!AzB(yI?B;Fe@O2!C3{qKY!(Y2K6RKN=b?oJB=}Vdi-<@wY2_nTs-pkZG|+e1>s$k?p$`$7Zh1 zzX{xK7}7S*d?1?kIvUscGrl)gJJSWmC@auh1>e-T8aPML7>%e4o&!<|s!p{X+g`0J zF)leKINJtY4ElOUSUMV9JfH_Mz*=|4hZ-jf;#U2FnJ1?&2Swt>z03+L_L{3h5+?#k zLy$f)5K@-iK<%)JKOd`nKGFA4h!N0`WW3UXCFd}X-1q?izIlv1U5{79QLWrTJqnMM zuF2t#3Hd)nURe|}D-wP781}KkV1A$v_QBeR-|3LiV1f*eR12yqTTa^^oVy-Ns63-d z*wc4sr}Fc-q})eiK0N<-RUPN~hG|cxgrS8vl`NsGz$VIO$az57nCTES6^+7u^-`{= z%0g^oE#kpYj4(r!R|aDDSMG}72N~w9%N57)d6hTn4A<|?WphQ6tEjP48^b*OLe{2v zb(s!%(5qc1gC5$AW{O^H+MWC|t^@=Y<;-nFgZ+yzjg(Hq(elQr_@lxiMseC-_fb40 zV&KCUBL_|^tjPHClVC$%R^L!<=hSZM`S19klp$NVm(#HCwJIv$P- z)5kxp?ptxfKTDp-Kjj;oBUEB{+Rz!>adj>9tLk!4l|mnJ?JV$MCy82x>gz0<8ul~v z$1kJ4g|YJ9#LldM`x>(O`nMupztPO%;=cVVN0zY#xSL{$n0!&)A`MgIw$Ae3oT;Ug zlWbUw<$7O^t039sL>}6a6U%NqNX1;QUAm;Q7%3IXSNMl{ZV^22{Cb`zXjYWQi%Lex+{6H zozi*dsZ68{!Fh&#O8By`3dI^2ki=Z`wR`_>*hvZ(3^gRSJ0-dq5t{#Q*8d*@RvIDx2;$WH z5qHEkrSS`fIBO9r1`ddSS-O*C2SAI@_s9OtbD2r*lLF`b9ABF4nuTHw6$NX}h*Jr7 z8OD(Sa@Q|}(E}Wv;d}o=?hXOa2(Iz0{soW9%j2gYMH=H*rsBZhDrAntmQxN9Ka%bU8jQSO|8P)HShx!`x^N34we)wwwS&N93)sEV^ zl3TNxr6Q@&J>`y3AFU|Bid0R7k2Md@Ro!Y)91~IAo0m;kmui%)ogA!BSeL9jul#89 z1*#3>P)*Z5izjZ~#=;Mzu@MC}Ss9*#N3D9+-wSNrJ|%@x?k1OK?WoW9AEg~{Gt8C| zRi23!Q{5E@^2P2V+!9)1)xbYEL=zna1pO9&l`d+JU`r{RriuINxRX!qegFi61R#($ zJH3OcYU0-nKc2Gvhj9*l1%ONx0hv6fb00_0c8T^Q-By25d)XEF-|&0=uut4Ue1ORdo^waC=~T z43)@&6rz)*X33ch%CB^;7#I^BxWbn&7IhIeR&=OEVZAM$SXJyMAeJalMuhuhb zU6v}NA?--B5tGW`Odu%?Y9H!rL&mehL!DE zq>3|z7)I* z!j|LWk=K6vuNftUG3WIOB0zB>ZO;&p*bh^X(sYQjaI`kpn^D)3d>$$nC*t+L)~x1R z%*D2-NlOu`)6>{2JnV_2QYi+Y!v=1`H)BI#Oa$#beczTA$E$Hc{9t86v5+q+0g@3s z{-Gl`D%&I|v)bSxl)`3b9%##hdEPf@8g1=SwC=_2Py9G{di{1SD{@JJkRN{bylQVg zSoBu{H0{mpd9)*mtvEYdxJ7|&T}w4jKn7p*ilRg+s`us}zeFeaLGjnxLCfe!aZLMM zT5&R^j$g&?`r1Y=U$|%V2H1D|<`l}q?W&3y6cwXw;TXb<;*Bz{Uqqf*DyQz-f~8Ap z36!F)8bL$w`lo&|>Bian8+CrFzAfW0`^>U^bSxS}wqSq7ymixUqW=1E6djR;`u69h z%KYmcfbK~eg|sA%TlQ^R#9@RJZEdsoj`sJ&o;w$E)!2IiU)alv0iw;nU3?NwJ4206mU#n-$tLHW@rbD=A(>>p;-VDy$_c(>r zXphA$*?o@edT=s~mU1L^YwwhFABp3jt#b*rfTECpE-2j5;CP7mJgQHI7^Y8wfh`~t z+l5zmsHOt|K!09M#bW>lbjOszyHig%`i!+N>blao+E{Q|-wlmwcmB%C_k~ z+l3wF_Xt3hI@6T8_)``TfI`P1~G^ihySZY~*c+i<6DO$VM#~6MbMuHv7C~i)0c-7Paqy zNxRWEMk683NJE6sw^O$CADB{bW^>lr;;0p*J)VA?WY)7cpfJDs!`}{*9oW0^xt)mK zq~zG81IX#GX*VQ1&OpHeUJkaUTD3HAUG+>?U+>;#_~pef8CqJ@&|XuSU9(5jr2eq$ zME2bX{4 z&o#=4*~+*Zg!UoV`Vo#V1Kqn8%Z;2B-844Ew6K(b;>a6r^~K1nF;^u_wqL5?P3czX z|I^-eMK!f<>rkXOMWrZFv7j5IiUC3}fE5vRD*^(+2ueqKNu(nIL9l`JqS8S+K|+z1 zNDaM(00Ai>2@wb#ltuesvGJ3F1emJLrUbNF)UsR1 zKXP+?vL{e~4@MBeKezF(@@d)nDXtnYz$7lg`ttbG(b4y}!7lm?wIid~h}DLc9#2T! zM|E6=-Cvk|OB=gq?2uXtcUK{$Ir$+B&1TrmM@U%-4KMT?PPvPPF*7dqj&EGd@WZc9 zGY^X~rWNB9U3*<++IG&*5@?r&#Dl%bpvplc@TM8{6&ffft|do!3fYE)4eXSa@4 ziT)mF#djMTpYqxBLatM^cw`Kn(~Dapz3N z%XCM|HRN^+Mf7jd8-AV_!nG4VcZd#nLvE?1X8*w;La3cGu{^CNJp%s5b)_-9yv`o3 zA{acUW*JV40%?S86`ZjDv>foeP>(9gu7z5xRCcz+wiAlO6=l}OpIQ>ve$Xz2;pZkQ%=yqvx91Q8_JJ(gyKLabH z3^mxpO27;tREk(%<#DwYpJS(`UP82e|yOi5xElwZ)Pl zy2yDvqMGHF>GXxe7KQ9{b|6*Hp{q{fy`t+0oZTJn0%qz6x3`nwzBU6{x=hp2+3MW! zzOh2>4f6cgv^?<+!UrFl&cS@}Nj>7z!5cDc9{t9Gwzpkw0+PZYAlU-s{v~3}9`|Qq zVC+tsi@HVQ)cc|5LQU%_CCkloy=PY%Y3r7CbS=|@=00*!s}n60vpo%kcY)!%L_5U%_-#zO?q8;qU9yWcdvu9oAi{v5oR``#sQ>o&X1S}CzmRK8d|6HaKU`P+od)yFVLA9o5CRryvZom!E|_@=KO{cAUWaW;a@qxupz(xaNZ zltg%W(VxR%qGECjB!6kL>j4wj?%$y+JS zm$i15qFVcTd#mdi8|w>$aCUF2YygOqr*~qgj=Rq;@ul+uo$@+d?3D==;uRO9c;&{2 zSga-aMNQ z#hKoY<7k=G7nHo}Dl~S-56aY#9g`%<&GP{L3a;I6QnQmzk1XQVEEB%)$=FWg?iU$Z zEN-Y`PU$^rAnE7TcSx4~lO1}$DtsbiyO`@e=(H`HwAJ2m*v^x>hHX*vmyhK!=?_kZuW2 zLx4^0{wRi#=qyWzP!T1vw_V2WK~l)sC(JF~YzLMJkKewNE>~ZBTZ+A%MzgyJ&ANnB zO!V~V0v^EHg8Qo8M3q2i>0|JflZ|SQja7xZynrrXT_H_xv2LjY@kH2G#~lBe+ud?v zL@p42)Xec{o6cQ?G*@fP#J4X)KKq(13j(5{zbLxN($q*)BWxCUK3Q`W0T+_?&3l!x z1bCU^RDBnJ^P>uz#KQ@bDa)rvT;ozpmU(ACh#-?;-e!K%2hnH(NTcre;Pl7UL?Kw7 z#GDLs42(~xomhaca8H;5;txy>}qd!|PQ$@KpsccG@ zr?07$?NTkU=P}$OJ+^+T^tWg(L{+;^-Fi|vD^n}LOR`dH2zfh&?!S2k6eY~KWd>RK zp7R%AEb2;)@;U@}ZwS=r@}-^k1^o3S z^%5o#u&D=~*5mFj$BLI1zs@@Z@8cR?ehtYS* zrU$6Igv9nGN`!-Q;O`9*Wc{zmAY>DSCkVPX7Ze<&`z?yDAyHuu>kUb8&57-#?Q{D& zTjFp6Mgd=}E~#wo<8iPx0!6pzjDX2(jMypAPJ^;<96Bp9MdLsj%<5c-k>`j| zEv)CT1p_n7w|x6f`8Z^XnFRp+my3-gb@Zz#>p`YBQsb($iL~RZ3W8udiJ*no(EoVR zE_D3+_T^j)vb{;aef;2gxleM}D?0=j@t9h_%-4uI&ax41ubw`wKgmVBL~#0TNq=Ln zBm#<&0+@R>;g&Gn(+k=wpypDN8FZ94{j|UAhE!4lpDOQX3!>R{4;07V-RgM*XgnPccz$HXd}1Z zrZ>X4CGbpt0LgRc%kWZ8i_mru2#`36<|8B$E<`?uOg?aR9C*<`4nfA3MAjl0~8 zj`~sXRx``=`N+8a?m>7+s+Em=ojj*Q@~^Vpwc(XFquT;D{`0xSn&MiX#ORx=muW@7 zs+1@YcA*60c&BKN-_^ST2tf+vb;x5$q^>Gu1%(&w73umLqFVm3LwDnmVC4D2rphmv zQ>O-iwkpX`#OGq$d?VNdZ_2Qx-QL*hL9o^czLc;jKGElF>#kY#VTcN) zR;-Cn@!t97sZE9NSA_g&ngy4@xbwnSVG|y{hy4wQ#dsrcd_}1(0H~E6v^%AyFG}S2 z%5J&X;&0@3=8){u%eK1uNs-krATZktI8lNf(6tRpt&K+B(K~F^3fIhPp7=;tI$KMA zRI-^@dRKGuL;cID`39!p79_k<%;8>N^ae1h0W$FVz1C5ejQB|B*Zv_JyU1^9S)tPg zFZaR#IlX-|s5-)9ULSrd#d<+2n;-!wAWnUcVmmv3q?3dDMwAIz#dAIP{ks$Oer9B~ z7xHxz1(%hOe@%<@A8N~*INhD9Z08x17K6Z*P(0!T@D$2R&Vdrv0WF`It!l&=uxoK; zUVfrLpZ|Db%!mez&)zVZQ{P%{!s+I8Y_rt%x>Sq@3n4nI9w4b9E4iVAOCtDh+=jx; z`&prpJWH?zV*+^Ch(CSkj6u%4`i}@PHUSwos+%Ohvrn>x&lPHnJbM(HrHdKBUSR(L zIAW7C9k|^a+aq4&tPZ>D5b8t2M?_7)_J;}lD`W2LH-xbsz7cs8&eY9LQ8W%K3KzYr zlzcMMP7$%?=ggmFaE|nTwG-902BHI@CF%ekZ}CpS+LOTUI)1#T+H#V;DNYqmM5pnl zK}&LEK|Np#e^abEfM(1ys8f*6iDd0fnpxcs!;i;iJa`` z=hsW~ywOM5PMks{+cb_k`V3?j2?=I++D^c>uMjw*&8Q&BLa;2)Yo1qAZkgFl2aYU^ zVeDAtNYo1RG9$fuRn-+^CrGkD9Q%CrVC}_(Jx8CG1KL_Q#M`$&WIcv{BoB`E08!=% zw|nnXV?*k8fSEamSdo?T+mu=+a_CE66+&Cbjx=7fRTz8i-dt0LIgZ)fmcwi!vWdhLKYn);L}gAQ@J(QG(@rCu*J=acl2vqB+kdygT8W32%eviU6>n>-#&)Xlk!+(xb5s$kThvc!?{{ zH0zG}Ss6+J1b@0F8-qfSf0S5Vmzl1VAP*`FaB((}2Z4n1WM6pg`mziAEx;yl!*S5% zxQ;8{u3wZ-7Omnefy&85%i60NI8*JSJ&Mf^2isN#Y_0vQcDiD{TWo<7tQk1w4G;Cp zYV_=;jq0cgMN#LwUSArO^k%yxJKcAJz?Y>xiLdMLa@tLK0xdPq&?kUoA^nS}86P-v zwk}xDU)Xk97iT+7TP=r-YL_|vK${CZHN*NL&%B&a(^++#SGz0FVbyk-60G6P?YRYQ zdU?;EP!-rsmp%`xA8Iav0|uLgKlJ1m)u_otL=$HQSqpxTQG^DgpspCIg<(E0Zx4V< z2mbXX+l9q#j2oY3sB6B`d%d(1o?ZJaX72Mp)k?2qbI8y0dvE+ElexfpYbMG7{hq7- z54ZWhahw0IeCEF#d+#3nLpc9KzyA>SBj`P2V5b(1J?jQcAe4Na?n*#!|E$`0}_zynKe@+bq`oE1Fp8E|MkUOTB!Tno% N>zcvUuR4!|{|jw8zBK>< literal 0 HcmV?d00001 diff --git a/docs/ui/mockups/chat-quiet-approval.svg b/docs/ui/mockups/chat-quiet-approval.svg new file mode 100644 index 000000000..5f92753af --- /dev/null +++ b/docs/ui/mockups/chat-quiet-approval.svg @@ -0,0 +1,65 @@ + + + + + + NETCLAW + incident-review + deepseek-v4-flash + + connected + 39% + + You + 09:58 + + + Fix the test and run the focused suite. + + + + Approval required + The agent wants to start a local process. + + + REQUESTED BY + release-reviewer + + ACTION + Run a focused test process + + DIRECTORY + /work/netclaw + + SCOPE + This exact command + + + COMMAND + dotnet test src/Netclaw.Cli.Tests --filter InlineChatPageTests + + + Allow once + + Allow this chat + + Deny + + Decision needed + ←→ choose + Enter confirm + Ctrl+O details + Esc deny + Ctrl+Q quit + diff --git a/docs/ui/mockups/chat-quiet-inspector.png b/docs/ui/mockups/chat-quiet-inspector.png new file mode 100644 index 0000000000000000000000000000000000000000..46a4eef9b8e4f070d70b17a499e1e2801d997260 GIT binary patch literal 92489 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

`{iwy6>OnK(Hl1DYn{}t!jStYZ`O)6!bz?L9I+D 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 Js!m-?`Y*J%H`)LI literal 0 HcmV?d00001 diff --git a/docs/ui/mockups/chat-quiet-inspector.svg b/docs/ui/mockups/chat-quiet-inspector.svg new file mode 100644 index 000000000..a5d6262bc --- /dev/null +++ b/docs/ui/mockups/chat-quiet-inspector.svg @@ -0,0 +1,80 @@ + + + + + + INSPECTOR + incident-review + event 5 of 9 + + + TURN EVENTS + + Done + User prompt + 09:56 + + Done + Thought + 4s + + Done + read workflow + 1.2s + + Done + release-reviewer + 8.4s + + + Tool + compare manifest + 3.1s + + Fail + release check + 0.8s + + Done + Netclaw reply + 09:57 + + + + TOOL RESULT + compare package manifest + + CALL + call_62c8 + STATUS + completed + DURATION + 3.1s + + + RESULT + The release tag matches VersionPrefix and VersionSuffix. + The package manifest contains one stale prerelease entry. + The feed order places beta.10 after beta.9. + The version comparator is correct. + + Y copies this event as clean text. + Shift+Y copies the complete turn. + + Inspect + ↑↓ event + PgUp/PgDn detail + Y copy event + Shift+Y copy turn + Ctrl+O close + diff --git a/docs/ui/mockups/chat-quiet-normal.png b/docs/ui/mockups/chat-quiet-normal.png new file mode 100644 index 0000000000000000000000000000000000000000..45b3d28fd92c42e5eb0787e6a52317e981195103 GIT binary patch literal 80661 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%0DSU4fP7~(D;QwA{> z#1;2j=09|6{IdLq{$nMxU-w1Y$_z&1$+McjwS^xByDbpWGI1t=%i6@4Ur#d`K8E}O zK~J^Zt)S*DolV%^Yfz^8`T*Jhk-OFP5V#7p+P2=R$83497UqOwZ%s{^ZWnxY$@KWsPkyXA&;hZiMFe`2nQc_p&+^A^qk*7vM?MCNTpsh|f}|M8TT+@jhd z%+X`CTsD-?mn4d{`K(N>JqZgBnNuI@V7G$u3h|q)XSC|C3vbl2ca#=h2Y_g%zGUt1 zFNRBgHE?}aG~69;Zo0H*fTPQ3-Fgsj_q_h`vdOpQS)T2WQ>y|hYg|Y7Q@TU$N^Gq8 z51B-Lh_*VxH$MSpg7Q4u!gB@RN#j}>ABy#R1%^I5adrHe#Na#2Q};$*rY(u#^|6-c zyNZ+kbuI1cm7n|s_`GWM_{d5k|9&mCl|@U&#&Zub6uy0EO&;r+I_7;bD0=o9fqP!JmJl>C>=NK**L^6$m(T9jhBmi{-5}5dsh4RvU z4>9$m#(k1

7pC%qBj{fChSC&hrwf-y7q8XV&cyCKM0!UcQSSF$eyyB?;+Gy{GU7 zI-76#Z+(-o9o5EFk_6@4rL7g?j}UFGP;-ZmmX=238P45CW>sk~vD(qh@|))Rg#s_XlJT-OZ9xhl+Hav0{K! zZFL6cKK67Pi9^64u7L?V^Bz9vef17gRgHbAc9-=*pC#IgPtC zqbfo)X|2~Pxih8edMWndFDRx+M3KbLYM&u4an$WGG5xayW>&a)WgO4;q_j~&vMzIL zRvN$?RlQvIvlW^SOCG;MbDa6R|ib?Ai)D3bXb0DhnR{dDM)(dfB1^3da> zhh<#*-F0q%9Bltn0Ii*a@61cZ{BdE+1z4m%0rm+BKMY>1^`Gs3I5P0Ky80F4fb8lj zsD))YdyjF}`lD_t(?$ANyZZgRJ4tM(&8ZLp^uZ7MFuJuUza_iXboU_JBtSZ?hIQyF(UV%PJ;5b9!;&RU&09 z)ktyRr?gF!R5>K+<&&k+4sn_=p*QeZ}pvN}NJtK0j@@zdX%z zc$pqL5@sa~JBqi(W_RDV40})9h(ImP3=e7M?QCm0J7_oDNlOcTLJBWroFBbi9@d(4 zXeoOp8?X38!iZAB0lbIya%bNJE%DKbvQfO}a-+NaINW?_6 zA`PL+myUT0_$LL0;xJuyXX#OyGxH8Iw{u4s=?aIn5A+f{kh`C8P&RUp6M$Wwp!6kM zrengNd4`phUz1^2S*Kx2i}uwxcmmEYN)iZ$I4$O7b+|C5Mb-(p|B2(fI+@Wza(dxW z6rlt>o+a59deuP%b_O`hqW=GGar(gjA&+}%k5?!}2o712Zv>8^f-EjunSO@5J^CM& Cv)}dr literal 0 HcmV?d00001 diff --git a/docs/ui/mockups/chat-quiet-normal.svg b/docs/ui/mockups/chat-quiet-normal.svg new file mode 100644 index 000000000..43c93b7a3 --- /dev/null +++ b/docs/ui/mockups/chat-quiet-normal.svg @@ -0,0 +1,60 @@ + + + + + + + NETCLAW + incident-review + deepseek-v4-flash + + connected + 38% + + You + 09:52 + + + Check the failed CI run and tell me whether the failure is safe to fix. + Do not change the branch yet. + + Netclaw + 09:53 + The Linux test failed because one path assertion uses a Windows separator. + The production path logic is correct. The test fixture contains the defect. + + + + TOOLS + 3 complete + 2.8s + PASS + read workflow · inspect failed log · compare fixture + Ctrl+O details + + A focused fixture change is safe. I can prepare it after you approve the edit. + + + + MESSAGE + Ask Netclaw… + + Ready + Enter send + Shift+Enter newline + ↑↓ history + Esc Esc clear + Ctrl+O inspect + Ctrl+Q quit + From f1f87cf81c768e65e9a025bad01a5909aafba59d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 05:50:08 -0500 Subject: [PATCH 11/39] Apply the quiet chat visual grammar --- Directory.Packages.props | 2 +- .../Tui/InlineChatPageTests.cs | 72 +-- src/Netclaw.Cli/Tui/ChatPresentation.cs | 26 +- src/Netclaw.Cli/Tui/InlineChatPage.cs | 476 +++++++++++++----- 4 files changed, 409 insertions(+), 167 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 11ed03e02..f429fe76f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -76,7 +76,7 @@ - + diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index ec2240450..37cd39d78 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -183,7 +183,7 @@ public async Task Approval_BlocksPasteFromTheHiddenComposer() { await using var harness = CreateHarness(approval: BuildApproval()); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Approval required")); harness.Events.Enqueue(new PasteEvent("blocked paste")); harness.Events.Enqueue(new KeyPressed( @@ -191,7 +191,7 @@ public async Task Approval_BlocksPasteFromTheHiddenComposer() await harness.WaitUntilAsync(() => harness.ViewModel.IsApprovalDetailVisible.Value); harness.Input.EnqueueKey(ConsoleKey.Escape); _ = await harness.ViewModel.ReadApprovalAsync(harness.Cancellation.Token); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Composer")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("MESSAGE")); harness.Input.EnqueueString("safe prompt"); harness.Input.EnqueueKey(ConsoleKey.Enter); @@ -206,7 +206,7 @@ public async Task CtrlO_KeepsTheApprovalSelection() var approval = BuildApproval(); await using var harness = CreateHarness(approval: approval); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Approval required")); harness.Input.EnqueueKey(ConsoleKey.DownArrow); harness.Input.EnqueueKey(ConsoleKey.O, control: true); @@ -223,9 +223,11 @@ public async Task Escape_DeniesAnApproval() { await using var harness = CreateHarness(approval: BuildApproval()); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); - Assert.Contains("This chat — until this chat ends", harness.Terminal.ToString(), StringComparison.Ordinal); - Assert.Contains("Deny — do not run", harness.Terminal.ToString(), StringComparison.Ordinal); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Approval required")); + var screen = harness.Terminal.ToString(); + Assert.Contains("This chat — until this chat ends", screen, StringComparison.Ordinal); + Assert.Contains("Deny — do not run", screen, StringComparison.Ordinal); + AssertHasNoDecorativeTrim(screen); harness.Input.EnqueueKey(ConsoleKey.Escape); @@ -249,7 +251,7 @@ public async Task CtrlO_ShowsApprovalSecurityContext() }; await using var harness = CreateHarness(approval: approval); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Approval required")); harness.Input.EnqueueKey(ConsoleKey.O, control: true); var screen = string.Empty; @@ -277,7 +279,7 @@ public async Task ApprovalDetail_PreservesSelectionAndScrollPositionAcrossCollap var approval = BuildApproval() with { DisplayText = detail }; await using var harness = CreateHarness(approval: approval, height: 24); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Decision Gate")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Approval required")); harness.Input.EnqueueKey(ConsoleKey.DownArrow); harness.Input.EnqueueKey(ConsoleKey.O, control: true); @@ -304,20 +306,20 @@ public async Task Generation_HidesTheComposerAndClearsFocus() { await using var harness = CreateHarness(); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Composer")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("MESSAGE")); Assert.NotNull(harness.Focus.CurrentFocus); harness.ViewModel.IsGenerating.Value = true; harness.ViewModel.StatusMessage.Value = "Generating..."; await harness.WaitUntilAsync(() => harness.Terminal.Contains("Working…") - && !harness.Terminal.Contains("Composer") + && !harness.Terminal.Contains("MESSAGE") && harness.Focus.CurrentFocus is null); harness.ViewModel.IsGenerating.Value = false; harness.ViewModel.StatusMessage.Value = "Ready"; await harness.WaitUntilAsync(() => - harness.Terminal.Contains("Composer") && harness.Focus.CurrentFocus is not null); + harness.Terminal.Contains("MESSAGE") && harness.Focus.CurrentFocus is not null); await harness.StopAsync(runTask); } @@ -333,9 +335,9 @@ public async Task StableTranscript_UsesPrimaryScrollbackWithoutAnOuterBorder() var runTask = harness.StartAsync(); await harness.WaitUntilAsync(() => harness.Terminal.Contains("A stable answer")); - Assert.Equal("NETCLAW", harness.Terminal.GetLine(0)); - Assert.Equal("A stable answer", harness.Terminal.GetLine(1)); - Assert.DoesNotContain('│', harness.Terminal.GetLine(1)); + Assert.Equal(" Netclaw", harness.Terminal.GetLine(0)); + Assert.Equal(" A stable answer", harness.Terminal.GetLine(1)); + AssertHasNoDecorativeTrim(harness.Terminal.ToString()); await harness.StopAsync(runTask); } @@ -399,10 +401,10 @@ await harness.WaitUntilAsync(() => && harness.Terminal.Contains("reviewer")); var screen = harness.Terminal.ToString(); - Assert.Contains("TOOL search", screen, StringComparison.Ordinal); - Assert.Contains("TOOL fetch", screen, StringComparison.Ordinal); - Assert.Contains("AGENT reviewer", screen, StringComparison.Ordinal); - Assert.DoesNotContain("Composer", screen, StringComparison.Ordinal); + Assert.Contains("Tool search", screen, StringComparison.Ordinal); + Assert.Contains("Tool fetch", screen, StringComparison.Ordinal); + Assert.Contains("Agent reviewer", screen, StringComparison.Ordinal); + Assert.DoesNotContain("MESSAGE", screen, StringComparison.Ordinal); await harness.StopAsync(runTask); } @@ -443,11 +445,11 @@ public async Task ActivityDeck_NestsTheActiveToolUnderItsSubagent() }; await using var harness = CreateHarness(outputs: outputs); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("TOOL shell_execute")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Tool shell_execute")); var screen = harness.Terminal.ToString(); - Assert.True(screen.IndexOf("AGENT interface-reviewer", StringComparison.Ordinal) - < screen.IndexOf("TOOL shell_execute", StringComparison.Ordinal)); + Assert.True(screen.IndexOf("Agent interface-reviewer", StringComparison.Ordinal) + < screen.IndexOf("Tool shell_execute", StringComparison.Ordinal)); Assert.Contains("awaiting human approval", screen, StringComparison.Ordinal); await harness.StopAsync(runTask); } @@ -471,7 +473,7 @@ public async Task SubagentApproval_ShowsRequesterPathAndCapsTheGateWidth() await using var harness = CreateHarness(outputs: [output], approval: approval, width: 160); var runTask = harness.StartAsync(); await harness.WaitUntilAsync(() => - harness.Terminal.Contains("interface-reviewer › shell_execute")); + harness.Terminal.Contains("interface-reviewer / shell_execute")); var longestLine = Enumerable.Range(0, harness.Terminal.Height) .Select(index => harness.Terminal.GetLine(index).TrimEnd().Length) @@ -528,13 +530,14 @@ public async Task Inspector_ShowsTheCompleteSemanticToolResult() Assert.DoesNotContain("complete hidden line", harness.Terminal.ToString(), StringComparison.Ordinal); harness.Input.EnqueueKey(ConsoleKey.O, control: true); await harness.WaitUntilAsync(() => - harness.Terminal.Contains("RAW DETAIL") + harness.Terminal.Contains("Tool result") && harness.Terminal.Contains("complete hidden line")); - Assert.Contains("RAW DETAIL", harness.Terminal.ToString(), StringComparison.Ordinal); + Assert.Contains("Tool result", harness.Terminal.ToString(), StringComparison.Ordinal); Assert.Contains("complete hidden line", harness.Terminal.ToString(), StringComparison.Ordinal); Assert.Contains("Arguments: {\"call\":\"call-a\"}", harness.Terminal.ToString(), StringComparison.Ordinal); + AssertHasNoDecorativeTrim(harness.Terminal.ToString()); await harness.StopAsync(runTask); } @@ -579,7 +582,7 @@ public async Task Inspector_DefersNewStableBlocksUntilItCloses() SessionId = SessionId, TimestampMs = 2 }); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("1/2")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("event 1 of 2")); Assert.DoesNotContain("queued answer", harness.Terminal.ToString(), StringComparison.Ordinal); harness.Input.EnqueueKey(ConsoleKey.O, control: true); @@ -701,12 +704,13 @@ public async Task CommonWidths_KeepTheComposerAndStatusVisible(int width) { await using var harness = CreateHarness(width: width); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Composer")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("MESSAGE")); var screen = harness.Terminal.ToString(); - Assert.Contains("netclaw", screen, StringComparison.Ordinal); - Assert.Contains("Composer", screen, StringComparison.Ordinal); + Assert.Contains("NETCLAW", screen, StringComparison.Ordinal); + Assert.Contains("MESSAGE", screen, StringComparison.Ordinal); Assert.Contains("Enter", screen, StringComparison.Ordinal); + AssertHasNoDecorativeTrim(screen); await harness.StopAsync(runTask); } @@ -721,9 +725,10 @@ public async Task NarrowHeader_KeepsACompactConnectionCue() }; await using var harness = CreateHarness(outputs: [joined], width: 40); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("●")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("connected")); - Assert.Contains("netclaw ●", harness.Terminal.ToString(), StringComparison.Ordinal); + Assert.Contains("NETCLAW", harness.Terminal.ToString(), StringComparison.Ordinal); + Assert.Contains("connected", harness.Terminal.ToString(), StringComparison.Ordinal); await harness.StopAsync(runTask); } @@ -736,6 +741,13 @@ public async Task NarrowHeader_KeepsACompactConnectionCue() ArgumentsJson = $"{{\"call\":\"{callId}\"}}" }; + private static void AssertHasNoDecorativeTrim(string screen) + { + const string decorativeTrim = "╭╮╰╯┌┐└┘│─╷╵█░▒▓✓◌↳"; + foreach (var character in decorativeTrim) + Assert.DoesNotContain(character, screen); + } + private static ToolInteractionRequest BuildApproval() => new() { SessionId = SessionId, diff --git a/src/Netclaw.Cli/Tui/ChatPresentation.cs b/src/Netclaw.Cli/Tui/ChatPresentation.cs index cbb0ce11d..23e74ed75 100644 --- a/src/Netclaw.Cli/Tui/ChatPresentation.cs +++ b/src/Netclaw.Cli/Tui/ChatPresentation.cs @@ -411,7 +411,7 @@ private static ChatPresentationState CompleteTool( $"tool:{output.CallId.Value}", ChatBlockKind.Tool, "TOOL", - $"✓ {output.ToolName.Value} · {FirstLine(output.Result)}", + $"{output.ToolName.Value} completed {FirstLine(output.Result)}", $"Tool: {output.ToolName.Value}\nCall: {output.CallId.Value}\n{detail}", output.TimestampMs, active?.TurnId ?? state.CurrentTurnId, @@ -459,7 +459,7 @@ private static ChatPresentationState ReduceSubAgent( $"subagent:{key}", ChatBlockKind.SubAgent, "AGENT", - $"{output.AgentName.Value} · {outcome} · {output.Duration.TotalSeconds:F1}s", + $"{output.AgentName.Value} {outcome} {output.Duration.TotalSeconds:F1}s", $"Sub-agent: {output.AgentName.Value}\n{detail}", output.TimestampMs, state.CurrentTurnId, @@ -511,7 +511,7 @@ private static ChatPresentationState ResolveApproval( $"approval:{output.CallId.Value}:{output.TimestampMs}", ChatBlockKind.Approval, "APPROVAL", - $"{path} · {decision}", + $"{path} {decision}", $"Approval: {path}\n{detail}", output.TimestampMs, state.CurrentTurnId, @@ -601,10 +601,10 @@ private static ChatPresentationBlock ResumeBlock(SessionTranscriptEntry entry, i var summary = kind switch { ChatBlockKind.User or ChatBlockKind.Assistant => entry.Text ?? string.Empty, - ChatBlockKind.Tool => $"✓ {entry.ToolName ?? "unknown"} · {FirstLine(entry.Result)}", - ChatBlockKind.Approval => $"{entry.ToolName ?? "unknown"} · {ApprovalDecisionText(entry.ApprovalSelectedKey)}", - ChatBlockKind.SubAgent => $"{entry.AgentName ?? "sub-agent"} · {entry.Outcome ?? "complete"}", - ChatBlockKind.File => $"{entry.FileName ?? "file"} · {entry.FilePath}", + ChatBlockKind.Tool => $"{entry.ToolName ?? "unknown"} completed {FirstLine(entry.Result)}", + ChatBlockKind.Approval => $"{entry.ToolName ?? "unknown"} {ApprovalDecisionText(entry.ApprovalSelectedKey)}", + ChatBlockKind.SubAgent => $"{entry.AgentName ?? "sub-agent"} {entry.Outcome ?? "complete"}", + ChatBlockKind.File => $"{entry.FileName ?? "file"} {entry.FilePath}", ChatBlockKind.Error => entry.ErrorMessage ?? "Unknown error", ChatBlockKind.Usage => UsageSummary(entry), ChatBlockKind.Compaction => $"{entry.MessagesBefore ?? 0} → {entry.MessagesAfter ?? 0} messages", @@ -626,9 +626,9 @@ private static ChatPresentationBlock ResumeBlock(SessionTranscriptEntry entry, i private static ChatPresentationBlock UsageBlock(UsageOutput usage, string? turnId) { - var summary = $"{usage.InputTokens ?? 0} in · {usage.OutputTokens ?? 0} out" - + (usage.ReasoningTokens is > 0 ? $" · {usage.ReasoningTokens} thought" : string.Empty) - + (usage.UsagePercent is not null ? $" · {usage.UsagePercent:P0} context" : string.Empty); + var summary = $"{usage.InputTokens ?? 0} in {usage.OutputTokens ?? 0} out" + + (usage.ReasoningTokens is > 0 ? $" {usage.ReasoningTokens} thought" : string.Empty) + + (usage.UsagePercent is not null ? $" {usage.UsagePercent:P0} context" : string.Empty); var detail = $"Input tokens: {usage.InputTokens ?? 0}\nOutput tokens: {usage.OutputTokens ?? 0}" + $"\nCached input tokens: {usage.CachedInputTokens ?? 0}" + $"\nReasoning tokens: {usage.ReasoningTokens ?? 0}" @@ -668,7 +668,7 @@ private static ChatPresentationBlock FileBlock(FileOutput file, string? turnId) $"file:{file.TimestampMs}:{file.FilePath}", ChatBlockKind.File, "FILE", - $"{file.FileName} · {file.MimeType.Value}", + $"{file.FileName} {file.MimeType.Value}", detail, file.TimestampMs, turnId, @@ -732,8 +732,8 @@ private static ChatPresentationBlock DiagnosticBlock( }; private static string UsageSummary(SessionTranscriptEntry entry) => - $"{entry.InputTokens ?? 0} in · {entry.OutputTokens ?? 0} out" - + (entry.ReasoningTokens is > 0 ? $" · {entry.ReasoningTokens} thought" : string.Empty); + $"{entry.InputTokens ?? 0} in {entry.OutputTokens ?? 0} out" + + (entry.ReasoningTokens is > 0 ? $" {entry.ReasoningTokens} thought" : string.Empty); private static string Label(ChatBlockKind kind) => kind switch { diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 2ae9016c8..dc460c9c8 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -74,6 +74,8 @@ protected override void OnBound() _promptInput = new TextAreaNode() .WithPlaceholder("Ask Netclaw...") + .WithForeground(ChatVisualTheme.Text) + .WithBackground(ChatVisualTheme.SurfaceStrong) .WithMaxHeight(8) .WithHistory(100) .WithNewlineModifier(ConsoleModifiers.Shift); @@ -205,6 +207,8 @@ private void ApplyReduction(ChatReduction reduction) switch (effect) { case ChatPresentationEffect.Commit commit: + if (!ShowsInPrimaryTranscript(commit.Block)) + break; if (_inspectorOpen) _deferredInspectorCommits.Add(commit.Block); else @@ -268,7 +272,10 @@ private ILayoutNode BuildLiveRegion() if (_inspectorOpen) return BuildInspector(); - var content = Layouts.Vertical() + var content = Layouts.Vertical(); + if (_state.Transcript.Count > 0) + content.WithChild(Layouts.Empty().Height(1)); + content .WithChild(BuildSessionHeader()) .WithChild(BuildActivityDeck()); @@ -283,7 +290,9 @@ private ILayoutNode BuildLiveRegion() private ILayoutNode BuildInspector() { var block = _state.Transcript[_inspectorIndex]; - var detailWidth = Math.Max(1, ReadableWidth() - 4); + var showEventList = _terminal.Width >= 92; + var eventListWidth = showEventList ? Math.Min(36, ReadableWidth() / 3) : 0; + var detailWidth = Math.Max(1, ReadableWidth() - eventListWidth - (showEventList ? 4 : 2)); if (_inspectorDetail is null || _inspectorBlockKey != block.Key || _inspectorRenderWidth != detailWidth) @@ -292,7 +301,7 @@ private ILayoutNode BuildInspector() _inspectorRenderWidth = detailWidth; _inspectorDetail ??= new ScrollableContainerNode() .WithAutoScroll(AutoScrollPolicy.None) - .WithScrollbar(true); + .WithScrollbar(false); var semanticText = ChatPresentationRenderer.SemanticCopyText(block.SemanticText); var displayText = RemoveDuplicateInspectorLabel(semanticText, block.Label); if (block.Kind == ChatBlockKind.Assistant) @@ -307,37 +316,96 @@ private ILayoutNode BuildInspector() _inspectorDetail.ScrollToTop(); } - var detailLabel = block.Kind == ChatBlockKind.Tool ? " RAW DETAIL" : string.Empty; - var heading = $" INSPECTOR {_inspectorIndex + 1}/{_state.Transcript.Count} {block.Label}{detailLabel}"; - var content = Layouts.Vertical() - .WithChild(new TextNode(heading).WithForeground(Color.BrightBlue).Bold()) + var heading = $"{InspectorDetailTitle(block)} {EventTime(block)} event {_inspectorIndex + 1} of {_state.Transcript.Count}"; + var detailContent = Layouts.Vertical() + .WithChild(new TextNode(heading).WithForeground(ChatVisualTheme.Primary).Bold()) .WithChild(_inspectorDetail.Fill()); if (_inspectorCopyStatus is not null) { var color = _inspectorCopyStatus.StartsWith("Copy failed", StringComparison.Ordinal) - ? Color.Red - : Color.Green; - content.WithChild(new TextNode($" {_inspectorCopyStatus}").WithForeground(color)); + ? ChatVisualTheme.Danger + : ChatVisualTheme.Success; + detailContent.WithChild(new TextNode(_inspectorCopyStatus).WithForeground(color)); } + detailContent.WithChild(new TextNode( + "Y copy event Shift+Y copy turn") + .WithForeground(ChatVisualTheme.Muted)); + + var inspectorHeight = Math.Clamp(_terminal.Height - 14, 8, 32); + var detailPanel = new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.Surface) + .WithPadding(1) + .WithContent(detailContent) + .Height(inspectorHeight); + + ILayoutNode inspectorBody; + if (showEventList) + { + inspectorBody = Layouts.Horizontal() + .WithChild(BuildInspectorEventList(eventListWidth, inspectorHeight)) + .WithChild(Layouts.Empty().Width(2)) + .WithChild(detailPanel.WidthFill()); + } + else + { + inspectorBody = detailPanel.Width(ReadableWidth()); + } + var help = _terminal.Width >= 86 - ? " ↑↓ event · PgUp/PgDn detail · Y event · Shift+Y turn · Ctrl+O/Esc close" - : " ↑↓ event · Pg scroll · Y event · Shift+Y turn · Esc close"; - content.WithChild(new TextNode(help) - .WithForeground(Color.Gray)); + ? "Up/Down event PgUp/PgDn detail Ctrl+O or Esc close" + : "Up/Down event Pg scroll Esc close"; + var inspectorHeader = new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.HeaderSurface) + .WithContent(new TextNode( + $" INSPECTOR {_state.SessionTitle ?? "current turn"} event {_inspectorIndex + 1} of {_state.Transcript.Count}") + .WithForeground(ChatVisualTheme.Primary) + .Bold()) + .Height(1); + return Layouts.Vertical() + .WithChild(inspectorHeader) + .WithChild(inspectorBody) + .WithChild(new TextNode(help).WithForeground(ChatVisualTheme.Muted)); + } - var panel = new PanelNode() - .WithBorder(BorderStyle.Single) - .WithBorderColor(Color.BrightBlue) - .WithContent(content) - .Width(ReadableWidth()) - .Height(Math.Max(8, _terminal.Height - 4)); + private ILayoutNode BuildInspectorEventList(int width, int height) + { + var rowCapacity = Math.Max(1, height - 3); + var start = Math.Clamp( + _inspectorIndex - (rowCapacity / 2), + 0, + Math.Max(0, _state.Transcript.Count - rowCapacity)); + var content = Layouts.Vertical() + .WithChild(new TextNode("TURN EVENTS").WithForeground(ChatVisualTheme.Muted)); + foreach (var (block, index) in _state.Transcript + .Skip(start) + .Take(rowCapacity) + .Select((value, offset) => (value, start + offset))) + { + var state = InspectorEventState(block); + var rowText = ChatPresentationRenderer.OneLine( + $"{state,-8} {InspectorEventName(block)}", + Math.Max(1, width - 2)); + var row = new TextNode(rowText) + .WithForeground(block.IsFailure ? ChatVisualTheme.Danger : ChatVisualTheme.Text) + .NoWrap(); + content.WithChild(new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(index == _inspectorIndex + ? ChatVisualTheme.SurfaceSelected + : ChatVisualTheme.Surface) + .WithContent(row) + .Height(1)); + } - var inspector = Layouts.Vertical() - .WithChild(Layouts.Empty().Height(1)) - .WithChild(panel); - return Layouts.Horizontal() - .WithChild(inspector) - .WithChild(Layouts.Empty().Fill()); + return new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.Surface) + .WithPadding(1) + .WithContent(content) + .Width(width) + .Height(height); } private static string RemoveDuplicateInspectorLabel(string text, string label) @@ -447,33 +515,41 @@ private void CopyInspectorSelection(bool completeTurn) private ILayoutNode BuildSessionHeader() { - var session = ViewModel.SessionIdDisplay.Value; - var connectionCue = _state.HasJoined ? "●" : "○"; - var sessionPart = string.IsNullOrWhiteSpace(session) + var connectionPart = _state.HasJoined ? "connected" : "connecting"; + var sessionPart = !_state.HasJoined ? "connecting" - : $"session {_state.SessionTitle ?? ChatPresentationRenderer.CompactIdentity(session, 30)}"; - var modelPart = _terminal.Width >= 100 ? $" model {ViewModel.ModelId}" : string.Empty; + : _state.SessionTitle ?? "new session"; + var modelPart = _terminal.Width >= 100 ? $" {ViewModel.ModelId}" : string.Empty; var contextPart = _terminal.Width >= 110 && _state.ContextUsagePercent is { } usage - ? $" context {Math.Round(usage * 100, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture)}%" + ? $" {Math.Round(usage * 100, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture)}%" : string.Empty; - var daemonPart = _terminal.Width >= 140 - ? _state.HasJoined ? " daemon connected" : " daemon connecting" + var daemonPart = _terminal.Width >= 76 + ? $" {connectionPart}" : string.Empty; - return new TextNode($" netclaw {connectionCue} {sessionPart}{modelPart}{contextPart}{daemonPart}") - .WithForeground(Color.BrightBlue) + var headerText = _terminal.Width < 60 + ? $" NETCLAW {connectionPart}" + : $" NETCLAW {sessionPart}{modelPart}{contextPart}{daemonPart}"; + var header = new TextNode(headerText) + .WithForeground(ChatVisualTheme.Primary) .Bold(); + return new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.HeaderSurface) + .WithContent(header) + .Width(ReadableWidth()) + .Height(1); } private ILayoutNode BuildActivityDeck() { var rows = new List(); - var lineWidth = Math.Max(20, ReadableWidth() - 2); + var lineWidth = Math.Max(20, ReadableWidth() - 4); if (!string.IsNullOrWhiteSpace(_state.ThoughtText)) { rows.Add(new TextNode(ChatPresentationRenderer.OneLine( - $" ◌ THOUGHT {_state.ThoughtText}", + $"Thought {_state.ThoughtText}", lineWidth)) - .WithForeground(Color.Yellow)); + .WithForeground(ChatVisualTheme.Warning)); } var agents = _state.SubAgents.Values @@ -494,7 +570,7 @@ private ILayoutNode BuildActivityDeck() }; var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; rows.Add(new TextNode(ChatPresentationRenderer.OneLine( - $" ◌ TOOL {tool.ToolName} {phase}{summary}", + $"{ActivityState(tool.Phase),-8} Tool {tool.ToolName} {phase}{summary}", lineWidth)) .WithForeground(ActivityColor(tool.Phase))); @@ -509,20 +585,36 @@ private ILayoutNode BuildActivityDeck() rows.Add(BuildAgentActivity(run, lineWidth)); if (rows.Count == 0 && _state.IsProcessing) - rows.Add(new TextNode(" ◌ WORKING").WithForeground(Color.Yellow)); - - return rows.Count == 0 ? Layouts.Empty() : Layouts.Vertical([.. rows]); + rows.Add(new TextNode("Working").WithForeground(ChatVisualTheme.Warning).Bold()); + + if (rows.Count == 0) + return Layouts.Empty(); + + var activity = Layouts.Vertical() + .WithChild(new TextNode("ACTIVITY").WithForeground(ChatVisualTheme.Primary).Bold()); + foreach (var row in rows) + activity.WithChild(row); + + return new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.Surface) + .WithPadding(1) + .WithContent(activity) + .Width(ReadableWidth()); } private ILayoutNode BuildComposer() { + var content = Layouts.Vertical() + .WithChild(new TextNode("MESSAGE").WithForeground(ChatVisualTheme.Primary).Bold()) + .WithChild(_promptInput); var composer = new PanelNode() - .WithTitle("Composer") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Cyan) - .WithContent(_promptInput) + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.SurfaceStrong) + .WithPadding(1) + .WithContent(content) .Width(ReadableWidth()) - .HeightAuto(min: 3, max: Math.Max(3, Math.Min(10, _terminal.Height / 3))); + .HeightAuto(min: 4, max: Math.Max(4, Math.Min(11, _terminal.Height / 3))); return Layouts.Horizontal() .WithChild(composer) .WithChild(Layouts.Empty().Fill()); @@ -535,10 +627,15 @@ private ILayoutNode BuildDecisionGate(ToolInteractionRequest approval) var maximumHeight = ViewModel.IsApprovalDetailVisible.Value ? detailHeight + approval.Options.Count + 4 : approval.Options.Count + 5; - var gate = Layouts.Vertical() - .WithChild(new TextNode($" APPROVAL {ChatPresentationRenderer.ApprovalPath(_state, approval)}") - .WithForeground(Color.Yellow) - .Bold()); + var header = new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.ApprovalHeader) + .WithContent(new TextNode( + $" Approval required {ChatPresentationRenderer.ApprovalPath(_state, approval)}") + .WithForeground(ChatVisualTheme.Warning) + .Bold()) + .Height(1); + var gate = Layouts.Vertical().WithChild(header); if (ViewModel.IsApprovalDetailVisible.Value) { gate.WithChild(EnsureApprovalDetail(approval) @@ -549,21 +646,21 @@ private ILayoutNode BuildDecisionGate(ToolInteractionRequest approval) gate.WithChild(new TextNode(ChatPresentationRenderer.OneLine( approval.DisplayText, Math.Max(20, width - 4))) - .WithForeground(Color.White)); + .WithForeground(ChatVisualTheme.Text)); } gate .WithChild(new TextNode( ViewModel.IsApprovalDetailVisible.Value - ? " PgUp/PgDn scroll · Ctrl+O close details · Escape deny" - : " Ctrl+O details · Escape deny") - .WithForeground(Color.Gray)) + ? "PgUp/PgDn scroll Ctrl+O close details Escape deny" + : "Ctrl+O details Escape deny") + .WithForeground(ChatVisualTheme.Muted)) .WithChild(EnsureApprovalList()); var panel = new PanelNode() - .WithTitle("Decision Gate") - .WithBorder(BorderStyle.Rounded) - .WithBorderColor(Color.Yellow) + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.ApprovalSurface) + .WithPadding(1) .WithContent(gate) .Width(ReadableWidth()) .HeightAuto(min: 6, max: Math.Max(6, Math.Min(maximumHeight, _terminal.Height / 2))); @@ -626,8 +723,8 @@ private ScrollableContainerNode EnsureApprovalDetail(ToolInteractionRequest appr _approvalDetailCallId = approval.CallId.Value; _approvalDetail = new ScrollableContainerNode() .WithAutoScroll(AutoScrollPolicy.None) - .WithScrollbar(true) - .WithContent(new TextNode(BuildApprovalDetail(approval)).WithForeground(Color.White)); + .WithScrollbar(false) + .WithContent(new TextNode(BuildApprovalDetail(approval)).WithForeground(ChatVisualTheme.Text)); return _approvalDetail; } @@ -643,14 +740,15 @@ private ILayoutNode BuildStatusLine() var keys = StatusKeys( _terminal.Width, _state.PendingApproval is not null, + ViewModel.IsApprovalDetailVisible.Value, ShowsComposer(_state), _modifiedEnterKeySupport); var statusLine = Layouts.Horizontal() - .WithChild(new TextNode($" {displayStatus}") + .WithChild(new TextNode(displayStatus) .WithForeground(StatusColor(status)) .WidthAuto()) - .WithChild(new TextNode($" · {keys}") - .WithForeground(Color.Gray) + .WithChild(new TextNode($" {keys}") + .WithForeground(ChatVisualTheme.Muted) .NoWrap() .Fill()) .Width(ReadableWidth()) @@ -665,19 +763,19 @@ private ILayoutNode BuildStatusLine() private static ILayoutNode BuildAgentActivity(SubAgentActivityPresentation run, int lineWidth) { var summary = string.IsNullOrWhiteSpace(run.Summary) ? string.Empty : $" {run.Summary}"; - var prefix = run.ParentCallId is null ? " ↳" : " ↳"; + var prefix = run.ParentCallId is null ? " " : " "; var rows = new List { new TextNode(ChatPresentationRenderer.OneLine( - $"{prefix} AGENT {run.AgentName} {run.Phase}{summary}", + $"{prefix}{ActivityState(run.Phase),-8} Agent {run.AgentName} {run.Phase}{summary}", lineWidth)) .WithForeground(ActivityColor(run.Phase)) }; if (run.ActiveToolName is not null) { - var toolPrefix = run.ParentCallId is null ? " ↳" : " ↳"; + var toolPrefix = run.ParentCallId is null ? " " : " "; rows.Add(new TextNode(ChatPresentationRenderer.OneLine( - $"{toolPrefix} TOOL {run.ActiveToolName} {run.Phase}", + $"{toolPrefix}{ActivityState(run.Phase),-8} Tool {run.ActiveToolName} {run.Phase}", lineWidth)) .WithForeground(ActivityColor(run.Phase))); } @@ -719,55 +817,111 @@ private static int ApprovalDetailHeight(ToolInteractionRequest approval, int wid private static string StatusKeys( int width, bool hasApproval, + bool approvalDetailVisible, bool hasComposer, TerminalCapabilityAvailability modifiedEnterKeySupport) { if (hasApproval) return width >= 88 - ? "↑↓ select · Enter confirm · Ctrl+O details · Esc deny · Ctrl+Q quit" - : "↑↓ select · Enter confirm · Esc deny"; + ? approvalDetailVisible + ? "Up/Down select Enter confirm Ctrl+O close Esc deny Ctrl+Q quit" + : "Up/Down select Enter confirm Ctrl+O details Esc deny Ctrl+Q quit" + : "Up/Down select Enter confirm Esc deny"; if (!hasComposer) - return width >= 70 ? "Ctrl+O inspect · Ctrl+Q quit" : "Ctrl+Q quit"; + return width >= 70 ? "Ctrl+O inspect Ctrl+Q quit" : "Ctrl+Q quit"; if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unavailable) { if (width >= 110) - return "Enter send · Shift+Enter unavailable · Esc Esc clear · Ctrl+O inspect · Ctrl+Q quit"; + return "Enter send Shift+Enter unavailable Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; return width >= 66 - ? "Enter send · Shift+Enter unavailable · Esc Esc clear" + ? "Enter send Shift+Enter unavailable Esc Esc clear" : width >= 48 - ? "Enter send · Shift+Enter unavailable" + ? "Enter send Shift+Enter unavailable" : "Shift+Enter unavailable"; } if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unknown) { if (width >= 110) - return "Enter send · Shift+Enter status pending · Esc Esc clear · Ctrl+O inspect · Ctrl+Q quit"; + return "Enter send Shift+Enter status pending Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; return width >= 66 - ? "Enter send · Shift+Enter pending · Esc Esc clear" + ? "Enter send Shift+Enter pending Esc Esc clear" : width >= 48 - ? "Enter send · Shift+Enter pending" + ? "Enter send Shift+Enter pending" : "Shift+Enter pending"; } if (width >= 110) - return "Enter send · Shift+Enter newline · Esc Esc clear · Ctrl+O inspect · Ctrl+Q quit"; + return "Enter send Shift+Enter newline Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; return width >= 66 - ? "Enter send · Shift+Enter line · Esc Esc clear · Ctrl+O inspect" - : "Enter send · Shift+Enter line"; + ? "Enter send Shift+Enter line Esc Esc clear Ctrl+O inspect" + : "Enter send Shift+Enter line"; } private static Color ActivityColor(string phase) => phase.ToLowerInvariant() switch { - "queued" => Color.Gray, - "failed" or "error" or "denied" => Color.Red, - "completed" or "complete" => Color.Green, - _ => Color.Yellow + "queued" => ChatVisualTheme.Muted, + "failed" or "error" or "denied" => ChatVisualTheme.Danger, + "completed" or "complete" => ChatVisualTheme.Success, + _ => ChatVisualTheme.Primary }; + private static string ActivityState(string phase) => phase.ToLowerInvariant() switch + { + "queued" => "Queued", + "failed" or "error" => "Failed", + "denied" => "Denied", + "completed" or "complete" => "Done", + _ => "Live" + }; + + private static bool ShowsInPrimaryTranscript(ChatPresentationBlock block) => + block.Kind is not ChatBlockKind.System and not ChatBlockKind.Usage; + + private static string InspectorEventState(ChatPresentationBlock block) => block.Kind switch + { + _ when block.IsFailure => "Fail", + ChatBlockKind.User => "Prompt", + ChatBlockKind.Assistant => "Reply", + ChatBlockKind.System => "Title", + ChatBlockKind.Thought => "Thought", + ChatBlockKind.Tool => "Tool", + ChatBlockKind.Parallel => "Batch", + ChatBlockKind.SubAgent => "Agent", + ChatBlockKind.Approval => "Approval", + ChatBlockKind.File => "File", + ChatBlockKind.Usage => "Usage", + ChatBlockKind.Compaction => "Context", + ChatBlockKind.Diagnostic => "Notice", + _ => "Done" + }; + + private static string InspectorEventName(ChatPresentationBlock block) => block.Kind switch + { + ChatBlockKind.User => "User", + ChatBlockKind.Assistant => "Netclaw", + ChatBlockKind.System => ChatPresentationRenderer.OneLine(block.Summary, 24), + ChatBlockKind.Usage => ChatPresentationRenderer.OneLine(block.Summary, 24), + ChatBlockKind.Tool => ChatPresentationRenderer.OneLine(block.Summary, 24), + ChatBlockKind.SubAgent => ChatPresentationRenderer.OneLine(block.Summary, 24), + _ => ChatPresentationRenderer.DisplayLabel(block.Kind) + }; + + private static string InspectorDetailTitle(ChatPresentationBlock block) => block.Kind switch + { + ChatBlockKind.User => "User prompt", + ChatBlockKind.Assistant => "Netclaw reply", + ChatBlockKind.Tool => "Tool result", + _ => ChatPresentationRenderer.DisplayLabel(block.Kind) + }; + + private static string EventTime(ChatPresentationBlock block) => block.TimestampMs > 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(block.TimestampMs).ToString("HH:mm") + : string.Empty; + private int FindDefaultInspectorIndex() { for (var index = _state.Transcript.Count - 1; index >= 0; index--) @@ -812,14 +966,14 @@ private void HandleEscape() private static Color StatusColor(string status) => status switch { - "Ready" => Color.Green, - "Approval required" => Color.Yellow, - _ when status.StartsWith("Connected", StringComparison.Ordinal) => Color.Green, - _ when status.StartsWith("Reconnected", StringComparison.Ordinal) => Color.Green, - _ when status.StartsWith("Generating", StringComparison.Ordinal) => Color.Yellow, - _ when status.StartsWith("Output failed", StringComparison.Ordinal) => Color.Red, - _ when status.StartsWith("Connection failed", StringComparison.Ordinal) => Color.Red, - _ => Color.Gray + "Ready" => ChatVisualTheme.Success, + "Approval required" => ChatVisualTheme.Warning, + _ when status.StartsWith("Connected", StringComparison.Ordinal) => ChatVisualTheme.Success, + _ when status.StartsWith("Reconnected", StringComparison.Ordinal) => ChatVisualTheme.Success, + _ when status.StartsWith("Generating", StringComparison.Ordinal) => ChatVisualTheme.Warning, + _ when status.StartsWith("Output failed", StringComparison.Ordinal) => ChatVisualTheme.Danger, + _ when status.StartsWith("Connection failed", StringComparison.Ordinal) => ChatVisualTheme.Danger, + _ => ChatVisualTheme.Muted }; private bool ShowsComposer(ChatPresentationState state) => @@ -844,19 +998,42 @@ public static ILayoutNode BuildStableBlock(ChatPresentationBlock block, int widt ? MarkdownToPlainText(block.Summary) : block.Summary; + var leftMargin = width >= 60 ? 2 : 0; + var readableWidth = Math.Min(width - leftMargin, MaximumReadableWidth); var bodyNode = new TextNode(VisibleControlText(body, 16_000)) .WithForeground(BodyColor(block)) - .Width(Math.Min(width, MaximumReadableWidth)); - var bodyRow = Layouts.Horizontal() - .WithChild(bodyNode) - .WithChild(Layouts.Empty().Fill()); + .Width(readableWidth - (UsesSurface(block) ? 2 : 0)); + var heading = new TextNode($"{DisplayLabel(block.Kind)}{timePart}") + .WithForeground(LabelColor(block)) + .Bold(); + ILayoutNode content; + if (UsesSurface(block)) + { + content = new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(SurfaceColor(block)) + .WithPadding(1) + .WithContent(bodyNode) + .Width(readableWidth); + } + else + { + content = Layouts.Horizontal() + .WithChild(bodyNode) + .WithChild(Layouts.Empty().Fill()); + } - return Layouts.Vertical() - .WithChild(new TextNode($"{block.Label}{timePart}") - .WithForeground(LabelColor(block)) - .Bold()) - .WithChild(bodyRow) + var stableBlock = Layouts.Vertical() + .WithChild(heading) + .WithChild(content) .WithChild(new TextNode(string.Empty)); + if (leftMargin == 0) + return stableBlock; + + return Layouts.Horizontal() + .WithChild(Layouts.Empty().Width(leftMargin)) + .WithChild(stableBlock.Width(readableWidth)) + .WithChild(Layouts.Empty().Fill()); } private const int MaximumReadableWidth = 120; @@ -964,41 +1141,75 @@ public static string ApprovalPath( var requester = state.SubAgents.Values.FirstOrDefault(run => string.Equals(run.ParentCallId, parentCallId, StringComparison.Ordinal)); return requester is null - ? $"sub-agent › {approval.ToolName.Value}" - : $"{requester.AgentName} › {approval.ToolName.Value}"; + ? $"sub-agent / {approval.ToolName.Value}" + : $"{requester.AgentName} / {approval.ToolName.Value}"; } private static Color LabelColor(ChatPresentationBlock block) => block.Kind switch { - ChatBlockKind.User => Color.Cyan, - ChatBlockKind.Assistant => Color.BrightBlue, - ChatBlockKind.Thought => Color.Yellow, - ChatBlockKind.Tool when block.IsFailure => Color.Red, - ChatBlockKind.Tool => Color.Green, - ChatBlockKind.Parallel => Color.BrightCyan, - ChatBlockKind.SubAgent when block.IsFailure => Color.Red, - ChatBlockKind.SubAgent => Color.Green, - ChatBlockKind.Approval => Color.Yellow, - ChatBlockKind.File => Color.Cyan, - ChatBlockKind.Error => Color.Red, - ChatBlockKind.Usage => Color.Gray, - ChatBlockKind.Compaction => Color.Gray, - ChatBlockKind.Diagnostic => Color.Red, - _ => Color.Gray + ChatBlockKind.User => ChatVisualTheme.Human, + ChatBlockKind.Assistant => ChatVisualTheme.Primary, + ChatBlockKind.Thought => ChatVisualTheme.Warning, + ChatBlockKind.Tool when block.IsFailure => ChatVisualTheme.Danger, + ChatBlockKind.Tool => ChatVisualTheme.Success, + ChatBlockKind.Parallel => ChatVisualTheme.Primary, + ChatBlockKind.SubAgent when block.IsFailure => ChatVisualTheme.Danger, + ChatBlockKind.SubAgent => ChatVisualTheme.Success, + ChatBlockKind.Approval => ChatVisualTheme.Warning, + ChatBlockKind.File => ChatVisualTheme.Primary, + ChatBlockKind.Error => ChatVisualTheme.Danger, + ChatBlockKind.Usage => ChatVisualTheme.Muted, + ChatBlockKind.Compaction => ChatVisualTheme.Muted, + ChatBlockKind.Diagnostic => ChatVisualTheme.Danger, + _ => ChatVisualTheme.Muted }; private static Color BodyColor(ChatPresentationBlock block) => block.Kind switch { - ChatBlockKind.Error or ChatBlockKind.Diagnostic => Color.Red, - ChatBlockKind.Approval when block.IsFailure => Color.Red, - ChatBlockKind.Usage or ChatBlockKind.Compaction => Color.Gray, - _ => Color.White + ChatBlockKind.Error or ChatBlockKind.Diagnostic => ChatVisualTheme.Danger, + ChatBlockKind.Approval when block.IsFailure => ChatVisualTheme.Danger, + ChatBlockKind.Usage or ChatBlockKind.Compaction => ChatVisualTheme.Muted, + _ => ChatVisualTheme.Text + }; + + private static bool UsesSurface(ChatPresentationBlock block) => block.Kind is + ChatBlockKind.User + or ChatBlockKind.Tool + or ChatBlockKind.Parallel + or ChatBlockKind.SubAgent + or ChatBlockKind.Approval + or ChatBlockKind.Error + or ChatBlockKind.Diagnostic; + + private static Color SurfaceColor(ChatPresentationBlock block) => block.Kind switch + { + ChatBlockKind.User => ChatVisualTheme.HumanSurface, + ChatBlockKind.Approval => ChatVisualTheme.ApprovalSurface, + ChatBlockKind.Error or ChatBlockKind.Diagnostic => ChatVisualTheme.DangerSurface, + _ => ChatVisualTheme.Surface + }; + + internal static string DisplayLabel(ChatBlockKind kind) => kind switch + { + ChatBlockKind.System => "Session", + ChatBlockKind.User => "You", + ChatBlockKind.Assistant => "Netclaw", + ChatBlockKind.Thought => "Thought", + ChatBlockKind.Tool => "Tool", + ChatBlockKind.Parallel => "Parallel tools", + ChatBlockKind.SubAgent => "Agent", + ChatBlockKind.Approval => "Approval", + ChatBlockKind.File => "File", + ChatBlockKind.Error => "Error", + ChatBlockKind.Usage => "Usage", + ChatBlockKind.Compaction => "Context", + _ => "Diagnostic" }; private static string MarkdownLineToPlainText(string sourceLine) { var line = HeadingPrefixRegex().Replace(sourceLine, string.Empty); - line = QuotePrefixRegex().Replace(line, "│ "); + line = QuotePrefixRegex().Replace(line, " "); line = BulletPrefixRegex().Replace(line, "$1• "); line = MarkdownLinkRegex().Replace(line, "$1 <$2>"); line = InlineCodeRegex().Replace(line, "$1"); @@ -1031,3 +1242,22 @@ private static string MarkdownLineToPlainText(string sourceLine) [GeneratedRegex(@"(? Date: Wed, 12 Aug 2026 06:10:10 -0500 Subject: [PATCH 12/39] Align chat surfaces with the visual grammar --- .../Tui/InlineChatPageTests.cs | 53 +++++++-- src/Netclaw.Cli/Tui/InlineChatPage.cs | 111 ++++++++++++------ 2 files changed, 117 insertions(+), 47 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index 37cd39d78..f26a7e767 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -50,7 +50,7 @@ public async Task ShiftEnter_AddsNewline_AndEnterSubmitsExactText() [InlineData(60)] [InlineData(80)] [InlineData(120)] - public async Task ModifiedEnterUnavailable_ShowsAVisibleCapabilityResult(int width) + public async Task ModifiedEnterUnavailable_OmitsTheUnavailableShortcut(int width) { await using var harness = CreateHarness(width: width); var runTask = harness.StartAsync(); @@ -60,14 +60,8 @@ public async Task ModifiedEnterUnavailable_ShowsAVisibleCapabilityResult(int wid TerminalCapabilityAvailability.Unavailable, TerminalInputCapabilitySource.LegacyTerminal))); - try - { - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Shift+Enter unavailable")); - } - catch (OperationCanceledException) - { - Assert.Fail($"Capability result was not visible. Screen:\n{harness.Terminal}"); - } + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Enter send")); + Assert.DoesNotContain("Shift+Enter", harness.Terminal.ToString(), StringComparison.Ordinal); await harness.StopAsync(runTask); } @@ -225,6 +219,7 @@ public async Task Escape_DeniesAnApproval() var runTask = harness.StartAsync(); await harness.WaitUntilAsync(() => harness.Terminal.Contains("Approval required")); var screen = harness.Terminal.ToString(); + Assert.Contains("Netclaw requests permission to run shell_execute", screen, StringComparison.Ordinal); Assert.Contains("This chat — until this chat ends", screen, StringComparison.Ordinal); Assert.Contains("Deny — do not run", screen, StringComparison.Ordinal); AssertHasNoDecorativeTrim(screen); @@ -267,6 +262,8 @@ await harness.WaitUntilAsync(() => Assert.Contains("Patterns: dotnet", screen, StringComparison.Ordinal); Assert.Contains("Verbs: dotnet", screen, StringComparison.Ordinal); + Assert.Contains("Requester: Netclaw", screen, StringComparison.Ordinal); + Assert.Contains("Action: Run shell_execute", screen, StringComparison.Ordinal); Assert.Contains("Complex command", screen, StringComparison.Ordinal); Assert.Contains("third-party context", screen, StringComparison.Ordinal); await harness.StopAsync(runTask); @@ -312,7 +309,7 @@ public async Task Generation_HidesTheComposerAndClearsFocus() harness.ViewModel.IsGenerating.Value = true; harness.ViewModel.StatusMessage.Value = "Generating..."; await harness.WaitUntilAsync(() => - harness.Terminal.Contains("Working…") + harness.Terminal.Contains("Working") && !harness.Terminal.Contains("MESSAGE") && harness.Focus.CurrentFocus is null); @@ -335,8 +332,9 @@ public async Task StableTranscript_UsesPrimaryScrollbackWithoutAnOuterBorder() var runTask = harness.StartAsync(); await harness.WaitUntilAsync(() => harness.Terminal.Contains("A stable answer")); - Assert.Equal(" Netclaw", harness.Terminal.GetLine(0)); - Assert.Equal(" A stable answer", harness.Terminal.GetLine(1)); + Assert.Equal(string.Empty, harness.Terminal.GetLine(0)); + Assert.Equal(" Netclaw", harness.Terminal.GetLine(1)); + Assert.Equal(" A stable answer", harness.Terminal.GetLine(2)); AssertHasNoDecorativeTrim(harness.Terminal.ToString()); await harness.StopAsync(runTask); } @@ -473,7 +471,7 @@ public async Task SubagentApproval_ShowsRequesterPathAndCapsTheGateWidth() await using var harness = CreateHarness(outputs: [output], approval: approval, width: 160); var runTask = harness.StartAsync(); await harness.WaitUntilAsync(() => - harness.Terminal.Contains("interface-reviewer / shell_execute")); + harness.Terminal.Contains("interface-reviewer requests permission to run shell_execute")); var longestLine = Enumerable.Range(0, harness.Terminal.Height) .Select(index => harness.Terminal.GetLine(index).TrimEnd().Length) @@ -541,6 +539,30 @@ await harness.WaitUntilAsync(() => await harness.StopAsync(runTask); } + [Fact] + public async Task Inspector_OwnsTheVisibleViewport() + { + var output = new TextOutput("viewport proof") + { + SessionId = SessionId, + TimestampMs = 1 + }; + await using var harness = CreateHarness(outputs: [output], width: 120, height: 24); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("viewport proof")); + + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("INSPECTOR") && harness.Terminal.Contains("Up/Down event")); + + var firstVisibleLine = Enumerable.Range(0, harness.Terminal.Height) + .Select(harness.Terminal.GetLine) + .First(line => !string.IsNullOrWhiteSpace(line)); + Assert.StartsWith(" INSPECTOR", firstVisibleLine, StringComparison.Ordinal); + Assert.Contains("Up/Down event", harness.Terminal.ToString(), StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + [Fact] public async Task Inspector_WrapsAssistantProseAtWordBoundaries() { @@ -710,6 +732,11 @@ public async Task CommonWidths_KeepTheComposerAndStatusVisible(int width) Assert.Contains("NETCLAW", screen, StringComparison.Ordinal); Assert.Contains("MESSAGE", screen, StringComparison.Ordinal); Assert.Contains("Enter", screen, StringComparison.Ordinal); + var header = Enumerable.Range(0, harness.Terminal.Height) + .Select(harness.Terminal.GetLine) + .First(line => line.Contains("NETCLAW", StringComparison.Ordinal)); + if (width >= 60) + Assert.StartsWith(" NETCLAW", header, StringComparison.Ordinal); AssertHasNoDecorativeTrim(screen); await harness.StopAsync(runTask); } diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index dc460c9c8..1d323c1f6 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -284,7 +284,8 @@ private ILayoutNode BuildLiveRegion() else if (ShowsComposer(_state)) content.WithChild(BuildComposer()); - return content.WithChild(BuildStatusLine()); + content.WithChild(BuildStatusLine()); + return WithViewportMargin(content); } private ILayoutNode BuildInspector() @@ -331,7 +332,7 @@ private ILayoutNode BuildInspector() "Y copy event Shift+Y copy turn") .WithForeground(ChatVisualTheme.Muted)); - var inspectorHeight = Math.Clamp(_terminal.Height - 14, 8, 32); + var inspectorHeight = Math.Max(6, _terminal.Height - 3); var detailPanel = new PanelNode() .WithBorder(BorderStyle.None) .WithBackground(ChatVisualTheme.Surface) @@ -359,14 +360,16 @@ private ILayoutNode BuildInspector() .WithBorder(BorderStyle.None) .WithBackground(ChatVisualTheme.HeaderSurface) .WithContent(new TextNode( - $" INSPECTOR {_state.SessionTitle ?? "current turn"} event {_inspectorIndex + 1} of {_state.Transcript.Count}") + $"INSPECTOR {_state.SessionTitle ?? "current turn"} event {_inspectorIndex + 1} of {_state.Transcript.Count}") .WithForeground(ChatVisualTheme.Primary) .Bold()) .Height(1); - return Layouts.Vertical() + var inspector = Layouts.Vertical() .WithChild(inspectorHeader) .WithChild(inspectorBody) - .WithChild(new TextNode(help).WithForeground(ChatVisualTheme.Muted)); + .WithChild(new TextNode(help).WithForeground(ChatVisualTheme.Muted)) + .Width(ReadableWidth()); + return WithViewportMargin(inspector); } private ILayoutNode BuildInspectorEventList(int width, int height) @@ -527,8 +530,8 @@ private ILayoutNode BuildSessionHeader() ? $" {connectionPart}" : string.Empty; var headerText = _terminal.Width < 60 - ? $" NETCLAW {connectionPart}" - : $" NETCLAW {sessionPart}{modelPart}{contextPart}{daemonPart}"; + ? $"NETCLAW {connectionPart}" + : $"NETCLAW {sessionPart}{modelPart}{contextPart}{daemonPart}"; var header = new TextNode(headerText) .WithForeground(ChatVisualTheme.Primary) .Bold(); @@ -562,15 +565,25 @@ private ILayoutNode BuildActivityDeck() .ThenBy(value => value.CallId, StringComparer.Ordinal)) { var childRuns = agents.Where(value => value.ParentCallId == tool.CallId).ToList(); - var phase = childRuns.Count switch - { - 0 => tool.Phase, - 1 => $"orchestrating {childRuns[0].AgentName}", - _ => $"orchestrating {childRuns.Count} agents" - }; + var awaitsApproval = string.Equals( + _state.PendingApproval?.CallId.Value, + tool.CallId, + StringComparison.Ordinal); + var phase = awaitsApproval + ? "awaiting approval" + : childRuns.Count switch + { + 0 => tool.Phase, + 1 => $"orchestrating {childRuns[0].AgentName}", + _ => $"orchestrating {childRuns.Count} agents" + }; + var state = awaitsApproval ? "Decision" : ActivityState(tool.Phase); + var phaseText = !awaitsApproval && string.Equals(phase, tool.Phase, StringComparison.OrdinalIgnoreCase) + ? string.Empty + : $" {phase}"; var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; rows.Add(new TextNode(ChatPresentationRenderer.OneLine( - $"{ActivityState(tool.Phase),-8} Tool {tool.ToolName} {phase}{summary}", + $"{state,-8} Tool {tool.ToolName}{phaseText}{summary}", lineWidth)) .WithForeground(ActivityColor(tool.Phase))); @@ -631,7 +644,7 @@ private ILayoutNode BuildDecisionGate(ToolInteractionRequest approval) .WithBorder(BorderStyle.None) .WithBackground(ChatVisualTheme.ApprovalHeader) .WithContent(new TextNode( - $" Approval required {ChatPresentationRenderer.ApprovalPath(_state, approval)}") + $"Approval required {ChatPresentationRenderer.ApprovalPath(_state, approval)}") .WithForeground(ChatVisualTheme.Warning) .Bold()) .Height(1); @@ -733,10 +746,12 @@ private ILayoutNode BuildStatusLine() var status = ViewModel.StatusMessage.Value; var displayStatus = status switch { - "Generating..." => "Working…", + "Generating..." => "Working", "Connecting..." when _terminal.Width < 48 => "Connect", _ => status }; + if (_state.PendingApproval is not null) + displayStatus = "Decision needed"; var keys = StatusKeys( _terminal.Width, _state.PendingApproval is not null, @@ -758,7 +773,23 @@ private ILayoutNode BuildStatusLine() .WithChild(Layouts.Empty().Fill()); } - private int ReadableWidth() => Math.Min(_terminal.Width, MaximumReadableWidth); + private int ReadableWidth() => Math.Min( + Math.Max(1, _terminal.Width - ViewportLeftMargin()), + MaximumReadableWidth); + + private int ViewportLeftMargin() => _terminal.Width >= 60 ? 2 : 0; + + private ILayoutNode WithViewportMargin(ILayoutNode content) + { + var margin = ViewportLeftMargin(); + if (margin == 0) + return content; + + return Layouts.Horizontal() + .WithChild(Layouts.Empty().Width(margin)) + .WithChild(content) + .WithChild(Layouts.Empty().Fill()); + } private static ILayoutNode BuildAgentActivity(SubAgentActivityPresentation run, int lineWidth) { @@ -783,9 +814,14 @@ private static ILayoutNode BuildAgentActivity(SubAgentActivityPresentation run, return Layouts.Vertical([.. rows]); } - private static string BuildApprovalDetail(ToolInteractionRequest approval) + private string BuildApprovalDetail(ToolInteractionRequest approval) { - var lines = new List { approval.DisplayText }; + var lines = new List + { + $"Requester: {ChatPresentationRenderer.ApprovalRequester(_state, approval)}", + $"Action: Run {approval.ToolName.Value}", + approval.DisplayText + }; if (approval.Patterns.Count > 0) lines.Add($"Patterns: {string.Join(", ", approval.Patterns)}"); if (approval.CandidateVerbs.Count > 0) @@ -805,7 +841,7 @@ private static string BuildApprovalDetail(ToolInteractionRequest approval) ChatViewModel.MaxExpandedApprovalBodyChars); } - private static int ApprovalDetailHeight(ToolInteractionRequest approval, int width) + private int ApprovalDetailHeight(ToolInteractionRequest approval, int width) { var contentWidth = Math.Max(1, width - 2); var lineCount = BuildApprovalDetail(approval) @@ -833,25 +869,21 @@ private static string StatusKeys( if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unavailable) { if (width >= 110) - return "Enter send Shift+Enter unavailable Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; + return "Enter send Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; return width >= 66 - ? "Enter send Shift+Enter unavailable Esc Esc clear" - : width >= 48 - ? "Enter send Shift+Enter unavailable" - : "Shift+Enter unavailable"; + ? "Enter send Esc Esc clear" + : "Enter send"; } if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unknown) { if (width >= 110) - return "Enter send Shift+Enter status pending Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; + return "Enter send Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; return width >= 66 - ? "Enter send Shift+Enter pending Esc Esc clear" - : width >= 48 - ? "Enter send Shift+Enter pending" - : "Shift+Enter pending"; + ? "Enter send Esc Esc clear" + : "Enter send"; } if (width >= 110) @@ -1023,7 +1055,10 @@ public static ILayoutNode BuildStableBlock(ChatPresentationBlock block, int widt .WithChild(Layouts.Empty().Fill()); } - var stableBlock = Layouts.Vertical() + var stableBlock = Layouts.Vertical(); + if (block.Kind == ChatBlockKind.Assistant) + stableBlock.WithChild(Layouts.Empty().Height(1)); + stableBlock .WithChild(heading) .WithChild(content) .WithChild(new TextNode(string.Empty)); @@ -1131,18 +1166,26 @@ public static string CompactIdentity(string identity, int maximumLength) => public static string ApprovalPath( ChatPresentationState state, ToolInteractionRequest approval) + { + var requester = ApprovalRequester(state, approval); + return $"{requester} requests permission to run {approval.ToolName.Value}"; + } + + public static string ApprovalRequester( + ChatPresentationState state, + ToolInteractionRequest approval) { const string subAgentMarker = "/subagent-approval/"; var markerIndex = approval.CallId.Value.IndexOf(subAgentMarker, StringComparison.Ordinal); if (markerIndex <= 0) - return approval.ToolName.Value; + return "Netclaw"; var parentCallId = approval.CallId.Value[..markerIndex]; var requester = state.SubAgents.Values.FirstOrDefault(run => string.Equals(run.ParentCallId, parentCallId, StringComparison.Ordinal)); return requester is null - ? $"sub-agent / {approval.ToolName.Value}" - : $"{requester.AgentName} / {approval.ToolName.Value}"; + ? "A sub-agent" + : requester.AgentName; } private static Color LabelColor(ChatPresentationBlock block) => block.Kind switch From 2cdac30594d52e9a87079038694240ffbc1607f6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 06:15:42 -0500 Subject: [PATCH 13/39] State the final chat viewport rules --- docs/ui/TUI-002-chat-visual-grammar.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/ui/TUI-002-chat-visual-grammar.md b/docs/ui/TUI-002-chat-visual-grammar.md index 4587760d6..baf1c4ada 100644 --- a/docs/ui/TUI-002-chat-visual-grammar.md +++ b/docs/ui/TUI-002-chat-visual-grammar.md @@ -90,6 +90,7 @@ Raw thought text remains available in the Inspector when policy allows it. ### Approval An approval uses an amber cell background at the top of the Decision Gate. +The header names the requester and the requested action. The gate has no border glyphs. The command uses a separate code surface. Each decision uses a text label with an inverse selected state. @@ -103,6 +104,8 @@ The hint row lists only controls that work in the current terminal. ### Inspector +The Inspector owns the visible viewport while it is open. +The transcript returns when the user closes the Inspector. The Inspector uses two background surfaces at wide widths. The left surface lists semantic events. The right surface shows complete safe detail. From c2e1aaaebc8f6d948295592ed2d65e4aa53b2456 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 06:21:58 -0500 Subject: [PATCH 14/39] Assert the Inspector wrap invariant --- src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index f26a7e767..12095d749 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -580,7 +580,11 @@ public async Task Inspector_WrapsAssistantProseAtWordBoundaries() await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); var screen = harness.Terminal.ToString(); - Assert.Contains("every\nexpandable", screen, StringComparison.Ordinal); + Assert.Contains("expandable", screen, StringComparison.Ordinal); + Assert.DoesNotContain( + "Accessibility reviewers verify every expandable control before release.", + screen, + StringComparison.Ordinal); Assert.DoesNotContain("expanda\nble", screen, StringComparison.Ordinal); await harness.StopAsync(runTask); } From 3267f4185ff2bd5a2a153ec5f51eee2f01b8f37f Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 06:33:11 -0500 Subject: [PATCH 15/39] Stabilize the Inspector frame proof --- .../Tui/InlineChatPageTests.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index 12095d749..ddc7ebc88 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -540,7 +540,7 @@ await harness.WaitUntilAsync(() => } [Fact] - public async Task Inspector_OwnsTheVisibleViewport() + public async Task Inspector_KeepsItsHeaderAndFooterInTheSameFrame() { var output = new TextOutput("viewport proof") { @@ -552,14 +552,17 @@ public async Task Inspector_OwnsTheVisibleViewport() await harness.WaitUntilAsync(() => harness.Terminal.Contains("viewport proof")); harness.Input.EnqueueKey(ConsoleKey.O, control: true); + var screen = string.Empty; await harness.WaitUntilAsync(() => - harness.Terminal.Contains("INSPECTOR") && harness.Terminal.Contains("Up/Down event")); + { + screen = harness.Terminal.ToString(); + return screen.Contains("INSPECTOR", StringComparison.Ordinal) + && screen.Contains("Up/Down event", StringComparison.Ordinal); + }); - var firstVisibleLine = Enumerable.Range(0, harness.Terminal.Height) - .Select(harness.Terminal.GetLine) - .First(line => !string.IsNullOrWhiteSpace(line)); - Assert.StartsWith(" INSPECTOR", firstVisibleLine, StringComparison.Ordinal); - Assert.Contains("Up/Down event", harness.Terminal.ToString(), StringComparison.Ordinal); + Assert.Contains(" INSPECTOR", screen, StringComparison.Ordinal); + Assert.Contains("TURN EVENTS", screen, StringComparison.Ordinal); + Assert.Contains("Up/Down event", screen, StringComparison.Ordinal); await harness.StopAsync(runTask); } From 14f2cd1b81be18905c8133489c9a1c1fcb369d82 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 08:54:39 -0500 Subject: [PATCH 16/39] fix(chat): keep the composer active and show reply deltas --- docs/prd/PRD-004-cli-onboarding-and-config.md | 2 + docs/spec/SPEC-004-cli-contract.md | 3 + .../Tui/InlineChatPageTests.cs | 63 ++++++++++++++++--- src/Netclaw.Cli/Tui/InlineChatPage.cs | 47 ++++++++++---- 4 files changed, 96 insertions(+), 19 deletions(-) diff --git a/docs/prd/PRD-004-cli-onboarding-and-config.md b/docs/prd/PRD-004-cli-onboarding-and-config.md index 5d2b338a2..c6c457fd4 100644 --- a/docs/prd/PRD-004-cli-onboarding-and-config.md +++ b/docs/prd/PRD-004-cli-onboarding-and-config.md @@ -322,6 +322,8 @@ interactive TUI for agent conversations. The TUI SHALL: - Keep the settled transcript borderless in native terminal scrollback - Show model name, token usage, and context percentage in the Session Header - Use bare `Enter` to submit and `Shift+Enter` to add a newline +- Keep the Composer active while the agent works and send later prompts to the session queue +- Show assistant text as each streaming delta arrives - Restore a saved draft after prompt history reaches its newest entry - Clear prompt text only after two Escape keys inside a `TimeProvider` window - Give a pending approval input priority and preserve `Ctrl+O` detail expansion diff --git a/docs/spec/SPEC-004-cli-contract.md b/docs/spec/SPEC-004-cli-contract.md index d3cbf2b72..094e725a4 100644 --- a/docs/spec/SPEC-004-cli-contract.md +++ b/docs/spec/SPEC-004-cli-contract.md @@ -111,6 +111,9 @@ Setup, config, provider, model, and session picker applications retain `FullScreen` presentation. The chat composer uses bare `Enter` for submit and `Shift+Enter` for a newline. +A model call does not disable or hide the composer. Later prompts enter the +session actor queue while the current turn runs. The live region shows assistant +text as each stream delta arrives. A pending approval owns input before the composer. `Ctrl+O` changes approval detail without a decision. diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index ddc7ebc88..1b5c82c6b 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -299,7 +299,7 @@ public async Task ApprovalDetail_PreservesSelectionAndScrollPositionAcrossCollap } [Fact] - public async Task Generation_HidesTheComposerAndClearsFocus() + public async Task Generation_KeepsTheComposerFocusedAndAcceptsAQueuedPrompt() { await using var harness = CreateHarness(); var runTask = harness.StartAsync(); @@ -310,13 +310,62 @@ public async Task Generation_HidesTheComposerAndClearsFocus() harness.ViewModel.StatusMessage.Value = "Generating..."; await harness.WaitUntilAsync(() => harness.Terminal.Contains("Working") - && !harness.Terminal.Contains("MESSAGE") - && harness.Focus.CurrentFocus is null); + && harness.Terminal.Contains("MESSAGE") + && harness.Focus.CurrentFocus is not null); + + harness.Input.EnqueueString("queue this next"); + harness.Input.EnqueueKey(ConsoleKey.Enter); + + Assert.Equal("queue this next", + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task AssistantText_UpdatesAsEachStreamDeltaArrives() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + + harness.ViewModel.Emit(new TextDeltaOutput("The first") + { + SessionId = SessionId, + TimestampMs = 1 + }); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("The first")); + + harness.ViewModel.Emit(new TextDeltaOutput(" streamed reply") + { + SessionId = SessionId, + TimestampMs = 2 + }); + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("The first streamed reply") + && harness.Terminal.Contains("MESSAGE")); + + var screen = harness.Terminal.ToString(); + Assert.Contains("NETCLAW LIVE", screen, StringComparison.Ordinal); + Assert.Contains("MESSAGE", screen, StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task LongAssistantStream_KeepsTheComposerVisible() + { + await using var harness = CreateHarness(height: 20); + var runTask = harness.StartAsync(); + + harness.ViewModel.Emit(new TextDeltaOutput(string.Join( + '\n', + Enumerable.Range(1, 30).Select(index => $"stream line {index}"))) + { + SessionId = SessionId, + TimestampMs = 1 + }); - harness.ViewModel.IsGenerating.Value = false; - harness.ViewModel.StatusMessage.Value = "Ready"; await harness.WaitUntilAsync(() => - harness.Terminal.Contains("MESSAGE") && harness.Focus.CurrentFocus is not null); + harness.Terminal.Contains("stream line 30") + && harness.Terminal.Contains("MESSAGE")); await harness.StopAsync(runTask); } @@ -402,7 +451,7 @@ await harness.WaitUntilAsync(() => Assert.Contains("Tool search", screen, StringComparison.Ordinal); Assert.Contains("Tool fetch", screen, StringComparison.Ordinal); Assert.Contains("Agent reviewer", screen, StringComparison.Ordinal); - Assert.DoesNotContain("MESSAGE", screen, StringComparison.Ordinal); + Assert.Contains("MESSAGE", screen, StringComparison.Ordinal); await harness.StopAsync(runTask); } diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 1d323c1f6..329e53152 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -39,6 +39,7 @@ public sealed class InlineChatPage : ReactivePage private DynamicLayoutNode _liveRegion = null!; private SelectionListNode? _approvalList; private ScrollableContainerNode? _approvalDetail; + private ScrollableContainerNode? _assistantStream; private CopyableTextNode? _inspectorCopyNode; private ScrollableContainerNode? _inspectorDetail; private string? _approvalCallId; @@ -100,11 +101,9 @@ protected override void OnBound() .Subscribe(_ => _liveRegion.Invalidate()) .DisposeWith(Subscriptions); ViewModel.IsGenerating - .Subscribe(isGenerating => + .Subscribe(_ => { - if (isGenerating) - Focus.ClearFocus(); - else if (ShowsComposer(_state)) + if (ShowsComposer(_state)) Focus.SetFocus(_promptInput); _liveRegion.Invalidate(); }) @@ -277,7 +276,8 @@ private ILayoutNode BuildLiveRegion() content.WithChild(Layouts.Empty().Height(1)); content .WithChild(BuildSessionHeader()) - .WithChild(BuildActivityDeck()); + .WithChild(BuildActivityDeck()) + .WithChild(BuildStreamingAssistant()); if (_state.PendingApproval is not null) content.WithChild(BuildDecisionGate(_state.PendingApproval)); @@ -616,6 +616,35 @@ private ILayoutNode BuildActivityDeck() .Width(ReadableWidth()); } + private ILayoutNode BuildStreamingAssistant() + { + if (string.IsNullOrEmpty(_state.AssistantText)) + return Layouts.Empty(); + + var maximumHeight = Math.Max(4, Math.Min(14, _terminal.Height / 3)); + _assistantStream ??= new ScrollableContainerNode() + .WithAutoScroll(AutoScrollPolicy.AlwaysTail) + .WithScrollbar(false); + _assistantStream.WithContent( + new TextNode(ChatPresentationRenderer.MarkdownToPlainText( + _state.AssistantText)) + .WithForeground(ChatVisualTheme.Text)); + var content = Layouts.Vertical() + .WithChild(new TextNode("NETCLAW LIVE") + .WithForeground(ChatVisualTheme.Primary) + .Bold()) + .WithChild(_assistantStream.HeightAuto( + min: 1, + max: Math.Max(1, maximumHeight - 3))); + return new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.Surface) + .WithPadding(1) + .WithContent(content) + .Width(ReadableWidth()) + .HeightAuto(min: 4, max: maximumHeight); + } + private ILayoutNode BuildComposer() { var content = Layouts.Vertical() @@ -1009,13 +1038,7 @@ _ when status.StartsWith("Connection failed", StringComparison.Ordinal) => ChatV }; private bool ShowsComposer(ChatPresentationState state) => - state.PendingApproval is null - && !ViewModel.IsGenerating.Value - && !state.IsProcessing - && state.Tools.Count == 0 - && state.SubAgents.Count == 0 - && string.IsNullOrWhiteSpace(state.ThoughtText) - && string.IsNullOrWhiteSpace(state.AssistantText); + state.PendingApproval is null; } internal static partial class ChatPresentationRenderer From c894e906f2dcb06635f41b6838399e36aaa7b977 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 08:54:43 -0500 Subject: [PATCH 17/39] docs(chat): add the Quiet Reply Stack review site --- docs/ui/README.md | 2 + docs/ui/TUI-002-chat-visual-grammar.md | 342 ++++++--- docs/ui/chat-reply-stack-v2/index.html | 936 +++++++++++++++++++++++++ 3 files changed, 1202 insertions(+), 78 deletions(-) create mode 100644 docs/ui/chat-reply-stack-v2/index.html diff --git a/docs/ui/README.md b/docs/ui/README.md index bca114e02..7e0bf311a 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -8,6 +8,8 @@ This directory contains management UI planning artifacts for Netclaw. component behavior - `TUI-001-command-wireframes.md` - Termina TUI wireframes for `netclaw init`, `netclaw chat`, and plain CLI commands +- `chat-reply-stack-v2/index.html` - interactive Netclaw chat hierarchy, + state, motion, and responsive mock-ups - `ops-console-v1.html` - static high-fidelity mockup for visual direction ## Design Intent diff --git a/docs/ui/TUI-002-chat-visual-grammar.md b/docs/ui/TUI-002-chat-visual-grammar.md index baf1c4ada..2c500527f 100644 --- a/docs/ui/TUI-002-chat-visual-grammar.md +++ b/docs/ui/TUI-002-chat-visual-grammar.md @@ -2,29 +2,62 @@ Source PRDs: `PRD-004`, `PRD-009` -## Design Intent - -Netclaw chat uses a quiet console grammar. -The design gives content priority over application chrome. -Color, whitespace, weight, and cell background create the hierarchy. -Decorative glyph borders do not create the hierarchy. +Revised: 2026-08-12 -This grammar has four goals: +## Design Intent -- Make each turn easy to scan. -- Keep parallel work easy to follow. -- Keep approval context visible and calm. -- Keep native terminal selection clean. +Netclaw chat uses a quiet conversation grammar. +The design gives prose priority over execution detail. +One user prompt and one Netclaw reply form one Turn. +Each event in that exchange belongs to the same Turn. + +This grammar has five goals: + +- Make each Turn easy to scan. +- Show useful motion before the first text delta. +- Keep tools inside the Netclaw Reply Block. +- Keep the Composer available while Netclaw works. +- Keep terminal selection free of decorative trim. + +## Hierarchy + +The interface uses five visual levels. + +| Level | Content | Rule | +|-------|---------|------| +| 1 | User prompt and Netclaw prose | Give this content the strongest contrast and widest measure. | +| 2 | Live work and decisions | Nest this content inside the current Reply Block. | +| 3 | Tool receipts and files | Use compact rows under the prose that caused them. | +| 4 | Time, model, tokens, and identity | Use muted text and remove it first at narrow widths. | +| 5 | Key hints | Keep this content on the Pulse Line. | + +No tool, approval, or parallel group can appear as a peer of the Reply Block. +The Reply Block is the unit of comprehension, settlement, inspection, and copy. + +## Named Regions + +| Region | Purpose | Lifetime | +|--------|---------|----------| +| Session Strip | Shows the session, model, context, and connection | Live | +| Transcript | Holds immutable settled Turns | Terminal scrollback | +| Turn | Groups one user prompt and one Reply Block | Settled after the reply ends | +| Reply Block | Owns Netclaw prose and all work for one Turn | Live, then immutable | +| Work Trace | Shows transient thought, tool, and sub-agent activity | Nested in the Reply Block | +| Decision Sheet | Owns one approval request and its choices | Nested in the Reply Block | +| Queue Shelf | Shows prompts that wait behind the current Turn | Live | +| Composer | Accepts the next prompt | Live, except during a decision | +| Pulse Line | Shows `Thinking.` state and valid keys | Live | +| Inspector | Shows complete safe detail for one Turn or event | Temporary viewport | ## Selection Rule -The settled transcript contains no corner, border, rail, or divider glyphs. +The settled Transcript contains no corner, border, rail, or divider glyphs. The live region also avoids these glyphs where practical. -Cell background can create a visual surface because terminal selection does not copy its color. +Cell background can define a surface because terminal selection does not copy color. -Visible text must carry useful meaning when a user selects it. -Labels such as `Tool`, `Agent`, `Approval`, and `Failed` are semantic content. -Spinners, tree rails, corner glyphs, and ornamental rules are not semantic content. +Visible text must have semantic value when the user selects it. +The Transcript omits spinners, selection markers, hint text, and elapsed-time frames. +The Inspector provides semantic copy without ANSI bytes or decorative text. ## Visual Tokens @@ -34,101 +67,254 @@ The application does not require one fixed theme. | Role | Purpose | |------|---------| | Canvas | The normal terminal background | -| Surface | A quiet cell background for a prompt or activity group | -| Surface strong | The selected event or the Composer | +| Human surface | The user prompt and queued prompts | +| Reply surface | The current Netclaw Reply Block | +| Work surface | A nested Work Trace or Decision Sheet | | Primary | Netclaw identity and active controls | | Human | User identity and user prompts | -| Success | Completed work | -| Warning | Thoughts and approval context | -| Danger | Failures and denied actions | -| Muted | Time, metadata, and inactive controls | +| Success | A useful completed result | +| Warning | A decision or degraded result | +| Danger | A failure or denial | +| Muted | Time, metadata, receipts, and key hints | -The design uses bold text only for identity, state, and a selected action. -The design uses dim text for metadata and shortcut hints. +Bold text identifies a speaker, an active state, or the selected decision. +The design uses no more than three emphasis colors in one region. ## Spacing Rhythm - The viewport uses a two-cell left margin at 60 columns or more. -- A turn uses one blank line before each speaker. -- A speaker label and message share a compact group. -- An activity group uses one blank line before and after the group. -- A completed tool group collapses to one summary row. -- The Composer uses two text rows plus one hint row. +- A Turn uses one blank line before the user prompt. +- The Reply Block follows its prompt without a large vertical break. +- Reply prose uses a two-cell indent. +- Work Trace rows use a four-cell indent. +- Child tools and sub-agents use a six-cell indent. +- The Composer uses two text rows plus the Pulse Line. +- A settled Turn uses one blank line before the next Turn. + +## Reply Block Grammar + +The Reply Block starts when the session accepts a user prompt. +The block stays live until the Turn ends. +Assistant deltas extend prose inside the same block. + +The Work Trace uses short verb-first descriptions. +It does not expose raw JSON in the Transcript. + +Examples: + +```text + Thinking about the deployment layout + ⠹ Search deployment settings + shell_execute · grep context window + ✓ Read the session manifest · 0.2s + ! Protected config blocked the request +``` + +The live spinner replaces its prior frame in place. +The tool name is secondary detail. +The description explains the current action. +The row can show safe fly-by text after the action. + +The settled Reply Block collapses successful work into receipts. +Failures remain visible because they can change the reply meaning. + +```text + ✓ Inspected deployment settings · 3 tools · 1.7s + ! Protected config prevented one check +``` + +The Inspector retains each call identity, argument, result, duration, and parent relation. + +## Composer and Queue Grammar + +The Composer stays visible while the model or a tool works. +Enter sends a later prompt to the session queue. +The Queue Shelf shows each accepted prompt above the Composer. + +The Queue Shelf does not interrupt the current Reply Block. +The prompt enters the next Turn after the current Turn ends. + +A Decision Sheet is the only state that hides the Composer. +This exception prevents prompt text from reaching an approval control. + +## Pulse Grammar + +The bottom-left Pulse Line shows model wait state with this exact sequence: + +```text +Thinking. → Thinking.. → Thinking... +``` + +The pulse continues until text, work, a decision, an error, or completion changes the state. +The right side shows only keys that work in the current state. + +The Pulse Line uses these state words: + +| State | Text | +|-------|------| +| Model wait or text stream | `Thinking.` pulse | +| Tool or sub-agent work | `Working.` pulse | +| Queued prompt accepted | `Queued 1` | +| Approval | `Decision needed` | +| Idle | `Ready` | +| Connection loss | `Disconnected` | + +## ASCII Mockup: Live Reply with a Queued Prompt + +```text +NETCLAW Casual Greeting Exchange deepseek-v4-flash-dspark 18% connected -## Region Grammar +You 13:35 + Find the configured context window. -### Session Bar +Netclaw 13:35 LIVE + I will inspect the deployment settings and the active session. -The Session Bar uses one row. -It shows the product, session, model, context, and connection state. -It uses a cell background or underline style without divider glyphs. + ⠹ Search deployment settings + shell_execute · grep context and model values -### Turn + Parallel work · 2 calls + ✓ Read session manifest · 0.2s + ⠋ Inspect model configuration · file_list -The user prompt uses a quiet surface background. -The Netclaw response uses the canvas background. -The user label and time appear above the prompt. -The Netclaw label and time appear above the response. +Queued 1 + Then tell me which setting wins. -### Activity Group +MESSAGE + Ask Netclaw... -An active group uses one quiet surface background. -Each row uses a semantic type label and a state word. -Indentation shows ownership without tree-rail glyphs. -Parallel rows keep a stable order. +Thinking.. Enter send Shift+Enter newline Esc Esc clear Ctrl+O inspect +``` -A settled group collapses to one receipt row: +The user sees prose first. +The tool rows explain intent and stay inside the Netclaw Reply Block. +The Composer remains available during the active Turn. -`Tools 3 complete · 2.8s · Ctrl+O for details` +## ASCII Mockup: Settled Turn -### Thought +```text +You 13:35 + Find the configured context window. -An active thought uses one short warning-colored line. -A settled thought collapses to a token and duration receipt. -Raw thought text remains available in the Inspector when policy allows it. +Netclaw 13:35 + I inspected the deployment settings and the active session. -### Approval + ✓ Inspected configuration · 3 tools · 1.7s + ! One protected path blocked direct access -An approval uses an amber cell background at the top of the Decision Gate. -The header names the requester and the requested action. -The gate has no border glyphs. -The command uses a separate code surface. -Each decision uses a text label with an inverse selected state. + The main model uses a 131,072-token context window. + The named model definition overrides the provider default. -### Composer +MESSAGE + Ask Netclaw... -The Composer uses a strong surface background and no border glyphs. -The first cell uses a primary background as a focus cue. -The prompt text starts after that cell. -The hint row lists only controls that work in the current terminal. +Ready Enter send Shift+Enter newline Esc Esc clear Ctrl+O inspect +``` -### Inspector +The settled Turn prints as one immutable block. +Individual tool cards do not enter the Transcript. -The Inspector owns the visible viewport while it is open. -The transcript returns when the user closes the Inspector. -The Inspector uses two background surfaces at wide widths. -The left surface lists semantic events. -The right surface shows complete safe detail. -A blank background gutter separates both surfaces. -The Inspector uses no vertical divider glyph. +## ASCII Mockup: Decision Sheet + +```text +NETCLAW Casual Greeting Exchange deepseek-v4-flash-dspark 18% connected + +You 13:35 + Find the configured context window. + +Netclaw 13:35 WAITING + I need permission to inspect a protected session path. + + Approval required + Requester Netclaw + Action Run shell_execute + Target grep context values in the current session + Scope this exact command in this session directory + + 1. Allow once + 2. Allow for this chat + 3. Deny + +Decision needed Up/Down select Enter confirm Ctrl+O details Esc deny +``` + +The Decision Sheet stays inside the current Reply Block. +The Sheet hides the Composer until the user makes a decision. + +## ASCII Mockup: Narrow Form at 48 Columns + +```text +NETCLAW Casual Greeting 18% + +You 13:35 + Find the context window. + +Netclaw 13:35 LIVE + I will inspect the settings. + + ⠹ Search deployment settings + shell_execute · grep context + +Queued 1 + Tell me which setting wins. + +MESSAGE + Ask Netclaw... + +Thinking.. Enter send Esc Esc clear +``` + +The narrow form removes the model, connection, duration, and tool kind first. +It keeps the speaker, action, outcome, queue, input, and decision state. + +## State Flow + +```text +Composer --Enter--> Reply Block --Turn ends--> Settled Turn + | | | + | +--tool--> Work Trace +--> next queued Turn + | +--approval--> Decision Sheet + +--Enter while live--> Queue Shelf +``` + +The Reply Block receives all events for the current Turn. +The Composer remains live during model, tool, and sub-agent work. +The Decision Sheet owns input before the Composer. ## Content Rules -- Do not show raw JSON in the main transcript. -- Do not put a complete command on a status row. -- Do not join a tool result to the next agent event. -- Do not repeat a tool name on every progress update. -- Do not show an empty fixed-height transcript panel. -- Do not use uppercase for complete sentences. -- Do not use more than three emphasis colors in one region. +- Use a verb-first action description for each live row. +- Put the tool name after the action or omit it at narrow widths. +- Show safe fly-by text only when it helps the operator predict progress. +- Keep assistant prose at stronger contrast than tool detail. +- Do not show raw JSON in the Transcript. +- Do not give each event a separate header or surface. +- Do not repeat `Tool`, `Approval`, or `Parallel tools` as peer cards. +- Do not put a complete command on the Pulse Line. - Do not truncate a security decision target. +- Keep complete safe detail in the Inspector. + +## Responsive Rules + +| Width | Session Strip | Work Trace | Pulse Line | +|-------|---------------|------------|------------| +| 120+ | session, model, context, connection | action, fly-by text, tool, duration | full key names | +| 80-119 | session, model, context | action, fly-by text, duration | common key names | +| 60-79 | session, context | action and short outcome | compact key names | +| 40-59 | session suffix, context | clipped action and outcome | essential keys only | + +No responsive rule moves an event outside its Reply Block. +Long content remains available through the Inspector and semantic copy. ## Mockup Set +The current SVG files show the first quiet-console concept. +They need revision before they become acceptance targets: + - `mockups/chat-quiet-normal.svg` - `mockups/chat-quiet-active.svg` - `mockups/chat-quiet-approval.svg` - `mockups/chat-quiet-inspector.svg` -These files show the intended hierarchy at 120 columns. -They are design targets, not raster product screenshots. +The ASCII mockups in this document are the current hierarchy authority. +The team will replace the SVG set after review. diff --git a/docs/ui/chat-reply-stack-v2/index.html b/docs/ui/chat-reply-stack-v2/index.html new file mode 100644 index 000000000..9e6c537c7 --- /dev/null +++ b/docs/ui/chat-reply-stack-v2/index.html @@ -0,0 +1,936 @@ + + + + + + Netclaw Chat — Quiet Reply Stack v2 + + + +

+
+
Netclaw chat design review
+

Quiet Reply Stack · visual grammar v2

+
+
+
+ + + +
+
+ + + +
+
+ + +
+
+
+ +
+
+
+
Active reply with a queued prompt
Interactive HTML mock-up · no production code
+
120-column hierarchy
+
+ +
+
+
+ netclaw chat · primary terminal buffer + design target +
+ +
+
+ Session Strip + NETCLAW + Casual Greeting Exchange + deepseek-v4-flash-dspark + 18% + connected +
+ +
+ Transcript +
You13:35
+
+ Prompt Surface + Find the configured context window and explain which setting wins. +
+ +
+ Reply Stack +
+ Netclaw13:35LIVE +
+
I will inspect the deployment settings and the active session.
+ +
+ Work Trace +
Current work2 active · 1 complete
+
+ / + Search deployment settings · shell_execute + 1.2s +
+
Searching session files for context values...
+
Parallel work · 2 calls
+
+ + Read session manifest · file_read + 0.2s +
+
+ / + Inspect model configuration · file_list + 0.8s +
+
+
+
+ +
+ Queue Shelf + Queued · 1 +
Then tell me which setting wins.
+
+ +
+ Composer +
MESSAGE
+
Ask Netclaw...
+
+ +
+ Pulse Line + Thinking. + Enter send   Shift+Enter newline   Esc Esc clear   Ctrl+O inspect   Ctrl+Q quit +
+
+ + + + +
+
+ + +
+ + + + From 5cd081f2edf48d6b934799b4077ecbe2e9d4706e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 08:58:57 -0500 Subject: [PATCH 18/39] docs(chat): stabilize the pulse line width --- docs/ui/TUI-002-chat-visual-grammar.md | 2 ++ docs/ui/chat-reply-stack-v2/index.html | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/ui/TUI-002-chat-visual-grammar.md b/docs/ui/TUI-002-chat-visual-grammar.md index 2c500527f..6d2923a93 100644 --- a/docs/ui/TUI-002-chat-visual-grammar.md +++ b/docs/ui/TUI-002-chat-visual-grammar.md @@ -147,6 +147,8 @@ Thinking. → Thinking.. → Thinking... The pulse continues until text, work, a decision, an error, or completion changes the state. The right side shows only keys that work in the current state. +The pulse reserves a fixed 12-character slot with one character of right padding. +Only the dots change, so the key hints and the complete row remain stationary. The Pulse Line uses these state words: diff --git a/docs/ui/chat-reply-stack-v2/index.html b/docs/ui/chat-reply-stack-v2/index.html index 9e6c537c7..01ca314a2 100644 --- a/docs/ui/chat-reply-stack-v2/index.html +++ b/docs/ui/chat-reply-stack-v2/index.html @@ -340,7 +340,13 @@ overflow: hidden; } - .pulse-line .pulse { min-width: 86px; color: var(--decision); } + .pulse-line .pulse { + flex: 0 0 12ch; + width: 12ch; + padding-right: 1ch; + color: var(--decision); + white-space: pre; + } .pulse-line.ready .pulse { color: var(--human); } .pulse-line.decision .pulse { color: var(--decision); } .pulse-line .keys { color: #687184; } @@ -766,7 +772,7 @@

Motion contract

- + From d4092499fa94890918775965a197f49e72657d1d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 09:00:12 -0500 Subject: [PATCH 19/39] fix(chat): label double Escape as x2 --- docs/ui/TUI-001-command-wireframes.md | 2 +- docs/ui/TUI-002-chat-visual-grammar.md | 6 +++--- docs/ui/chat-reply-stack-v2/index.html | 4 ++-- src/Netclaw.Cli/Tui/InlineChatPage.cs | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/ui/TUI-001-command-wireframes.md b/docs/ui/TUI-001-command-wireframes.md index 5285979eb..34cd41e5f 100644 --- a/docs/ui/TUI-001-command-wireframes.md +++ b/docs/ui/TUI-001-command-wireframes.md @@ -159,7 +159,7 @@ The failure is a deterministic path assertion. I can prepare a fix. ┌ prompt ───────────────────────────────────────────────────────────────────────────────────────────┐ │ Ask Netclaw… │ └──────────────────────────────────────────────────────────────────────────────────────────────────┘ -Enter send Shift+Enter newline ↑↓ history Esc Esc clear Ctrl+Q quit +Enter send Shift+Enter newline ↑↓ history Esc x2 clear Ctrl+Q quit ``` ### Active Turn at 80 Columns diff --git a/docs/ui/TUI-002-chat-visual-grammar.md b/docs/ui/TUI-002-chat-visual-grammar.md index 6d2923a93..7ed9bf635 100644 --- a/docs/ui/TUI-002-chat-visual-grammar.md +++ b/docs/ui/TUI-002-chat-visual-grammar.md @@ -185,7 +185,7 @@ Queued 1 MESSAGE Ask Netclaw... -Thinking.. Enter send Shift+Enter newline Esc Esc clear Ctrl+O inspect +Thinking.. Enter send Shift+Enter newline Esc x2 clear Ctrl+O inspect ``` The user sees prose first. @@ -210,7 +210,7 @@ Netclaw 13:35 MESSAGE Ask Netclaw... -Ready Enter send Shift+Enter newline Esc Esc clear Ctrl+O inspect +Ready Enter send Shift+Enter newline Esc x2 clear Ctrl+O inspect ``` The settled Turn prints as one immutable block. @@ -263,7 +263,7 @@ Queued 1 MESSAGE Ask Netclaw... -Thinking.. Enter send Esc Esc clear +Thinking.. Enter send Esc x2 clear ``` The narrow form removes the model, connection, duration, and tool kind first. diff --git a/docs/ui/chat-reply-stack-v2/index.html b/docs/ui/chat-reply-stack-v2/index.html index 01ca314a2..db7dc2c35 100644 --- a/docs/ui/chat-reply-stack-v2/index.html +++ b/docs/ui/chat-reply-stack-v2/index.html @@ -633,7 +633,7 @@

Quiet Reply Stack · visual grammar v2

Pulse Line Thinking. - Enter send   Shift+Enter newline   Esc Esc clear   Ctrl+O inspect   Ctrl+Q quit + Enter send   Shift+Enter newline   Esc x2 clear   Ctrl+O inspect   Ctrl+Q quit
@@ -733,7 +733,7 @@

Quiet Reply Stack · visual grammar v2

Pulse Line Ready - Enter send   Shift+Enter newline   Esc Esc clear   Ctrl+O inspect   Ctrl+Q quit + Enter send   Shift+Enter newline   Esc x2 clear   Ctrl+O inspect   Ctrl+Q quit
diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 329e53152..b582cde98 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -898,27 +898,27 @@ private static string StatusKeys( if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unavailable) { if (width >= 110) - return "Enter send Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; + return "Enter send Esc x2 clear Ctrl+O inspect Ctrl+Q quit"; return width >= 66 - ? "Enter send Esc Esc clear" + ? "Enter send Esc x2 clear" : "Enter send"; } if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unknown) { if (width >= 110) - return "Enter send Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; + return "Enter send Esc x2 clear Ctrl+O inspect Ctrl+Q quit"; return width >= 66 - ? "Enter send Esc Esc clear" + ? "Enter send Esc x2 clear" : "Enter send"; } if (width >= 110) - return "Enter send Shift+Enter newline Esc Esc clear Ctrl+O inspect Ctrl+Q quit"; + return "Enter send Shift+Enter newline Esc x2 clear Ctrl+O inspect Ctrl+Q quit"; return width >= 66 - ? "Enter send Shift+Enter line Esc Esc clear Ctrl+O inspect" + ? "Enter send Shift+Enter line Esc x2 clear Ctrl+O inspect" : "Enter send Shift+Enter line"; } From c5bbe8cba8ab533abbdda5107a15151b9d09e429 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 09:31:01 -0500 Subject: [PATCH 20/39] feat(chat): surface tool rationales in work trace --- docs/ui/TUI-002-chat-visual-grammar.md | 35 +++- docs/ui/chat-reply-stack-v2/index.html | 171 ++++++++++++++---- evals/run-evals.sh | 13 ++ .../.system/files/netclaw-operations/SKILL.md | 2 +- .../netclaw-operations/references/chat.md | 21 ++- .../Protocol/SerializationRoundTripTests.cs | 12 ++ .../SessionTranscriptEntryFactoryTests.cs | 2 + .../Sessions/ToolExecutionIntegrationTests.cs | 7 +- .../Protocol/SessionOutputDto.cs | 1 + .../Protocol/SessionOutputDtoMapper.cs | 6 +- .../Protocol/SessionTranscriptEntry.cs | 2 + .../Serialization/NetclawProtoMapper.cs | 4 +- .../Protos/netclaw_messages.proto | 1 + .../Sessions/LlmSessionActor.cs | 36 +++- .../Sessions/SessionProtocol.Outputs.cs | 3 + .../Sessions/SessionTranscriptEntryFactory.cs | 1 + .../Sessions/SessionTranscriptExtractor.cs | 2 + .../Tui/ChatPresentationReducerTests.cs | 25 +++ .../Tui/InlineChatPageTests.cs | 22 ++- src/Netclaw.Cli/HeadlessChannel.cs | 6 +- src/Netclaw.Cli/Tui/ChatPresentation.cs | 13 +- src/Netclaw.Cli/Tui/InlineChatPage.cs | 9 +- 22 files changed, 326 insertions(+), 68 deletions(-) diff --git a/docs/ui/TUI-002-chat-visual-grammar.md b/docs/ui/TUI-002-chat-visual-grammar.md index 7ed9bf635..3b49238b5 100644 --- a/docs/ui/TUI-002-chat-visual-grammar.md +++ b/docs/ui/TUI-002-chat-visual-grammar.md @@ -38,10 +38,11 @@ The Reply Block is the unit of comprehension, settlement, inspection, and copy. | Region | Purpose | Lifetime | |--------|---------|----------| -| Session Strip | Shows the session, model, context, and connection | Live | +| Session Strip | Shows the session, model, context, and connection | Persistent bottom dock | | Transcript | Holds immutable settled Turns | Terminal scrollback | | Turn | Groups one user prompt and one Reply Block | Settled after the reply ends | | Reply Block | Owns Netclaw prose and all work for one Turn | Live, then immutable | +| Reply Passage | Groups one model step with its prose and Work Trace | Nested in the Reply Block | | Work Trace | Shows transient thought, tool, and sub-agent activity | Nested in the Reply Block | | Decision Sheet | Owns one approval request and its choices | Nested in the Reply Block | | Queue Shelf | Shows prompts that wait behind the current Turn | Live | @@ -97,7 +98,9 @@ The Reply Block starts when the session accepts a user prompt. The block stays live until the Turn ends. Assistant deltas extend prose inside the same block. -The Work Trace uses short verb-first descriptions. +The Work Trace uses the model-supplied tool rationale as each action title. +The tool name remains secondary metadata. +The client does not infer an action title from tool arguments. It does not expose raw JSON in the Transcript. Examples: @@ -125,6 +128,20 @@ Failures remain visible because they can change the reply meaning. The Inspector retains each call identity, argument, result, duration, and parent relation. +## Chronology Grammar + +One Reply Block can contain multiple Reply Passages. +Each Reply Passage represents one model step in the tool loop. +New model prose starts the next passage without starting a new user Turn. + +A completed call remains visible as a receipt while later calls remain active. +Parallel calls share one group and retain separate lifecycle states. +The final settled Turn replaces transient Work Trace rows with one compact receipt. + +The current session contract preserves order between model steps. +It does not preserve exact text and tool order inside one model response. +That capability requires an additive ordered-segment contract. + ## Composer and Queue Grammar The Composer stays visible while the model or a tool works. @@ -139,6 +156,10 @@ This exception prevents prompt text from reaching an approval control. ## Pulse Grammar +The Session Strip stays in the persistent bottom dock. +It stays beside the Composer or Decision Sheet in that dock. +The Pulse Line remains the bottom row. + The bottom-left Pulse Line shows model wait state with this exact sequence: ```text @@ -164,8 +185,6 @@ The Pulse Line uses these state words: ## ASCII Mockup: Live Reply with a Queued Prompt ```text -NETCLAW Casual Greeting Exchange deepseek-v4-flash-dspark 18% connected - You 13:35 Find the configured context window. @@ -182,6 +201,8 @@ Netclaw 13:35 LIVE Queued 1 Then tell me which setting wins. +NETCLAW Casual Greeting Exchange deepseek-v4-flash-dspark 18% connected + MESSAGE Ask Netclaw... @@ -207,6 +228,8 @@ Netclaw 13:35 The main model uses a 131,072-token context window. The named model definition overrides the provider default. +NETCLAW Casual Greeting Exchange deepseek-v4-flash-dspark 19% connected + MESSAGE Ask Netclaw... @@ -219,14 +242,14 @@ Individual tool cards do not enter the Transcript. ## ASCII Mockup: Decision Sheet ```text -NETCLAW Casual Greeting Exchange deepseek-v4-flash-dspark 18% connected - You 13:35 Find the configured context window. Netclaw 13:35 WAITING I need permission to inspect a protected session path. +NETCLAW Casual Greeting Exchange deepseek-v4-flash-dspark 18% connected + Approval required Requester Netclaw Action Run shell_execute diff --git a/docs/ui/chat-reply-stack-v2/index.html b/docs/ui/chat-reply-stack-v2/index.html index db7dc2c35..504309095 100644 --- a/docs/ui/chat-reply-stack-v2/index.html +++ b/docs/ui/chat-reply-stack-v2/index.html @@ -254,6 +254,8 @@ white-space: pre-wrap; } + .reply-passage + .reply-passage { margin-top: 15px; } + .work-trace { margin: 7px 0 10px; padding: 9px 11px; @@ -540,6 +542,7 @@

Quiet Reply Stack · visual grammar v2

+
@@ -570,15 +573,6 @@

Quiet Reply Stack · visual grammar v2

-
- Session Strip - NETCLAW - Casual Greeting Exchange - deepseek-v4-flash-dspark - 18% - connected -
-
Transcript
You13:35
@@ -624,6 +618,15 @@

Quiet Reply Stack · visual grammar v2

Then tell me which setting wins.
+
+ Session Strip + NETCLAW + Casual Greeting Exchange + deepseek-v4-flash-dspark + 18% + connected +
+
Composer
MESSAGE
@@ -637,16 +640,87 @@

Quiet Reply Stack · visual grammar v2

-
ElementMotionStops when
Pulse LineThinking. dotsDecision, error, or idle
Pulse LineThinking. dots in a fixed 12-character slotDecision, error, or idle
Tool glyph/ - \\ | in placeTool settles
Fly-by textSafe action text replaces in placeTool settles
Composer caretSlow blinkDecision owns input
- + - - - - - - + + + + + +
RegionActiveApprovalSettled
RegionActiveSequenceApprovalSettled
Reply StackLivePausedFrozen
Work TraceExpandedExpandedReceipt
Decision SheetHiddenVisibleHidden
Queue ShelfIf queuedHiddenPromoted
ComposerVisibleHiddenVisible
Pulse animationRunsStoppedReady
Reply StackLiveLivePausedFrozen
Work TraceExpandedPer passageExpandedReceipt
Decision SheetHiddenHiddenVisibleHidden
Queue ShelfIf queuedIf queuedHiddenPromoted
ComposerVisibleVisibleHiddenVisible
Pulse animationRunsRunsStoppedReady
@@ -816,8 +912,9 @@

Reduced color system

Fragment budget

  • One Prompt Surface per user message.
  • -
  • One Reply Stack per Netclaw response.
  • -
  • One Work Trace inside the live Reply Stack.
  • +
  • One Reply Stack per user Turn.
  • +
  • One Reply Passage per model step.
  • +
  • One Work Trace per passage that uses tools.
  • One Composer or one Decision Sheet. Never both.
  • No event-level cards in settled scrollback.
@@ -847,10 +944,11 @@

Turn transition

const replayButton = document.getElementById('replay-button'); const stateNames = { active: 'Active reply with a queued prompt', + sequence: 'Completed and active work across model steps', approval: 'Approval inside the requesting reply', settled: 'One immutable settled Turn' }; - const flowStates = ['active', 'approval', 'settled']; + const flowStates = ['active', 'sequence', 'approval', 'settled']; let currentState = 'active'; let replayTimer = null; let spinnerFrame = 0; @@ -872,6 +970,9 @@

Turn transition

stateTitle.textContent = stateNames[state]; } + const requestedState = new URLSearchParams(window.location.search).get('state'); + if (requestedState && Object.hasOwn(stateNames, requestedState)) setState(requestedState); + stateButtons.forEach(button => button.addEventListener('click', () => { stopReplay(); setState(button.dataset.state); @@ -914,16 +1015,16 @@

Turn transition

}); window.setInterval(() => { - if (currentState !== 'active') return; + if (currentState !== 'active' && currentState !== 'sequence') return; spinnerFrame = (spinnerFrame + 1) % spinnerFrames.length; - document.querySelectorAll('[data-screen="active"] .spinner') + document.querySelectorAll(`[data-screen="${currentState}"] .spinner`) .forEach(element => { element.textContent = spinnerFrames[spinnerFrame]; }); }, 140); window.setInterval(() => { - if (currentState !== 'active') return; + if (currentState !== 'active' && currentState !== 'sequence') return; pulseFrame = (pulseFrame + 1) % pulseFrames.length; - document.querySelectorAll('[data-screen="active"] .thinking-pulse') + document.querySelectorAll(`[data-screen="${currentState}"] .thinking-pulse`) .forEach(element => { element.textContent = pulseFrames[pulseFrame]; }); }, 460); diff --git a/evals/run-evals.sh b/evals/run-evals.sh index c352a5171..9c971ccac 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -1099,6 +1099,15 @@ assert_skill_operations_diagnostics() { stdout_contains '\[tool:call\]' } +assert_skill_chat_tui_knowledge() { + daemon_log_skill_loaded_via_skill_tool 'netclaw-operations' \ + && stdout_tool_called 'skill_read_resource' \ + && stdout_contains 'Queue Shelf' \ + && stdout_contains 'Session Strip' \ + && stdout_contains 'Pulse Line' \ + && stdout_no_skill_file_read_called +} + assert_skill_citation_search() { # Model should actually search when asked to search. stdout_contains '\[tool:call\] web_search' @@ -1914,6 +1923,10 @@ run_all() { "My session seems broken, help me fix it" \ "Debug my Netclaw session" + run_case skill_chat_tui_knowledge "knows the chat queue and bottom dock" \ + "While netclaw chat is busy, can I send another message? Where does it go, and which named regions remain in the bottom dock? Use the exact UI region names." \ + "Explain prompt queue behavior during active Netclaw work. Name the persistent session and wait-state rows at the bottom." + run_case skill_citation_search "performs web search when asked" \ "Search the web for the latest Akka.NET release" \ "Look up the current version of Akka.NET" diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 879116eac..be4534cf4 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.51.0" + version: "2.51.1" --- # Netclaw Operations diff --git a/feeds/skills/.system/files/netclaw-operations/references/chat.md b/feeds/skills/.system/files/netclaw-operations/references/chat.md index add45af16..a714f5a43 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/chat.md +++ b/feeds/skills/.system/files/netclaw-operations/references/chat.md @@ -8,16 +8,29 @@ Use these keys: - `Enter` sends the prompt. - `Shift+Enter` adds a new line. - `Up` and `Down` recall prompts and restore the current draft. -- `Esc Esc` clears the prompt. +- `Esc x2` clears the prompt. - `Ctrl+O` opens the Inspector when chat is idle. - `Y` copies one Inspector event. `Shift+Y` copies its complete turn. - `Ctrl+O` expands or collapses an approval detail view. - `Esc` denies an approval. It also closes the Inspector. - `Ctrl+Q` exits chat. -The Composer disappears while a turn or an approval gate is active. Wait for -the Composer before you enter another prompt. The activity deck shows thought, -tool, parallel call, and subagent state. +The Composer stays available while the model or a tool works. A prompt that you +send during active work enters the Queue Shelf for the next turn. An approval +gate replaces the Composer until the user makes a decision. + +The Session Strip stays in the persistent bottom dock with the Composer or +approval gate. The Pulse Line stays at the bottom and shows the current wait +state. + +The Work Trace keeps tools inside the current Netclaw Reply Block. Each tool +uses the model-supplied rationale as its primary title. The tool name remains +secondary detail. Safe activity summaries can replace fly-by text while a tool +runs. Parallel calls remain separate rows with separate states. + +New model prose after a tool result starts a new Reply Passage in the same user +turn. A completed call remains visible as a compact receipt while later work +continues. The final settled turn replaces transient work with a short receipt. The Inspector shows complete semantic event text. It omits display borders from copy output. A failed copy keeps the event selected and shows a visible error. diff --git a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs index ffe690c81..901123f77 100644 --- a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs @@ -151,6 +151,7 @@ public void TurnRecorded_round_trips_structured_transcript_entries() CallId = "call-1", ToolName = "shell_execute", ArgumentsJson = "{\"command\":\"dotnet test\"}", + Rationale = "Verify the source tree", Result = "Passed", BatchId = "batch-1", BatchSize = 2 @@ -208,6 +209,7 @@ public void Session_output_DTO_round_trips_parallel_and_approval_fields() CallId = new ToolCallId("call-1"), ToolName = new ToolName("search"), ArgumentsJson = "{}", + Rationale = "Find the relevant source", BatchId = "batch-1", BatchSize = 2 }; @@ -215,6 +217,16 @@ public void Session_output_DTO_round_trips_parallel_and_approval_fields() SessionOutputDtoMapper.FromDto(SessionOutputDtoMapper.ToDto(tool))); Assert.Equal("batch-1", toolResult.BatchId); Assert.Equal(2, toolResult.BatchSize); + Assert.Equal("Find the relevant source", toolResult.Rationale); + + var oldToolResult = Assert.IsType(SessionOutputDtoMapper.FromDto(new SessionOutputDto + { + Type = SessionOutputTypes.ToolCall, + SessionId = "test/wire", + CallId = "old-call", + ToolName = "search" + })); + Assert.Null(oldToolResult.Rationale); var approval = new ApprovalOutcomeOutput { diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionTranscriptEntryFactoryTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionTranscriptEntryFactoryTests.cs index bb0dc357f..791ca0887 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionTranscriptEntryFactoryTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionTranscriptEntryFactoryTests.cs @@ -26,6 +26,7 @@ public void Settled_outputs_map_to_complete_transcript_entries() TimestampMs = 10, CallId = new ToolCallId("call-1"), ToolName = new ToolName("shell_execute"), + Rationale = "Verify the source tree", ArgumentsJson = "{\"command\":\"dotnet test\"}" }; var tool = SessionTranscriptEntryFactory.Tool(call, new ToolResultOutput @@ -98,6 +99,7 @@ public void Settled_outputs_map_to_complete_transcript_entries() Assert.Equal("dotnet test", System.Text.Json.JsonDocument.Parse(tool.ArgumentsJson!).RootElement .GetProperty("command").GetString()); Assert.Equal("Passed", tool.Result); + Assert.Equal("Verify the source tree", tool.Rationale); Assert.Equal("run-1", subAgent.RunId); Assert.Equal("partial", subAgent.Outcome); Assert.Equal("report.txt", file.FileName); diff --git a/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs index 87bc8fa28..7ec28b6ad 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs @@ -68,7 +68,11 @@ public async Task Tool_call_executes_and_feeds_result_back_to_LLM() _fakeChatClient.ToolCallsOnFirstCall = [ new FunctionCallContent("call-1", "web_search", - new Dictionary { ["query"] = "test query" }) + new Dictionary + { + ["query"] = "test query", + ["_rationale"] = "Find sources for the requested topic" + }) ]; _fakeToolExecutor.Results["web_search"] = "Found 3 results for test query"; @@ -94,6 +98,7 @@ await sessionManager.Ask(new SendUserMessage var toolCall = await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("web_search", toolCall.ToolName.Value); Assert.Equal("call-1", toolCall.CallId.Value); + Assert.Equal("Find sources for the requested topic", toolCall.Rationale); // Drain the tool result output emitted after tool execution await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index ace267615..df4ade9d6 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -74,6 +74,7 @@ public sealed record SessionOutputDto public string? ActivitySummary { get; init; } public string? ToolBatchId { get; init; } public int? ToolBatchSize { get; init; } + public string? ToolRationale { get; init; } // Usage public long? InputTokens { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index 160a9fd13..ca87d013f 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -61,7 +61,8 @@ public static class SessionOutputDtoMapper ToolName = msg.ToolName.Value, ArgumentsJson = msg.ArgumentsJson, ToolBatchId = msg.BatchId, - ToolBatchSize = msg.BatchSize + ToolBatchSize = msg.BatchSize, + ToolRationale = msg.Rationale }, ToolActivityOutput msg => new SessionOutputDto @@ -283,7 +284,8 @@ public static SessionOutput FromDto(SessionOutputDto dto) ToolName = new Netclaw.Tools.ToolName(dto.ToolName ?? "unknown"), ArgumentsJson = dto.ArgumentsJson, BatchId = dto.ToolBatchId ?? string.Empty, - BatchSize = dto.ToolBatchSize ?? 1 + BatchSize = dto.ToolBatchSize ?? 1, + Rationale = dto.ToolRationale }, SessionOutputTypes.ToolActivity => new ToolActivityOutput { diff --git a/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs b/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs index 345aab204..e3b058454 100644 --- a/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs +++ b/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs @@ -45,6 +45,8 @@ public sealed record SessionTranscriptEntry public string? ArgumentsJson { get; init; } + public string? Rationale { get; init; } + public string? BatchId { get; init; } public int? BatchSize { get; init; } diff --git a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs index 39537723b..b9c951707 100644 --- a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs +++ b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs @@ -181,6 +181,7 @@ internal static Proto.SessionTranscriptEntryProto ToProto(SessionTranscriptEntry if (entry.BatchId is not null) proto.BatchId = entry.BatchId; if (entry.BatchSize is not null) proto.BatchSize = entry.BatchSize.Value; if (entry.ApprovalSelectedKey is not null) proto.ApprovalSelectedKey = entry.ApprovalSelectedKey; + if (entry.Rationale is not null) proto.Rationale = entry.Rationale; return proto; } @@ -229,7 +230,8 @@ internal static Proto.SessionTranscriptEntryProto ToProto(SessionTranscriptEntry KeepCountUsed = proto.HasKeepCountUsed ? proto.KeepCountUsed : null, BatchId = proto.HasBatchId ? proto.BatchId : null, BatchSize = proto.HasBatchSize ? proto.BatchSize : null, - ApprovalSelectedKey = proto.HasApprovalSelectedKey ? proto.ApprovalSelectedKey : null + ApprovalSelectedKey = proto.HasApprovalSelectedKey ? proto.ApprovalSelectedKey : null, + Rationale = proto.HasRationale ? proto.Rationale : null }; // ── SendUserMessage ── diff --git a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto index 11b4c3d23..191b2c9d1 100644 --- a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto +++ b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto @@ -121,6 +121,7 @@ message SessionTranscriptEntryProto { optional string batch_id = 41; optional int32 batch_size = 42; optional string approval_selected_key = 43; + optional string rationale = 44; } // ── Commands ── diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 8dd76db54..39ea1cc03 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -1858,15 +1858,25 @@ private void HandleToolCallResponse( // Persist tool calls exactly as the executor will interpret them (schema-aware // meta extraction), so recorded history matches what actually runs — a near-miss // meta key is stripped + captured in MetaJson, not left raw with an empty meta. + var toolMetadata = new Dictionary(StringComparer.Ordinal); var assistantMsg = ChatMessageConverter.FromAiMessage( lastMessage, - interpretToolCall: _toolExecutor is { } toolExec - ? tc => + interpretToolCall: tc => + { + ToolCallMeta? meta; + IDictionary? cleanedArguments; + if (_toolExecutor is { } toolExec) { - var (meta, cleaned) = toolExec.PrepareToolCall(tc); - return (meta, cleaned.Arguments); + var prepared = toolExec.PrepareToolCall(tc); + meta = prepared.Meta; + toolMetadata[tc.CallId] = meta; + return (meta, prepared.Cleaned.Arguments); } - : null); + + (meta, cleanedArguments) = ChatMessageConverter.ExtractMeta(tc.Arguments); + toolMetadata[tc.CallId] = meta; + return (meta, cleanedArguments); + }); var userMsg = _state.FindLastUserMessage() ?? new SerializableChatMessage { Role = Protocol.ChatRole.User, @@ -1882,7 +1892,7 @@ private void HandleToolCallResponse( }, evt => { ApplyToolBatchStarted(evt); - EmitAndDispatchToolBatch(lastMessage, toolCalls, usage); + EmitAndDispatchToolBatch(lastMessage, toolCalls, toolMetadata, usage); }); } @@ -1926,6 +1936,7 @@ private static void CanonicalizeToolCallNames( private void EmitAndDispatchToolBatch( AiChatMessage lastMessage, List toolCalls, + IReadOnlyDictionary toolMetadata, UsageDetails? usage) { @@ -1967,7 +1978,8 @@ private void EmitAndDispatchToolBatch( ToolName = new ToolName(tc.Name), ArgumentsJson = argsJson, BatchId = batchId, - BatchSize = Math.Max(1, toolCalls.Count) + BatchSize = Math.Max(1, toolCalls.Count), + Rationale = toolMetadata[tc.CallId]?.Rationale }, OutputFilter.ToolCalls); // Duplicate tool call detection: hash tool name + args @@ -3862,6 +3874,7 @@ private void EmitResponseOutputs( ToolName = new ToolName(toolCall.Name), BatchId = batchId, BatchSize = Math.Max(1, toolCalls.Count), + Rationale = ExtractToolCallRationale(toolCall), ArgumentsJson = toolCall.Arguments is not null ? JsonSerializer.Serialize(toolCall.Arguments) : null @@ -3903,6 +3916,15 @@ private void EmitResponseOutputs( }); } + private string? ExtractToolCallRationale(FunctionCallContent toolCall) + { + if (_toolExecutor is { } toolExecutor) + return toolExecutor.PrepareToolCall(toolCall).Meta?.Rationale; + + var (meta, _) = ChatMessageConverter.ExtractMeta(toolCall.Arguments); + return meta?.Rationale; + } + private void EmitUsageOutput(UsageDetails usage) { _sessionMetrics?.RecordTokenUsage(usage.InputTokenCount ?? 0, usage.OutputTokenCount ?? 0); diff --git a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs index 8a6020dc5..49504c265 100644 --- a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs +++ b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs @@ -76,6 +76,9 @@ public sealed record ToolCallOutput : SessionOutput /// Number of calls in the model tool-call batch. public int BatchSize { get; init; } = 1; + /// The model-supplied intent for this call. + public string? Rationale { get; init; } + /// /// Tool arguments as a JSON string. Kept opaque at the protocol level — /// tool executors parse based on their schema. diff --git a/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs b/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs index 3b359204d..bfeafcada 100644 --- a/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs +++ b/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs @@ -21,6 +21,7 @@ public static SessionTranscriptEntry Tool( CallId = result.CallId.Value, ToolName = result.ToolName.Value, ArgumentsJson = call?.ArgumentsJson, + Rationale = call?.Rationale, BatchId = call?.BatchId, BatchSize = call?.BatchSize, Result = result.Result diff --git a/src/Netclaw.Actors/Sessions/SessionTranscriptExtractor.cs b/src/Netclaw.Actors/Sessions/SessionTranscriptExtractor.cs index 261f81994..f11759d5a 100644 --- a/src/Netclaw.Actors/Sessions/SessionTranscriptExtractor.cs +++ b/src/Netclaw.Actors/Sessions/SessionTranscriptExtractor.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Netclaw.Actors.Protocol; +using Netclaw.Tools; namespace Netclaw.Actors.Sessions; @@ -81,6 +82,7 @@ public static IReadOnlyList Extract( CallId = callId.Value, ToolName = message.Name ?? call?.Name.Value ?? "unknown", ArgumentsJson = call?.ArgumentsJson, + Rationale = ToolCallMeta.Parse(call?.MetaJson)?.Rationale, Result = message.Content }); calls.Remove(callId.Value); diff --git a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs index ad2bdca7b..fa2ce8f72 100644 --- a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs @@ -39,6 +39,28 @@ public void Parallel_tool_results_update_only_their_call_rows() first.State.Transcript.Select(block => block.Key).ToArray()); } + [Fact] + public void Tool_rationale_is_the_work_title_and_arguments_do_not_supply_a_fallback() + { + var withRationale = ToolCall("call-a", "search", 1) with + { + Rationale = "Find the relevant source" + }; + var state = Apply(ChatPresentationState.Empty, withRationale); + + Assert.Equal("Find the relevant source", state.Tools["call-a"].Rationale); + + state = Apply(state, ToolResult("call-a", "result-a", 2)); + var completed = Assert.Single(state.Transcript); + Assert.Contains("Find the relevant source", completed.Summary, StringComparison.Ordinal); + + state = Apply(state, ToolCall("call-b", "search", 3)); + state = Apply(state, ToolResult("call-b", "result-b", 4)); + var missing = state.Transcript[^1]; + Assert.Contains("No rationale supplied", missing.Summary, StringComparison.Ordinal); + Assert.DoesNotContain("call-b", missing.Summary, StringComparison.Ordinal); + } + [Fact] public void Parallel_tool_batch_commits_one_durable_group() { @@ -181,6 +203,7 @@ public void Session_resume_prefers_structured_transcript_over_legacy_messages() Type = SessionTranscriptEntryTypes.Tool, CallId = "call-1", ToolName = "status", + Rationale = "Check service health", Result = "healthy" } ] @@ -189,6 +212,8 @@ public void Session_resume_prefers_structured_transcript_over_legacy_messages() Assert.DoesNotContain(state.Transcript, block => block.Summary == "legacy text"); Assert.Contains(state.Transcript, block => block.Kind == ChatBlockKind.Tool && block.SemanticText.Contains("healthy", StringComparison.Ordinal)); + Assert.Contains(state.Transcript, block => + block.Kind == ChatBlockKind.Tool && block.Summary.Contains("Check service health", StringComparison.Ordinal)); } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index 1b5c82c6b..40d7d1085 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -410,6 +410,12 @@ public async Task ActivityDeck_ShowsParallelToolsThoughtAndSubAgent() { var outputs = new SessionOutput[] { + new SessionJoined + { + SessionId = SessionId, + TimestampMs = 0, + TurnCount = 0 + }, new ThinkingDeltaOutput("Compare both results") { SessionId = SessionId, @@ -448,10 +454,16 @@ await harness.WaitUntilAsync(() => && harness.Terminal.Contains("reviewer")); var screen = harness.Terminal.ToString(); - Assert.Contains("Tool search", screen, StringComparison.Ordinal); - Assert.Contains("Tool fetch", screen, StringComparison.Ordinal); + Assert.Contains("Inspect call-a", screen, StringComparison.Ordinal); + Assert.Contains("Inspect call-b", screen, StringComparison.Ordinal); + Assert.Contains("· search", screen, StringComparison.Ordinal); + Assert.Contains("· fetch", screen, StringComparison.Ordinal); Assert.Contains("Agent reviewer", screen, StringComparison.Ordinal); Assert.Contains("MESSAGE", screen, StringComparison.Ordinal); + Assert.True(screen.IndexOf("Inspect call-a", StringComparison.Ordinal) + < screen.IndexOf("connected", StringComparison.Ordinal)); + Assert.True(screen.IndexOf("connected", StringComparison.Ordinal) + < screen.IndexOf("MESSAGE", StringComparison.Ordinal)); await harness.StopAsync(runTask); } @@ -572,8 +584,9 @@ public async Task Inspector_ShowsTheCompleteSemanticToolResult() }; await using var harness = CreateHarness(outputs: outputs); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("compact line")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Inspect call-a")); + Assert.DoesNotContain("compact line", harness.Terminal.ToString(), StringComparison.Ordinal); Assert.DoesNotContain("complete hidden line", harness.Terminal.ToString(), StringComparison.Ordinal); harness.Input.EnqueueKey(ConsoleKey.O, control: true); await harness.WaitUntilAsync(() => @@ -685,7 +698,7 @@ public async Task Inspector_EventCopyUsesCompleteSemanticText() }; await using var harness = CreateHarness(outputs: outputs); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("first line")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Inspect call-a")); harness.Input.EnqueueKey(ConsoleKey.O, control: true); await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); @@ -821,6 +834,7 @@ public async Task NarrowHeader_KeepsACompactConnectionCue() TimestampMs = timestamp, CallId = new ToolCallId(callId), ToolName = new ToolName(name), + Rationale = $"Inspect {callId}", ArgumentsJson = $"{{\"call\":\"{callId}\"}}" }; diff --git a/src/Netclaw.Cli/HeadlessChannel.cs b/src/Netclaw.Cli/HeadlessChannel.cs index 5e80a2885..1365daa9e 100644 --- a/src/Netclaw.Cli/HeadlessChannel.cs +++ b/src/Netclaw.Cli/HeadlessChannel.cs @@ -227,14 +227,15 @@ private void HandleOutput(SessionOutput output, StreamWriter? log) { CallId = msg.CallId.Value, ToolName = msg.ToolName.Value, - ArgumentsJson = msg.ArgumentsJson + ArgumentsJson = msg.ArgumentsJson, + Rationale = msg.Rationale }); } else { Console.WriteLine($"[tool:call] {msg.ToolName}({msg.ArgumentsJson ?? ""})"); } - Log(log, $"TOOL_CALL: {msg.ToolName} call_id={msg.CallId} args={msg.ArgumentsJson ?? "{}"}"); + Log(log, $"TOOL_CALL: {msg.ToolName} call_id={msg.CallId} rationale={msg.Rationale ?? ""} args={msg.ArgumentsJson ?? "{}"}"); break; case ToolResultOutput msg: @@ -383,6 +384,7 @@ private sealed class JsonToolCall public required string CallId { get; init; } public required string ToolName { get; init; } public string? ArgumentsJson { get; init; } + public string? Rationale { get; init; } } private sealed class JsonUsage diff --git a/src/Netclaw.Cli/Tui/ChatPresentation.cs b/src/Netclaw.Cli/Tui/ChatPresentation.cs index 23e74ed75..3c277c287 100644 --- a/src/Netclaw.Cli/Tui/ChatPresentation.cs +++ b/src/Netclaw.Cli/Tui/ChatPresentation.cs @@ -41,6 +41,7 @@ internal sealed record ChatPresentationBlock( internal sealed record ToolActivityPresentation( string CallId, string ToolName, + string? Rationale, string? ArgumentsJson, string Phase, string? Summary, @@ -359,6 +360,7 @@ private static ChatPresentationState StartTool( var tool = new ToolActivityPresentation( output.CallId.Value, output.ToolName.Value, + output.Rationale, output.ArgumentsJson, "queued", null, @@ -378,6 +380,7 @@ private static ChatPresentationState UpdateTool(ChatPresentationState state, Too key, output.ToolName.Value, null, + null, output.Phase, output.Summary, output.TimestampMs, @@ -404,6 +407,7 @@ private static ChatPresentationState CompleteTool( state.Tools.TryGetValue(output.CallId.Value, out var active); var detail = string.Join('\n', new[] { + active?.Rationale is null ? null : $"Rationale: {active.Rationale}", active?.ArgumentsJson is null ? null : $"Arguments: {active.ArgumentsJson}", $"Result: {output.Result}" }.Where(value => value is not null)); @@ -411,7 +415,7 @@ private static ChatPresentationState CompleteTool( $"tool:{output.CallId.Value}", ChatBlockKind.Tool, "TOOL", - $"{output.ToolName.Value} completed {FirstLine(output.Result)}", + $"{ToolWorkTitle(active?.Rationale)} · {output.ToolName.Value}", $"Tool: {output.ToolName.Value}\nCall: {output.CallId.Value}\n{detail}", output.TimestampMs, active?.TurnId ?? state.CurrentTurnId, @@ -601,7 +605,7 @@ private static ChatPresentationBlock ResumeBlock(SessionTranscriptEntry entry, i var summary = kind switch { ChatBlockKind.User or ChatBlockKind.Assistant => entry.Text ?? string.Empty, - ChatBlockKind.Tool => $"{entry.ToolName ?? "unknown"} completed {FirstLine(entry.Result)}", + ChatBlockKind.Tool => $"{ToolWorkTitle(entry.Rationale)} · {entry.ToolName ?? "unknown"}", ChatBlockKind.Approval => $"{entry.ToolName ?? "unknown"} {ApprovalDecisionText(entry.ApprovalSelectedKey)}", ChatBlockKind.SubAgent => $"{entry.AgentName ?? "sub-agent"} {entry.Outcome ?? "complete"}", ChatBlockKind.File => $"{entry.FileName ?? "file"} {entry.FilePath}", @@ -712,6 +716,7 @@ private static ChatPresentationBlock DiagnosticBlock( { SessionTranscriptEntryTypes.User or SessionTranscriptEntryTypes.Assistant => entry.Text ?? string.Empty, SessionTranscriptEntryTypes.Tool => $"Tool: {entry.ToolName ?? "unknown"}\nCall: {entry.CallId ?? "unknown"}" + + (entry.Rationale is null ? string.Empty : $"\nRationale: {entry.Rationale}") + (entry.ArgumentsJson is null ? string.Empty : $"\nArguments: {entry.ArgumentsJson}") + $"\nResult: {entry.Result ?? string.Empty}", SessionTranscriptEntryTypes.Approval => $"Tool: {entry.ToolName ?? "unknown"}\nCall: {entry.CallId ?? "unknown"}" @@ -751,6 +756,10 @@ private static string UsageSummary(SessionTranscriptEntry entry) => _ => "DIAGNOSTIC" }; + private static string ToolWorkTitle(string? rationale) => string.IsNullOrWhiteSpace(rationale) + ? "No rationale supplied" + : rationale.Trim(); + private static string FirstLine(string? text) { if (string.IsNullOrWhiteSpace(text)) diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index b582cde98..0123364ed 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -275,9 +275,9 @@ private ILayoutNode BuildLiveRegion() if (_state.Transcript.Count > 0) content.WithChild(Layouts.Empty().Height(1)); content - .WithChild(BuildSessionHeader()) .WithChild(BuildActivityDeck()) - .WithChild(BuildStreamingAssistant()); + .WithChild(BuildStreamingAssistant()) + .WithChild(BuildSessionHeader()); if (_state.PendingApproval is not null) content.WithChild(BuildDecisionGate(_state.PendingApproval)); @@ -582,8 +582,11 @@ private ILayoutNode BuildActivityDeck() ? string.Empty : $" {phase}"; var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; + var action = string.IsNullOrWhiteSpace(tool.Rationale) + ? "No rationale supplied" + : tool.Rationale.Trim(); rows.Add(new TextNode(ChatPresentationRenderer.OneLine( - $"{state,-8} Tool {tool.ToolName}{phaseText}{summary}", + $"{state,-8} {action} · {tool.ToolName}{phaseText}{summary}", lineWidth)) .WithForeground(ActivityColor(tool.Phase))); From bf81a72c1799fac72740e3a88740bc013a0379f9 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 09:52:00 -0500 Subject: [PATCH 21/39] feat(chat): keep turn work in one reply block --- .../Tui/ChatPresentationReducerTests.cs | 115 +++- .../Tui/InlineChatPageTests.cs | 34 +- src/Netclaw.Cli/Tui/ChatPresentation.cs | 548 +++++++++++++----- src/Netclaw.Cli/Tui/ChatViewModel.cs | 21 + src/Netclaw.Cli/Tui/InlineChatPage.cs | 265 ++++++--- 5 files changed, 730 insertions(+), 253 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs index fa2ce8f72..60b5086c1 100644 --- a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs @@ -18,7 +18,7 @@ public sealed class ChatPresentationReducerTests private static readonly SessionId SessionId = new("test/chat"); [Fact] - public void Parallel_tool_results_update_only_their_call_rows() + public void Parallel_tool_results_keep_stable_rows_until_the_turn_settles() { var state = ChatPresentationState.Empty; state = Apply(state, ToolCall("call-a", "search", 1)); @@ -27,16 +27,23 @@ public void Parallel_tool_results_update_only_their_call_rows() var second = ChatPresentationReducer.Reduce(state, ToolResult("call-b", "result-b", 3)); Assert.True(second.State.Tools.ContainsKey("call-a")); - Assert.False(second.State.Tools.ContainsKey("call-b")); - var secondBlock = Assert.Single(second.Effects.OfType()).Block; - Assert.Equal("tool:call-b", secondBlock.Key); - Assert.Contains("result-b", secondBlock.SemanticText, StringComparison.Ordinal); + Assert.True(second.State.Tools.ContainsKey("call-b")); + Assert.Equal("completed", second.State.Tools["call-b"].Phase); + Assert.Equal("result-b", second.State.Tools["call-b"].Result); + Assert.Empty(second.Effects.OfType()); var first = ChatPresentationReducer.Reduce(second.State, ToolResult("call-a", "result-a", 4)); - Assert.Empty(first.State.Tools); - Assert.Equal(["tool:call-b", "tool:call-a"], - first.State.Transcript.Select(block => block.Key).ToArray()); + Assert.Equal(2, first.State.Tools.Count); + Assert.All(first.State.Tools.Values, tool => Assert.NotNull(tool.CompletedAtMs)); + + var settled = ChatPresentationReducer.Reduce(first.State, CompletedTurn(5)); + + Assert.Empty(settled.State.Tools); + var reply = Assert.Single(settled.State.Transcript); + Assert.Equal(ChatBlockKind.Assistant, reply.Kind); + Assert.Contains("result-b", reply.SemanticText, StringComparison.Ordinal); + Assert.Contains("result-a", reply.SemanticText, StringComparison.Ordinal); } [Fact] @@ -51,18 +58,17 @@ public void Tool_rationale_is_the_work_title_and_arguments_do_not_supply_a_fallb Assert.Equal("Find the relevant source", state.Tools["call-a"].Rationale); state = Apply(state, ToolResult("call-a", "result-a", 2)); - var completed = Assert.Single(state.Transcript); - Assert.Contains("Find the relevant source", completed.Summary, StringComparison.Ordinal); - - state = Apply(state, ToolCall("call-b", "search", 3)); - state = Apply(state, ToolResult("call-b", "result-b", 4)); - var missing = state.Transcript[^1]; - Assert.Contains("No rationale supplied", missing.Summary, StringComparison.Ordinal); - Assert.DoesNotContain("call-b", missing.Summary, StringComparison.Ordinal); + Assert.Equal("Find the relevant source", state.Tools["call-a"].Rationale); + + var missingState = Apply(ChatPresentationState.Empty, ToolCall("call-b", "search", 3)); + missingState = Apply(missingState, ToolResult("call-b", "result-b", 4)); + missingState = Apply(missingState, CompletedTurn(5)); + var missing = Assert.Single(missingState.Transcript); + Assert.Contains("No rationale supplied", missing.SemanticText, StringComparison.Ordinal); } [Fact] - public void Parallel_tool_batch_commits_one_durable_group() + public void Parallel_tool_batch_stays_in_one_live_passage() { var first = ToolCall("call-a", "search", 1) with { BatchId = "batch-1", BatchSize = 2 }; var second = ToolCall("call-b", "fetch", 2) with { BatchId = "batch-1", BatchSize = 2 }; @@ -70,9 +76,9 @@ public void Parallel_tool_batch_commits_one_durable_group() var state = Apply(ChatPresentationState.Empty, first); state = Apply(state, second); - var group = Assert.Single(state.Transcript, block => block.Kind == ChatBlockKind.Parallel); - Assert.Contains("2 tool calls", group.Summary, StringComparison.Ordinal); - Assert.Contains("batch-1", state.CommittedToolBatches); + var passage = Assert.Single(state.ReplyPassages); + Assert.Equal(["call-a", "call-b"], passage.ToolCallIds); + Assert.Empty(state.Transcript); } [Fact] @@ -89,9 +95,10 @@ public void Parallel_same_name_subagents_keep_distinct_run_rows() state = Apply(state, SubAgent("run-a", SubAgentPhase.Completed, 4)); - Assert.False(state.SubAgents.ContainsKey("run-a")); + Assert.True(state.SubAgents.ContainsKey("run-a")); + Assert.Equal("completed", state.SubAgents["run-a"].Phase); Assert.True(state.SubAgents.ContainsKey("run-b")); - Assert.Contains(state.Transcript, block => block.Key == "subagent:run-a"); + Assert.Empty(state.Transcript); } [Fact] @@ -124,7 +131,8 @@ public void Approval_outcome_removes_only_its_request_and_commits_the_decision() }); Assert.Equal(secondCallId, state.PendingApproval?.CallId.Value); - var decision = Assert.Single(state.Transcript, block => block.Kind == ChatBlockKind.Approval); + Assert.Empty(state.Transcript); + var decision = Assert.Single(state.CompletedApprovals); Assert.True(decision.IsFailure); Assert.Contains("denied", decision.Summary, StringComparison.Ordinal); } @@ -159,9 +167,8 @@ public void Transient_thought_and_tool_activity_stay_out_of_settled_transcript() TimestampMs = 4 }); - var thought = Assert.Single(state.Transcript); - Assert.Equal(ChatBlockKind.Thought, thought.Kind); - Assert.Equal("short reason", thought.Detail); + Assert.Empty(state.Transcript); + Assert.Equal("short reason", state.ThoughtText); } [Fact] @@ -275,7 +282,53 @@ public void Failed_turn_settles_incomplete_activity_as_diagnostics() Assert.Empty(state.Tools); Assert.Empty(state.SubAgents); - Assert.Equal(2, state.Transcript.Count(block => block.Kind == ChatBlockKind.Diagnostic)); + Assert.Single(state.Transcript, block => block.Kind == ChatBlockKind.Assistant); + Assert.Single(state.Transcript, block => block.Kind == ChatBlockKind.Diagnostic); + } + + [Fact] + public void Sequential_model_steps_settle_as_one_ordered_reply() + { + var state = Apply(ChatPresentationState.Empty, new TextDeltaOutput("I will inspect the source.") + { + SessionId = SessionId, + TimestampMs = 1 + }); + state = Apply(state, new TextOutput("I will inspect the source.") + { + SessionId = SessionId, + TimestampMs = 2 + }); + state = Apply(state, ToolCall("call-a", "search", 3) with + { + Rationale = "Find the source" + }); + state = Apply(state, ToolResult("call-a", "source found", 4)); + state = Apply(state, new TextDeltaOutput("The source confirms the behavior.") + { + SessionId = SessionId, + TimestampMs = 5 + }); + state = Apply(state, new TextOutput("The source confirms the behavior.") + { + SessionId = SessionId, + TimestampMs = 6 + }); + + Assert.Equal(2, state.ReplyPassages.Count); + Assert.Empty(state.Transcript); + Assert.Equal("I will inspect the source.", state.ReplyPassages[0].Text); + Assert.Equal("The source confirms the behavior.", state.ReplyPassages[1].Text); + Assert.True(state.Tools.ContainsKey("call-a")); + + state = Apply(state, CompletedTurn(7)); + + var reply = Assert.Single(state.Transcript); + Assert.True(reply.Summary.IndexOf("I will inspect", StringComparison.Ordinal) + < reply.Summary.IndexOf("The source confirms", StringComparison.Ordinal)); + Assert.Contains("Completed work · 1 tool", reply.Summary, StringComparison.Ordinal); + Assert.Contains("Find the source", reply.SemanticText, StringComparison.Ordinal); + Assert.Empty(state.ReplyPassages); } [Fact] @@ -372,6 +425,14 @@ private static ChatPresentationState Apply(ChatPresentationState state, SessionO Result = result }; + private static TurnCompleted CompletedTurn(long timestamp) => new() + { + SessionId = SessionId, + TimestampMs = timestamp, + TurnNumber = new TurnNumber(1), + Outcome = TurnOutcome.Completed + }; + private static ToolInteractionRequest Approval(string callId, long timestamp) => new() { SessionId = SessionId, diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index 40d7d1085..cf17dd6df 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -309,7 +309,7 @@ public async Task Generation_KeepsTheComposerFocusedAndAcceptsAQueuedPrompt() harness.ViewModel.IsGenerating.Value = true; harness.ViewModel.StatusMessage.Value = "Generating..."; await harness.WaitUntilAsync(() => - harness.Terminal.Contains("Working") + harness.Terminal.Contains("Thinking") && harness.Terminal.Contains("MESSAGE") && harness.Focus.CurrentFocus is not null); @@ -318,6 +318,7 @@ await harness.WaitUntilAsync(() => Assert.Equal("queue this next", await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("QUEUED 1 message")); await harness.StopAsync(runTask); } @@ -584,16 +585,16 @@ public async Task Inspector_ShowsTheCompleteSemanticToolResult() }; await using var harness = CreateHarness(outputs: outputs); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Inspect call-a")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Completed work")); Assert.DoesNotContain("compact line", harness.Terminal.ToString(), StringComparison.Ordinal); Assert.DoesNotContain("complete hidden line", harness.Terminal.ToString(), StringComparison.Ordinal); harness.Input.EnqueueKey(ConsoleKey.O, control: true); - await harness.WaitUntilAsync(() => - harness.Terminal.Contains("Tool result") - && harness.Terminal.Contains("complete hidden line")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); + harness.Input.EnqueueKey(ConsoleKey.End); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("complete hidden line")); - Assert.Contains("Tool result", harness.Terminal.ToString(), StringComparison.Ordinal); + Assert.Contains("Reply", harness.Terminal.ToString(), StringComparison.Ordinal); Assert.Contains("complete hidden line", harness.Terminal.ToString(), StringComparison.Ordinal); Assert.Contains("Arguments: {\"call\":\"call-a\"}", harness.Terminal.ToString(), StringComparison.Ordinal); @@ -673,6 +674,13 @@ public async Task Inspector_DefersNewStableBlocksUntilItCloses() SessionId = SessionId, TimestampMs = 2 }); + harness.ViewModel.Emit(new TurnCompleted + { + SessionId = SessionId, + TimestampMs = 3, + TurnNumber = new TurnNumber(2), + Outcome = TurnOutcome.Completed + }); await harness.WaitUntilAsync(() => harness.Terminal.Contains("event 1 of 2")); Assert.DoesNotContain("queued answer", harness.Terminal.ToString(), StringComparison.Ordinal); @@ -698,7 +706,7 @@ public async Task Inspector_EventCopyUsesCompleteSemanticText() }; await using var harness = CreateHarness(outputs: outputs); var runTask = harness.StartAsync(); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("Inspect call-a")); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Completed work")); harness.Input.EnqueueKey(ConsoleKey.O, control: true); await harness.WaitUntilAsync(() => harness.Terminal.Contains("INSPECTOR")); @@ -1025,6 +1033,18 @@ public override void OnActivated() base.OnActivated(); foreach (var output in _outputs) PublishOutputForTesting(output); + if (_outputs.Count > 0 + && !_outputs.Any(output => output is TurnCompleted) + && _outputs.Any(output => output is TextOutput or ToolResultOutput)) + { + PublishOutputForTesting(new TurnCompleted + { + SessionId = SessionId, + TimestampMs = _outputs.Max(output => output.TimestampMs) + 1, + TurnNumber = new TurnNumber(1), + Outcome = TurnOutcome.Completed + }); + } if (_approval is not null) SeedPendingInteractionForTesting(_approval); } diff --git a/src/Netclaw.Cli/Tui/ChatPresentation.cs b/src/Netclaw.Cli/Tui/ChatPresentation.cs index 3c277c287..ffbd11a13 100644 --- a/src/Netclaw.Cli/Tui/ChatPresentation.cs +++ b/src/Netclaw.Cli/Tui/ChatPresentation.cs @@ -48,7 +48,17 @@ internal sealed record ToolActivityPresentation( long StartedAtMs, string? TurnId, string BatchId, - int BatchSize); + int BatchSize, + int PassageIndex, + string? Result, + long? CompletedAtMs); + +internal sealed record ReplyPassagePresentation( + int Index, + long StartedAtMs, + string Text, + bool IsFinal, + ImmutableList ToolCallIds); internal sealed record SubAgentActivityPresentation( string RunId, @@ -57,7 +67,11 @@ internal sealed record SubAgentActivityPresentation( string Phase, string? Summary, long StartedAtMs, - string? ActiveToolName); + string? ActiveToolName, + long? CompletedAtMs, + string? Outcome, + string? Detail, + bool IsFailure); internal sealed record ChatPresentationState { @@ -77,7 +91,9 @@ internal sealed record ChatPresentationState public ImmutableQueue PendingApprovals { get; init; } = ImmutableQueue.Empty; - public string AssistantText { get; init; } = string.Empty; + public ImmutableList CompletedApprovals { get; init; } = []; + + public ImmutableList ReplyPassages { get; init; } = []; public string ThoughtText { get; init; } = string.Empty; @@ -122,20 +138,17 @@ public static ChatReduction Reduce(ChatPresentationState state, SessionOutput ou var next = output switch { SessionJoined joined => ReduceJoined(state, joined, effects), - TextDeltaOutput textDelta => state with - { - AssistantText = state.AssistantText + textDelta.Delta - }, - TextOutput text => CommitAssistant(state, text, effects), + TextDeltaOutput textDelta => AppendAssistantDelta(state, textDelta), + TextOutput text => FinalizeAssistantPassage(state, text), ThinkingDeltaOutput thoughtDelta => state with { ThoughtText = state.ThoughtText + thoughtDelta.Delta }, - ThinkingOutput thought => CommitThought(state, thought, effects), - ToolCallOutput toolCall => StartTool(state, toolCall, effects), + ThinkingOutput thought => FinalizeThought(state, thought), + ToolCallOutput toolCall => StartTool(state, toolCall), ToolActivityOutput activity => UpdateTool(state, activity), - ToolResultOutput toolResult => CompleteTool(state, toolResult, effects), - SubAgentOutput subAgent => ReduceSubAgent(state, subAgent, effects), + ToolResultOutput toolResult => CompleteTool(state, toolResult), + SubAgentOutput subAgent => ReduceSubAgent(state, subAgent), UsageOutput usage => CommitUsage(state, usage, effects), ErrorOutput error => Commit(state, ErrorBlock(error, state.CurrentTurnId), effects), FileOutput file => Commit(state, FileBlock(file, state.CurrentTurnId), effects), @@ -145,7 +158,7 @@ public static ChatReduction Reduce(ChatPresentationState state, SessionOutput ou TurnCompleted completed => CompleteTurn(state, completed, effects), ProcessingStateOutput processing => state with { IsProcessing = processing.IsProcessing }, SessionTitleOutput title => CommitTitle(state, title, effects), - BufferFlush => FlushAssistant(state, output.TimestampMs, effects), + BufferFlush => FinalizeAssistantPassage(state, null), _ => Commit(state, DiagnosticBlock( $"unsupported:{output.GetType().Name}:{output.TimestampMs}", $"Unsupported session output: {output.GetType().Name}", @@ -153,7 +166,8 @@ public static ChatReduction Reduce(ChatPresentationState state, SessionOutput ou state.CurrentTurnId), effects) }; - if (output is TextDeltaOutput or ThinkingDeltaOutput or ToolCallOutput or ToolActivityOutput + if (output is TextDeltaOutput or TextOutput or ThinkingDeltaOutput or ThinkingOutput + or ToolCallOutput or ToolActivityOutput or ToolResultOutput or ProcessingStateOutput) { effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); @@ -196,9 +210,24 @@ private static ChatPresentationState ReduceJoined( if (joined.RecentTranscript is { Count: > 0 }) { + var restoredTurns = new HashSet(StringComparer.Ordinal); for (var index = 0; index < joined.RecentTranscript.Count; index++) { var entry = joined.RecentTranscript[index]; + if (entry.TurnId is { Length: > 0 } turnId && IsReplyEntry(entry)) + { + if (restoredTurns.Add(turnId)) + { + var turnEntries = joined.RecentTranscript + .Where(candidate => string.Equals(candidate.TurnId, turnId, StringComparison.Ordinal) + && IsReplyEntry(candidate)) + .ToList(); + state = Commit(state, ResumeReplyBlock(turnId, turnEntries), effects); + } + + continue; + } + if (entry is { Type: SessionTranscriptEntryTypes.Tool, @@ -247,6 +276,59 @@ private static ChatPresentationState ReduceJoined( }; } + private static bool IsReplyEntry(SessionTranscriptEntry entry) => entry.Type is + SessionTranscriptEntryTypes.Assistant + or SessionTranscriptEntryTypes.Tool + or SessionTranscriptEntryTypes.Approval + or SessionTranscriptEntryTypes.SubAgent; + + private static ChatPresentationBlock ResumeReplyBlock( + string turnId, + IReadOnlyList entries) + { + var prose = string.Join("\n\n", entries + .Where(entry => entry.Type == SessionTranscriptEntryTypes.Assistant) + .Select(entry => entry.Text?.Trim()) + .Where(text => !string.IsNullOrEmpty(text))); + var toolCount = entries.Count(entry => entry.Type == SessionTranscriptEntryTypes.Tool); + var agentCount = entries.Count(entry => entry.Type == SessionTranscriptEntryTypes.SubAgent); + var decisionCount = entries.Count(entry => entry.Type == SessionTranscriptEntryTypes.Approval); + var receiptParts = new[] + { + CountLabel(toolCount, "tool", "tools"), + CountLabel(agentCount, "agent", "agents"), + CountLabel(decisionCount, "decision", "decisions") + }.Where(value => value.Length > 0).ToArray(); + var receipt = receiptParts.Length == 0 + ? string.Empty + : $"Completed work · {string.Join(" · ", receiptParts)}"; + var summary = string.Join("\n\n", new[] { prose, receipt } + .Where(value => value.Length > 0)); + var detail = string.Join("\n\n", entries + .Select(entry => entry.Type == SessionTranscriptEntryTypes.Assistant + ? entry.Text ?? string.Empty + : ResumeDetail(entry)) + .Where(value => value.Length > 0)); + return new ChatPresentationBlock( + $"resume:reply:{turnId}", + ChatBlockKind.Assistant, + "NETCLAW", + summary, + detail.Length == 0 ? "NETCLAW" : $"NETCLAW\n{detail}", + entries.Min(entry => entry.TimestampMs), + turnId, + detail, + entries.Any(entry => string.Equals(entry.Outcome, "failed", StringComparison.OrdinalIgnoreCase) + || string.Equals(entry.ApprovalSelectedKey, ApprovalOptionKeys.Deny, StringComparison.Ordinal))); + } + + private static string CountLabel(int count, string singular, string plural) => count switch + { + 0 => string.Empty, + 1 => $"1 {singular}", + _ => $"{count} {plural}" + }; + private static ChatPresentationState CommitTitle( ChatPresentationState state, SessionTitleOutput output, @@ -277,85 +359,82 @@ private static ChatPresentationState CommitUsage( effects); } - private static ChatPresentationState CommitAssistant( + private static ChatPresentationState AppendAssistantDelta( ChatPresentationState state, - TextOutput output, - List effects) + TextDeltaOutput output) { - var text = string.IsNullOrEmpty(output.Text) ? state.AssistantText : output.Text; - if (string.IsNullOrEmpty(text)) - return state; - - var block = new ChatPresentationBlock( - $"turn:{state.TurnNumber}:assistant", - ChatBlockKind.Assistant, - "NETCLAW", - text, - $"NETCLAW\n{text}", - output.TimestampMs, - state.CurrentTurnId, - text); - return Commit(state with { AssistantText = string.Empty }, block, effects); + var passages = EnsureOpenPassage(state.ReplyPassages, output.TimestampMs); + var passage = passages[^1]; + return state with + { + ReplyPassages = passages.SetItem(passages.Count - 1, passage with + { + Text = passage.Text + output.Delta + }) + }; } - private static ChatPresentationState FlushAssistant( + private static ChatPresentationState FinalizeAssistantPassage( ChatPresentationState state, - long timestampMs, - List effects) + TextOutput? output) { - if (string.IsNullOrEmpty(state.AssistantText)) + var text = output?.Text; + if (state.ReplyPassages.Count == 0) + { + if (string.IsNullOrEmpty(text)) + return state; + + return state with + { + ReplyPassages = + [new ReplyPassagePresentation(0, output!.TimestampMs, text, true, [])] + }; + } + + var passages = state.ReplyPassages; + var passage = passages[^1]; + if (passage.IsFinal) + { + if (string.IsNullOrEmpty(text) || string.Equals(passage.Text, text, StringComparison.Ordinal)) + return state; + + var next = new ReplyPassagePresentation( + passages.Count, + output!.TimestampMs, + text, + true, + []); + return state with { ReplyPassages = passages.Add(next) }; + } + + var finalText = string.IsNullOrEmpty(text) ? passage.Text : text; + if (string.IsNullOrEmpty(finalText) && passage.ToolCallIds.Count == 0) return state; - var block = new ChatPresentationBlock( - $"turn:{state.TurnNumber}:assistant:preamble:{timestampMs}", - ChatBlockKind.Assistant, - "NETCLAW", - state.AssistantText, - $"NETCLAW\n{state.AssistantText}", - timestampMs, - state.CurrentTurnId, - state.AssistantText); - return Commit(state with { AssistantText = string.Empty }, block, effects); + return state with + { + ReplyPassages = passages.SetItem(passages.Count - 1, passage with + { + Text = finalText, + IsFinal = true + }) + }; } - private static ChatPresentationState CommitThought( + private static ChatPresentationState FinalizeThought( ChatPresentationState state, - ThinkingOutput output, - List effects) + ThinkingOutput output) { var text = string.IsNullOrEmpty(output.Text) ? state.ThoughtText : output.Text; - if (string.IsNullOrWhiteSpace(text)) - return state with { ThoughtText = string.Empty }; - - var block = new ChatPresentationBlock( - $"turn:{state.TurnNumber}:thought:{output.TimestampMs}", - ChatBlockKind.Thought, - "THOUGHT", - FirstLine(text), - $"THOUGHT\n{text}", - output.TimestampMs, - state.CurrentTurnId, - text); - return Commit(state with { ThoughtText = string.Empty }, block, effects); + return state with { ThoughtText = text }; } private static ChatPresentationState StartTool( ChatPresentationState state, - ToolCallOutput output, - List effects) + ToolCallOutput output) { - if (output.BatchSize > 1 - && output.BatchId.Length > 0 - && !state.CommittedToolBatches.Contains(output.BatchId)) - { - state = CommitParallelGroup( - state, - output.BatchId, - output.BatchSize, - output.TimestampMs, - state.CurrentTurnId, - effects); - } + var passages = EnsureToolPassage(state, output.TimestampMs); + var passage = passages[^1]; var tool = new ToolActivityPresentation( output.CallId.Value, @@ -367,8 +446,16 @@ private static ChatPresentationState StartTool( output.TimestampMs, state.CurrentTurnId, output.BatchId, - output.BatchSize); - return state with { Tools = state.Tools.SetItem(tool.CallId, tool) }; + output.BatchSize, + passage.Index, + null, + null); + passage = passage with { ToolCallIds = passage.ToolCallIds.Add(tool.CallId) }; + return state with + { + ReplyPassages = passages.SetItem(passages.Count - 1, passage), + Tools = state.Tools.SetItem(tool.CallId, tool) + }; } private static ChatPresentationState UpdateTool(ChatPresentationState state, ToolActivityOutput output) @@ -386,7 +473,10 @@ private static ChatPresentationState UpdateTool(ChatPresentationState state, Too output.TimestampMs, output.TurnId.Value, string.Empty, - 1); + 1, + state.ReplyPassages.Count == 0 ? 0 : state.ReplyPassages[^1].Index, + null, + null); return state with { CurrentTurnId = output.TurnId.Value, @@ -401,32 +491,51 @@ private static ChatPresentationState UpdateTool(ChatPresentationState state, Too private static ChatPresentationState CompleteTool( ChatPresentationState state, - ToolResultOutput output, - List effects) + ToolResultOutput output) { - state.Tools.TryGetValue(output.CallId.Value, out var active); - var detail = string.Join('\n', new[] + var key = output.CallId.Value; + var passages = state.ReplyPassages; + if (!state.Tools.TryGetValue(key, out var active)) { - active?.Rationale is null ? null : $"Rationale: {active.Rationale}", - active?.ArgumentsJson is null ? null : $"Arguments: {active.ArgumentsJson}", - $"Result: {output.Result}" - }.Where(value => value is not null)); - var block = new ChatPresentationBlock( - $"tool:{output.CallId.Value}", - ChatBlockKind.Tool, - "TOOL", - $"{ToolWorkTitle(active?.Rationale)} · {output.ToolName.Value}", - $"Tool: {output.ToolName.Value}\nCall: {output.CallId.Value}\n{detail}", - output.TimestampMs, - active?.TurnId ?? state.CurrentTurnId, - detail); - return Commit(state with { Tools = state.Tools.Remove(output.CallId.Value) }, block, effects); + passages = EnsureToolPassage(state, output.TimestampMs); + var passage = passages[^1]; + active = new ToolActivityPresentation( + key, + output.ToolName.Value, + null, + null, + "completed", + null, + output.TimestampMs, + state.CurrentTurnId, + string.Empty, + 1, + passage.Index, + output.Result, + output.TimestampMs); + passage = passage with { ToolCallIds = passage.ToolCallIds.Add(key) }; + passages = passages.SetItem(passages.Count - 1, passage); + } + else + { + active = active with + { + Phase = "completed", + Result = output.Result, + CompletedAtMs = output.TimestampMs + }; + } + + return state with + { + ReplyPassages = passages, + Tools = state.Tools.SetItem(key, active) + }; } private static ChatPresentationState ReduceSubAgent( ChatPresentationState state, - SubAgentOutput output, - List effects) + SubAgentOutput output) { var key = output.RunId?.Value ?? $"legacy:{output.AgentName.Value}"; if (output.Phase != Actors.SubAgents.SubAgentPhase.Completed) @@ -440,7 +549,11 @@ private static ChatPresentationState ReduceSubAgent( output.Phase.ToString().ToLowerInvariant(), output.ActivitySummary, output.TimestampMs, - null); + null, + null, + null, + null, + false); var activeToolName = ActiveSubAgentTool(output.ActivityPhase) ?? current.ActiveToolName; if (output.ActivityPhase is "processing tool results" or "calling the model") activeToolName = null; @@ -459,17 +572,33 @@ private static ChatPresentationState ReduceSubAgent( var detail = $"Run: {key}\nOutcome: {outcome}\nDuration: {output.Duration.TotalSeconds:F1}s" + (output.OutcomeReason is null ? string.Empty : $"\nReason: {output.OutcomeReason.Value.Value}") + (output.MemoryDecision is null ? string.Empty : $"\nMemory: {output.MemoryDecision}"); - var block = new ChatPresentationBlock( - $"subagent:{key}", - ChatBlockKind.SubAgent, - "AGENT", - $"{output.AgentName.Value} {outcome} {output.Duration.TotalSeconds:F1}s", - $"Sub-agent: {output.AgentName.Value}\n{detail}", - output.TimestampMs, - state.CurrentTurnId, - detail, - output.Outcome == SubAgentRunOutcome.Failed); - return Commit(state with { SubAgents = state.SubAgents.Remove(key) }, block, effects); + var completed = state.SubAgents.TryGetValue(key, out var completedActive) + ? completedActive + : new SubAgentActivityPresentation( + key, + output.ParentCallId?.Value, + output.AgentName.Value, + "completed", + output.ActivitySummary, + output.TimestampMs, + null, + null, + null, + null, + false); + return state with + { + SubAgents = state.SubAgents.SetItem(key, completed with + { + Phase = "completed", + Summary = output.ActivitySummary, + ActiveToolName = null, + CompletedAtMs = output.TimestampMs, + Outcome = outcome, + Detail = detail, + IsFailure = output.Outcome == SubAgentRunOutcome.Failed + }) + }; } private static string? ActiveSubAgentTool(string? phase) @@ -523,7 +652,11 @@ private static ChatPresentationState ResolveApproval( string.Equals(output.SelectedKey.Value, ApprovalOptionKeys.Deny, StringComparison.Ordinal)); var remaining = ImmutableQueue.CreateRange(state.PendingApprovals.Where(request => !string.Equals(request.CallId.Value, output.CallId.Value, StringComparison.Ordinal))); - state = Commit(state with { PendingApprovals = remaining }, block, effects); + state = state with + { + PendingApprovals = remaining, + CompletedApprovals = state.CompletedApprovals.Add(block) + }; effects.Add(new ChatPresentationEffect.SetStatus( remaining.IsEmpty ? "Generating..." : "Approval required")); effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); @@ -535,26 +668,23 @@ private static ChatPresentationState CompleteTurn( TurnCompleted completed, List effects) { - state = FlushAssistant(state, completed.TimestampMs, effects); - if (!string.IsNullOrWhiteSpace(state.ThoughtText)) - { - state = CommitThought(state, new ThinkingOutput(state.ThoughtText) - { - SessionId = completed.SessionId, - TimestampMs = completed.TimestampMs - }, effects); - } - - foreach (var tool in state.Tools.Values.OrderBy(tool => tool.StartedAtMs)) + state = FinalizeAssistantPassage(state, null); + var incompleteTools = state.Tools.Values + .Where(tool => tool.CompletedAtMs is null) + .OrderBy(tool => tool.StartedAtMs) + .ToList(); + + if (state.ReplyPassages.Count > 0 + || state.Tools.Count > 0 + || state.SubAgents.Count > 0 + || state.CompletedApprovals.Count > 0) { - state = Commit(state, DiagnosticBlock( - $"tool:{tool.CallId}:incomplete", - $"Tool '{tool.ToolName}' ended without a settled result.", - completed.TimestampMs, - tool.TurnId), effects); + state = Commit(state, BuildSettledReply(state, completed, incompleteTools), effects); } - foreach (var subAgent in state.SubAgents.Values.OrderBy(run => run.StartedAtMs)) + foreach (var subAgent in state.SubAgents.Values + .Where(run => run.CompletedAtMs is null) + .OrderBy(run => run.StartedAtMs)) { state = Commit(state, DiagnosticBlock( $"subagent:{subAgent.RunId}:incomplete", @@ -570,13 +700,165 @@ private static ChatPresentationState CompleteTurn( { Tools = state.Tools.Clear(), SubAgents = state.SubAgents.Clear(), + ReplyPassages = [], + ThoughtText = string.Empty, PendingApprovals = ImmutableQueue.Empty, + CompletedApprovals = [], IsProcessing = false, TurnNumber = Math.Max(state.TurnNumber + 1, completed.TurnNumber.Value + 1), CurrentTurnId = null }; } + private static ChatPresentationBlock BuildSettledReply( + ChatPresentationState state, + TurnCompleted completed, + IReadOnlyCollection incompleteTools) + { + var prose = string.Join("\n\n", state.ReplyPassages + .Select(passage => passage.Text.Trim()) + .Where(text => text.Length > 0)); + var completedToolCount = state.Tools.Count - incompleteTools.Count; + var toolReceipt = state.Tools.Count switch + { + 0 => string.Empty, + 1 when incompleteTools.Count == 0 => "1 tool", + _ when incompleteTools.Count == 0 => $"{state.Tools.Count} tools", + _ => $"{completedToolCount}/{state.Tools.Count} tools completed" + }; + var completedAgentCount = state.SubAgents.Count(run => run.Value.CompletedAtMs is not null); + var agentReceipt = completedAgentCount switch + { + 0 => string.Empty, + 1 => "1 agent", + _ => $"{completedAgentCount} agents" + }; + var approvalReceipt = state.CompletedApprovals.Count switch + { + 0 => string.Empty, + 1 => "1 decision", + _ => $"{state.CompletedApprovals.Count} decisions" + }; + var receiptParts = new[] { toolReceipt, agentReceipt, approvalReceipt } + .Where(value => value.Length > 0) + .ToArray(); + var receipt = receiptParts.Length == 0 + ? string.Empty + : $"{(incompleteTools.Count == 0 ? "Completed work" : "Work stopped")} · {string.Join(" · ", receiptParts)}"; + var summary = string.Join("\n\n", new[] { prose, receipt } + .Where(value => value.Length > 0)); + + var detailParts = new List(); + if (!string.IsNullOrWhiteSpace(state.ThoughtText)) + detailParts.Add($"Reasoning:\n{state.ThoughtText.Trim()}"); + if (state.Tools.Count > 0) + { + var toolDetail = state.Tools.Values + .OrderBy(tool => tool.PassageIndex) + .ThenBy(tool => tool.StartedAtMs) + .Select(ToolDetail); + detailParts.Add($"Work trace:\n{string.Join("\n\n", toolDetail)}"); + } + if (state.SubAgents.Count > 0) + { + var agentDetail = state.SubAgents.Values + .OrderBy(run => run.StartedAtMs) + .Select(SubAgentDetail); + detailParts.Add($"Agent trace:\n{string.Join("\n\n", agentDetail)}"); + } + if (state.CompletedApprovals.Count > 0) + { + detailParts.Add($"Decisions:\n{string.Join("\n\n", state.CompletedApprovals.Select( + approval => approval.Detail ?? approval.Summary))}"); + } + + var detail = string.Join("\n\n", new[] { prose } + .Concat(detailParts) + .Where(value => value.Length > 0)); + var semanticText = detail.Length == 0 ? "NETCLAW" : $"NETCLAW\n{detail}"; + var firstTimestamp = state.ReplyPassages.Count > 0 + ? state.ReplyPassages[0].StartedAtMs + : state.Tools.Count > 0 + ? state.Tools.Values.Min(tool => tool.StartedAtMs) + : state.SubAgents.Count > 0 + ? state.SubAgents.Values.Min(run => run.StartedAtMs) + : state.CompletedApprovals.Min(approval => approval.TimestampMs); + return new ChatPresentationBlock( + $"turn:{state.TurnNumber}:reply", + ChatBlockKind.Assistant, + "NETCLAW", + summary, + semanticText, + firstTimestamp, + state.CurrentTurnId, + detail, + incompleteTools.Count > 0 + || state.CompletedApprovals.Any(approval => approval.IsFailure) + || completed.Outcome == TurnOutcome.Failed); + } + + private static string ToolDetail(ToolActivityPresentation tool) + { + var duration = tool.CompletedAtMs is { } completedAt + ? $"\nDuration: {Math.Max(0, completedAt - tool.StartedAtMs)} ms" + : string.Empty; + return $"{ToolWorkTitle(tool.Rationale)}\nTool: {tool.ToolName}\nCall: {tool.CallId}\nState: {tool.Phase}" + + (tool.ArgumentsJson is null ? string.Empty : $"\nArguments: {tool.ArgumentsJson}") + + (tool.Result is null ? string.Empty : $"\nResult: {tool.Result}") + + duration; + } + + private static string SubAgentDetail(SubAgentActivityPresentation run) => + $"{run.AgentName}\nRun: {run.RunId}\nState: {run.Outcome ?? run.Phase}" + + (run.Summary is null ? string.Empty : $"\nActivity: {run.Summary}") + + (run.Detail is null ? string.Empty : $"\n{run.Detail}"); + + private static ImmutableList EnsureOpenPassage( + ImmutableList passages, + long timestampMs) + { + if (passages.Count > 0 && !passages[^1].IsFinal) + return passages; + + return passages.Add(new ReplyPassagePresentation( + passages.Count, + timestampMs, + string.Empty, + false, + [])); + } + + private static ImmutableList EnsureToolPassage( + ChatPresentationState state, + long timestampMs) + { + if (state.ReplyPassages.Count == 0) + { + return + [ + new ReplyPassagePresentation(0, timestampMs, string.Empty, true, []) + ]; + } + + var last = state.ReplyPassages[^1]; + var hasActiveTool = last.ToolCallIds.Any(callId => + state.Tools.TryGetValue(callId, out var tool) && tool.CompletedAtMs is null); + if (last.ToolCallIds.Count == 0 || hasActiveTool) + { + return state.ReplyPassages.SetItem(state.ReplyPassages.Count - 1, last with + { + IsFinal = true + }); + } + + return state.ReplyPassages.Add(new ReplyPassagePresentation( + state.ReplyPassages.Count, + timestampMs, + string.Empty, + true, + [])); + } + private static ChatPresentationState Commit( ChatPresentationState state, ChatPresentationBlock block, @@ -760,16 +1042,6 @@ private static string ToolWorkTitle(string? rationale) => string.IsNullOrWhiteSp ? "No rationale supplied" : rationale.Trim(); - private static string FirstLine(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - return "complete"; - - var end = text.IndexOfAny(['\r', '\n']); - var first = end < 0 ? text : text[..end]; - return first.Length <= 100 ? first : string.Concat(first.AsSpan(0, 97), "..."); - } - private static ChatPresentationState CommitParallelGroup( ChatPresentationState state, string batchId, diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index b50d0beee..d370bbf13 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -37,6 +37,7 @@ public partial class ChatViewModel : ReactiveViewModel private readonly Subject _outputSubject = new(); private readonly Queue _pendingMessages = new(); + private readonly Queue _queuedTurnMessages = new(); private readonly Queue _pendingInteractions = new(); /// @@ -65,6 +66,7 @@ public partial class ChatViewModel : ReactiveViewModel public ReactiveProperty SessionIdDisplay { get; } = new(null); public ReactiveProperty UsageDisplay { get; } = new(null); public ReactiveProperty UiVersion { get; } = new(0); + internal ReactiveProperty QueuedTurnMessageCount { get; } = new(0); /// /// When true, the approval prompt body renders in full inside the Input @@ -138,6 +140,7 @@ protected virtual Task InitializeSessionAsync() _pendingInteractions.Clear(); RefreshApprovalOptions(); IsGenerating.Value = false; + _ = SendNextQueuedTurnMessageAsync(); break; case ErrorOutput: _pendingInteractions.Clear(); @@ -195,6 +198,14 @@ public virtual async Task SubmitAsync(string text) return; } + if (IsGenerating.Value) + { + _queuedTurnMessages.Enqueue(text); + QueuedTurnMessageCount.Value = _queuedTurnMessages.Count; + RequestRedraw(); + return; + } + if (!_sessionReady || !_daemonClient.IsConnected) { _pendingMessages.Enqueue(text); @@ -232,6 +243,16 @@ public virtual void RequestAppShutdown() Shutdown(); } + private async Task SendNextQueuedTurnMessageAsync() + { + if (_queuedTurnMessages.Count == 0) + return; + + var next = _queuedTurnMessages.Dequeue(); + QueuedTurnMessageCount.Value = _queuedTurnMessages.Count; + await SubmitAsync(next); + } + private async Task SubmitInteractionResponseAsync(string text) { if (!_sessionReady || !_daemonClient.IsConnected) diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 0123364ed..f6cd37ffa 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -47,9 +47,11 @@ public sealed class InlineChatPage : ReactivePage private string? _inspectorBlockKey; private string? _inspectorCopyStatus; private int _inspectorRenderWidth; + private int _thinkingFrame; private ChatPresentationState _state = ChatPresentationState.Empty; private Task _commitTail = Task.CompletedTask; private readonly List _deferredInspectorCommits = []; + private readonly Queue _queuedPromptDisplays = new(); private long? _lastEscapeTimestamp; private int _inspectorIndex; private bool _inspectorOpen; @@ -81,6 +83,17 @@ protected override void OnBound() .WithHistory(100) .WithNewlineModifier(ConsoleModifiers.Shift); _liveRegion = new DynamicLayoutNode(BuildLiveRegion); + var thinkingTimer = new System.Timers.Timer(500) { AutoReset = true }; + thinkingTimer.Elapsed += (_, _) => Post(() => + { + if (!ViewModel.IsGenerating.Value) + return; + + _thinkingFrame = (_thinkingFrame + 1) % 3; + _liveRegion.Invalidate(); + }); + thinkingTimer.Start(); + thinkingTimer.DisposeWith(Subscriptions); _promptInput.Submitted .Where(text => !string.IsNullOrWhiteSpace(text)) @@ -108,6 +121,9 @@ protected override void OnBound() _liveRegion.Invalidate(); }) .DisposeWith(Subscriptions); + ViewModel.QueuedTurnMessageCount + .Subscribe(count => Post(() => PromoteQueuedPrompt(count))) + .DisposeWith(Subscriptions); ViewModel.Input.OfType() .Subscribe(_ => _liveRegion.Invalidate()) .DisposeWith(Subscriptions); @@ -183,6 +199,14 @@ private void SubmitPrompt(string text) { _promptInput.Clear(); _lastEscapeTimestamp = null; + if (ViewModel.IsGenerating.Value) + { + _queuedPromptDisplays.Enqueue(text); + _liveRegion.Invalidate(); + _ = ViewModel.SubmitAsync(text); + return; + } + ApplyReduction(ChatPresentationReducer.RecordUserPrompt( _state, text, @@ -190,6 +214,20 @@ private void SubmitPrompt(string text) _ = ViewModel.SubmitAsync(text); } + private void PromoteQueuedPrompt(int queuedCount) + { + while (_queuedPromptDisplays.Count > queuedCount) + { + var prompt = _queuedPromptDisplays.Dequeue(); + ApplyReduction(ChatPresentationReducer.RecordUserPrompt( + _state, + prompt, + _timeProvider.GetUtcNow().ToUnixTimeMilliseconds())); + } + + _liveRegion.Invalidate(); + } + private void ApplyOutput(SessionOutput output) { ApplyReduction(ChatPresentationReducer.Reduce(_state, output)); @@ -275,9 +313,9 @@ private ILayoutNode BuildLiveRegion() if (_state.Transcript.Count > 0) content.WithChild(Layouts.Empty().Height(1)); content - .WithChild(BuildActivityDeck()) - .WithChild(BuildStreamingAssistant()) + .WithChild(BuildLiveReplyBlock()) .WithChild(BuildSessionHeader()); + content.WithChild(BuildQueueShelf()); if (_state.PendingApproval is not null) content.WithChild(BuildDecisionGate(_state.PendingApproval)); @@ -543,16 +581,52 @@ private ILayoutNode BuildSessionHeader() .Height(1); } - private ILayoutNode BuildActivityDeck() + private ILayoutNode BuildQueueShelf() { - var rows = new List(); + if (_queuedPromptDisplays.Count == 0) + return Layouts.Empty(); + + var count = _queuedPromptDisplays.Count; + var label = count == 1 ? "1 message" : $"{count} messages"; + var preview = ChatPresentationRenderer.OneLine( + _queuedPromptDisplays.Peek(), + Math.Max(20, ReadableWidth() - 20)); + var content = Layouts.Horizontal() + .WithChild(new TextNode($"QUEUED {label} ") + .WithForeground(ChatVisualTheme.Muted) + .Bold()) + .WithChild(new TextNode(preview) + .WithForeground(ChatVisualTheme.Text) + .Fill()); + return new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.Surface) + .WithPadding(1) + .WithContent(content) + .Width(ReadableWidth()) + .Height(3); + } + + private ILayoutNode BuildLiveReplyBlock() + { + var hasReply = _state.ReplyPassages.Count > 0 + || _state.Tools.Count > 0 + || _state.SubAgents.Count > 0 + || !string.IsNullOrWhiteSpace(_state.ThoughtText); + if (!hasReply) + return Layouts.Empty(); + + var reply = Layouts.Vertical() + .WithChild(new TextNode("NETCLAW LIVE") + .WithForeground(ChatVisualTheme.Primary) + .Bold()); var lineWidth = Math.Max(20, ReadableWidth() - 4); if (!string.IsNullOrWhiteSpace(_state.ThoughtText)) { - rows.Add(new TextNode(ChatPresentationRenderer.OneLine( - $"Thought {_state.ThoughtText}", + reply.WithChild(new TextNode(ChatPresentationRenderer.OneLine( + $"Reasoning {_state.ThoughtText}", lineWidth)) - .WithForeground(ChatVisualTheme.Warning)); + .WithForeground(ChatVisualTheme.Muted)); } var agents = _state.SubAgents.Values @@ -560,92 +634,114 @@ private ILayoutNode BuildActivityDeck() .ThenBy(value => value.RunId, StringComparer.Ordinal) .ToList(); var renderedAgents = new HashSet(StringComparer.Ordinal); - foreach (var tool in _state.Tools.Values - .OrderBy(value => value.StartedAtMs) - .ThenBy(value => value.CallId, StringComparer.Ordinal)) + var renderedTools = new HashSet(StringComparer.Ordinal); + foreach (var passage in _state.ReplyPassages.OrderBy(value => value.Index)) { - var childRuns = agents.Where(value => value.ParentCallId == tool.CallId).ToList(); - var awaitsApproval = string.Equals( - _state.PendingApproval?.CallId.Value, - tool.CallId, - StringComparison.Ordinal); - var phase = awaitsApproval - ? "awaiting approval" - : childRuns.Count switch - { - 0 => tool.Phase, - 1 => $"orchestrating {childRuns[0].AgentName}", - _ => $"orchestrating {childRuns.Count} agents" - }; - var state = awaitsApproval ? "Decision" : ActivityState(tool.Phase); - var phaseText = !awaitsApproval && string.Equals(phase, tool.Phase, StringComparison.OrdinalIgnoreCase) - ? string.Empty - : $" {phase}"; - var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; - var action = string.IsNullOrWhiteSpace(tool.Rationale) - ? "No rationale supplied" - : tool.Rationale.Trim(); - rows.Add(new TextNode(ChatPresentationRenderer.OneLine( - $"{state,-8} {action} · {tool.ToolName}{phaseText}{summary}", - lineWidth)) - .WithForeground(ActivityColor(tool.Phase))); + if (!string.IsNullOrWhiteSpace(passage.Text)) + { + reply.WithChild(new TextNode(ChatPresentationRenderer.MarkdownToPlainText(passage.Text)) + .WithForeground(ChatVisualTheme.Text)); + } - foreach (var run in childRuns) + var passageTools = passage.ToolCallIds + .Select(callId => _state.Tools.GetValueOrDefault(callId)) + .Where(tool => tool is not null) + .Cast() + .ToList(); + if (passageTools.Count == 0) + continue; + + reply.WithChild(new TextNode("Work trace") + .WithForeground(ChatVisualTheme.Muted)); + foreach (var group in passageTools.GroupBy(tool => + tool.BatchSize > 1 && tool.BatchId.Length > 0 + ? tool.BatchId + : tool.CallId)) { - rows.Add(BuildAgentActivity(run, lineWidth)); - renderedAgents.Add(run.RunId); + var tools = group.ToList(); + if (tools.Any(tool => tool.BatchSize > 1)) + { + var settled = tools.Count(tool => tool.CompletedAtMs is not null); + reply.WithChild(new TextNode( + $"Parallel work · {settled}/{tools.Max(tool => tool.BatchSize)} complete") + .WithForeground(ChatVisualTheme.Muted)); + } + + foreach (var tool in tools) + { + reply.WithChild(BuildToolActivity(tool, agents, renderedAgents, lineWidth)); + renderedTools.Add(tool.CallId); + } } } + foreach (var tool in _state.Tools.Values + .Where(tool => !renderedTools.Contains(tool.CallId)) + .OrderBy(tool => tool.StartedAtMs)) + { + reply.WithChild(BuildToolActivity(tool, agents, renderedAgents, lineWidth)); + } foreach (var run in agents.Where(value => !renderedAgents.Contains(value.RunId))) - rows.Add(BuildAgentActivity(run, lineWidth)); - - if (rows.Count == 0 && _state.IsProcessing) - rows.Add(new TextNode("Working").WithForeground(ChatVisualTheme.Warning).Bold()); + reply.WithChild(BuildAgentActivity(run, lineWidth)); - if (rows.Count == 0) - return Layouts.Empty(); - - var activity = Layouts.Vertical() - .WithChild(new TextNode("ACTIVITY").WithForeground(ChatVisualTheme.Primary).Bold()); - foreach (var row in rows) - activity.WithChild(row); + var maximumHeight = Math.Max(5, Math.Min(18, _terminal.Height / 2)); + _assistantStream ??= new ScrollableContainerNode() + .WithAutoScroll(AutoScrollPolicy.AlwaysTail) + .WithScrollbar(false); + _assistantStream.WithContent(reply); return new PanelNode() .WithBorder(BorderStyle.None) .WithBackground(ChatVisualTheme.Surface) .WithPadding(1) - .WithContent(activity) - .Width(ReadableWidth()); + .WithContent(_assistantStream.HeightAuto( + min: 1, + max: Math.Max(1, maximumHeight - 2))) + .Width(ReadableWidth()) + .HeightAuto(min: 3, max: maximumHeight); } - private ILayoutNode BuildStreamingAssistant() + private ILayoutNode BuildToolActivity( + ToolActivityPresentation tool, + IReadOnlyCollection agents, + ISet renderedAgents, + int lineWidth) { - if (string.IsNullOrEmpty(_state.AssistantText)) - return Layouts.Empty(); + var childRuns = agents.Where(value => value.ParentCallId == tool.CallId).ToList(); + var awaitsApproval = string.Equals( + _state.PendingApproval?.CallId.Value, + tool.CallId, + StringComparison.Ordinal); + var phase = awaitsApproval + ? "awaiting approval" + : childRuns.Count switch + { + 0 => tool.Phase, + 1 => $"orchestrating {childRuns[0].AgentName}", + _ => $"orchestrating {childRuns.Count} agents" + }; + var state = awaitsApproval ? "Decision" : ActivityState(tool.Phase); + if (string.Equals(state, "Live", StringComparison.Ordinal)) + state = $"Live{new string('.', _thinkingFrame + 1)}"; + var phaseText = !awaitsApproval && string.Equals(phase, tool.Phase, StringComparison.OrdinalIgnoreCase) + ? string.Empty + : $" {phase}"; + var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; + var action = string.IsNullOrWhiteSpace(tool.Rationale) + ? "No rationale supplied" + : tool.Rationale.Trim(); + var rows = Layouts.Vertical() + .WithChild(new TextNode(ChatPresentationRenderer.OneLine( + $"{state,-8} {action} · {tool.ToolName}{phaseText}{summary}", + lineWidth)) + .WithForeground(ActivityColor(tool.Phase))); + foreach (var run in childRuns) + { + rows.WithChild(BuildAgentActivity(run, lineWidth)); + renderedAgents.Add(run.RunId); + } - var maximumHeight = Math.Max(4, Math.Min(14, _terminal.Height / 3)); - _assistantStream ??= new ScrollableContainerNode() - .WithAutoScroll(AutoScrollPolicy.AlwaysTail) - .WithScrollbar(false); - _assistantStream.WithContent( - new TextNode(ChatPresentationRenderer.MarkdownToPlainText( - _state.AssistantText)) - .WithForeground(ChatVisualTheme.Text)); - var content = Layouts.Vertical() - .WithChild(new TextNode("NETCLAW LIVE") - .WithForeground(ChatVisualTheme.Primary) - .Bold()) - .WithChild(_assistantStream.HeightAuto( - min: 1, - max: Math.Max(1, maximumHeight - 3))); - return new PanelNode() - .WithBorder(BorderStyle.None) - .WithBackground(ChatVisualTheme.Surface) - .WithPadding(1) - .WithContent(content) - .Width(ReadableWidth()) - .HeightAuto(min: 4, max: maximumHeight); + return rows; } private ILayoutNode BuildComposer() @@ -778,7 +874,7 @@ private ILayoutNode BuildStatusLine() var status = ViewModel.StatusMessage.Value; var displayStatus = status switch { - "Generating..." => "Working", + "Generating..." => $"Thinking{new string('.', _thinkingFrame + 1)}", "Connecting..." when _terminal.Width < 48 => "Connect", _ => status }; @@ -790,10 +886,17 @@ private ILayoutNode BuildStatusLine() ViewModel.IsApprovalDetailVisible.Value, ShowsComposer(_state), _modifiedEnterKeySupport); + var statusNode = new TextNode( + string.Equals(status, "Generating...", StringComparison.Ordinal) + ? displayStatus.PadRight(12) + : displayStatus) + .WithForeground(StatusColor(status)); + if (string.Equals(status, "Generating...", StringComparison.Ordinal)) + statusNode.Width(12); + else + statusNode.WidthAuto(); var statusLine = Layouts.Horizontal() - .WithChild(new TextNode(displayStatus) - .WithForeground(StatusColor(status)) - .WidthAuto()) + .WithChild(statusNode) .WithChild(new TextNode($" {keys}") .WithForeground(ChatVisualTheme.Muted) .NoWrap() @@ -929,7 +1032,7 @@ private static string StatusKeys( { "queued" => ChatVisualTheme.Muted, "failed" or "error" or "denied" => ChatVisualTheme.Danger, - "completed" or "complete" => ChatVisualTheme.Success, + "completed" or "complete" => ChatVisualTheme.Muted, _ => ChatVisualTheme.Primary }; From 08500c1b0e757aed7ddec4198d51cd3ebaa30afd Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 10:24:56 -0500 Subject: [PATCH 22/39] feat(chat): queue parallel approval decisions --- docs/ui/TUI-002-chat-visual-grammar.md | 22 +++ evals/run-evals.sh | 7 +- .../netclaw-operations/references/chat.md | 6 + .../Cli/DaemonClientSessionTests.cs | 65 +++++++++ .../Tui/ChatPresentationReducerTests.cs | 8 +- .../Tui/InlineChatPageTests.cs | 97 ++++++++++++++ src/Netclaw.Cli/Tui/ChatPresentation.cs | 19 +++ src/Netclaw.Cli/Tui/ChatViewModel.cs | 125 ++++++++++++------ src/Netclaw.Cli/Tui/InlineChatPage.cs | 93 ++++++++++--- 9 files changed, 384 insertions(+), 58 deletions(-) diff --git a/docs/ui/TUI-002-chat-visual-grammar.md b/docs/ui/TUI-002-chat-visual-grammar.md index 3b49238b5..ef31b1713 100644 --- a/docs/ui/TUI-002-chat-visual-grammar.md +++ b/docs/ui/TUI-002-chat-visual-grammar.md @@ -266,6 +266,27 @@ Decision needed Up/Down select Enter confirm Ctrl+O details Esc deny The Decision Sheet stays inside the current Reply Block. The Sheet hides the Composer until the user makes a decision. +## Serial Approval Queue + +Netclaw shows one Decision Sheet at a time. +The queue head owns the sheet and keyboard input. +Other approval requests stay in the Work Trace with a `Waiting` state. +The sheet header shows `1 of N` when the queue contains multiple requests. +Each choice targets one exact `CallId`. +Netclaw waits for that approval outcome before it shows the next sheet. + +```text + Decision List workspaces · shell_execute awaiting decision + Waiting Run diagnostics · shell_execute decision 2 of 2 + + Approval required 1 of 2 Netclaw requests permission to run shell_execute +``` + +A grant does not resolve another queued request. +The daemon can authorize a later request if a persistent grant covers it. +A denial affects only the request in the current sheet. +The next sheet keeps keyboard focus when it replaces the prior sheet. + ## ASCII Mockup: Narrow Form at 48 Columns ```text @@ -305,6 +326,7 @@ Composer --Enter--> Reply Block --Turn ends--> Settled Turn The Reply Block receives all events for the current Turn. The Composer remains live during model, tool, and sub-agent work. The Decision Sheet owns input before the Composer. +The approval queue exposes only its head as a Decision Sheet. ## Content Rules diff --git a/evals/run-evals.sh b/evals/run-evals.sh index 9c971ccac..4937ad612 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -1105,6 +1105,7 @@ assert_skill_chat_tui_knowledge() { && stdout_contains 'Queue Shelf' \ && stdout_contains 'Session Strip' \ && stdout_contains 'Pulse Line' \ + && stdout_contains 'one.*approval\|approval.*one' \ && stdout_no_skill_file_read_called } @@ -1923,9 +1924,9 @@ run_all() { "My session seems broken, help me fix it" \ "Debug my Netclaw session" - run_case skill_chat_tui_knowledge "knows the chat queue and bottom dock" \ - "While netclaw chat is busy, can I send another message? Where does it go, and which named regions remain in the bottom dock? Use the exact UI region names." \ - "Explain prompt queue behavior during active Netclaw work. Name the persistent session and wait-state rows at the bottom." + run_case skill_chat_tui_knowledge "knows the chat queue, approvals, and bottom dock" \ + "While netclaw chat is busy, where does another message go? Name the bottom regions. Also explain how parallel approvals appear." \ + "Explain prompt queue and parallel approval behavior. Name the persistent session and wait-state rows at the bottom." run_case skill_citation_search "performs web search when asked" \ "Search the web for the latest Akka.NET release" \ diff --git a/feeds/skills/.system/files/netclaw-operations/references/chat.md b/feeds/skills/.system/files/netclaw-operations/references/chat.md index a714f5a43..8a9120ab1 100644 --- a/feeds/skills/.system/files/netclaw-operations/references/chat.md +++ b/feeds/skills/.system/files/netclaw-operations/references/chat.md @@ -19,6 +19,12 @@ The Composer stays available while the model or a tool works. A prompt that you send during active work enters the Queue Shelf for the next turn. An approval gate replaces the Composer until the user makes a decision. +Netclaw shows one approval gate at a time. Parallel approval requests enter one +serial queue. The queue head owns the gate and keyboard input. Other requests +stay visible in the Work Trace with a `Waiting` state. A decision targets one +exact tool call. Netclaw waits for its outcome before it shows the next gate. +A persistent grant can let the daemon authorize a later queued request. + The Session Strip stays in the persistent bottom dock with the Composer or approval gate. The Pulse Line stays at the bottom and shows the current wait state. diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index f295c611c..36eacbcfe 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -15,6 +15,7 @@ using Netclaw.Cli.Tui; using Netclaw.Configuration; using Netclaw.Daemon.Gateway; +using Netclaw.Tools; using R3; using Xunit; using static Netclaw.Actors.Sessions.SessionProtocol; @@ -120,6 +121,70 @@ public async Task RespondToInteractionAsync_supports_session_scope() Assert.Equal(("call-2", ApprovalOptionKeys.ApproveSession), state.LastInteractionResponse); } + [Fact] + public async Task ChatViewModel_keeps_queue_head_until_approval_outcome_arrives() + { + var transport = new FakeDaemonHubTransport(); + await using var client = new DaemonClient( + "http://127.0.0.1:1", + transport, + reconnectDelays: [TimeSpan.Zero]); + using var viewModel = new ChatViewModel( + client, + TimeProvider.System, + new ModelCapabilities { ModelId = "test-model" }, + new ChatNavigationState(), + new NetclawPaths()); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = viewModel.SessionIdDisplay.Subscribe(value => + { + if (string.Equals(value, "fake/session", StringComparison.Ordinal)) + ready.TrySetResult(); + }); + + viewModel.OnActivated(); + await ready.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + var first = Approval("call-a", 1); + var second = Approval("call-b", 2); + viewModel.SeedPendingInteractionForTesting(first); + viewModel.SeedPendingInteractionForTesting(second); + + await viewModel.SubmitInteractionOptionAsync(first.CallId, ApprovalOptionKeys.ApproveOnceLabel); + + Assert.Equal("call-a", viewModel.CurrentInteraction?.CallId.Value); + Assert.Contains(transport.Invocations, invocation => + string.Equals(invocation.Method, "RespondToInteraction", StringComparison.Ordinal)); + + transport.PushOutput(SessionOutputDtoMapper.ToDto(new ApprovalOutcomeOutput + { + SessionId = new SessionId("fake/session"), + TimestampMs = 3, + CallId = first.CallId, + ToolName = first.ToolName, + SelectedKey = ApprovalOptionKeys.ApproveOnceKey + })); + + Assert.Equal("call-b", viewModel.CurrentInteraction?.CallId.Value); + Assert.Equal("Approval required", viewModel.StatusMessage.Value); + } + + private static ToolInteractionRequest Approval(string callId, long timestampMs) => new() + { + SessionId = new SessionId("fake/session"), + TimestampMs = timestampMs, + Kind = "approval", + CallId = new ToolCallId(callId), + ToolName = new ToolName("shell_execute"), + DisplayText = $"inspect {callId}", + Options = + [ + new ToolInteractionOption( + ApprovalOptionKeys.ApproveOnceKey, + ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.DenyKey, ApprovalOptionKeys.DenyLabel) + ] + }; + // port: 0 (default) lets Kestrel bind a free ephemeral port and hold it for the // host's lifetime; callers read the actual port back via TestNetworkHelpers.GetBoundPort. private static async Task StartFakeHubAsync(int port = 0) diff --git a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs index 60b5086c1..3544cc21e 100644 --- a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs @@ -119,11 +119,16 @@ public void Approval_outcome_removes_only_its_request_and_commits_the_decision() const string secondCallId = "parent-b/subagent-approval/approval-b"; var state = Apply(ChatPresentationState.Empty, Approval(firstCallId, 1)); state = Apply(state, Approval(secondCallId, 2)); + state = Apply(state, Approval(firstCallId, 3)); + + Assert.Equal(2, state.PendingApprovalCount); + Assert.Equal(1, state.ApprovalQueuePosition(firstCallId)); + Assert.Equal(2, state.ApprovalQueuePosition(secondCallId)); state = Apply(state, new ApprovalOutcomeOutput { SessionId = SessionId, - TimestampMs = 3, + TimestampMs = 4, CallId = new ToolCallId(firstCallId), ToolName = new ToolName("shell_execute"), ParentCallId = "parent-a", @@ -131,6 +136,7 @@ public void Approval_outcome_removes_only_its_request_and_commits_the_decision() }); Assert.Equal(secondCallId, state.PendingApproval?.CallId.Value); + Assert.Equal(1, state.ApprovalQueuePosition(secondCallId)); Assert.Empty(state.Transcript); var decision = Assert.Single(state.CompletedApprovals); Assert.True(decision.IsFailure); diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index cf17dd6df..e8f250616 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -231,6 +231,56 @@ public async Task Escape_DeniesAnApproval() await harness.StopAsync(runTask); } + [Fact] + public async Task ParallelApprovals_ShowOneDecisionGateAndQueueTheRemainingRequests() + { + var firstApproval = BuildApproval() with + { + CallId = new ToolCallId("call-a"), + DisplayText = "first protected command" + }; + var secondApproval = BuildApproval() with + { + CallId = new ToolCallId("call-b"), + DisplayText = "second protected command" + }; + var outputs = new SessionOutput[] + { + ToolCall("call-a", "shell_execute", 1), + ToolCall("call-b", "shell_execute", 2), + firstApproval, + secondApproval + }; + await using var harness = CreateHarness(outputs: outputs); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("Approval required 1 of 2") + && harness.Terminal.Contains("first protected command") + && harness.Terminal.Contains("Waiting")); + + var firstScreen = harness.Terminal.ToString(); + Assert.Contains("Decision Inspect call-a", firstScreen, StringComparison.Ordinal); + Assert.Contains("Waiting Inspect call-b", firstScreen, StringComparison.Ordinal); + Assert.DoesNotContain("second protected command", firstScreen, StringComparison.Ordinal); + + harness.ViewModel.Emit(new ApprovalOutcomeOutput + { + SessionId = SessionId, + TimestampMs = 3, + CallId = new ToolCallId("call-a"), + ToolName = new ToolName("shell_execute"), + SelectedKey = ApprovalOptionKeys.ApproveOnceKey + }); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("second protected command")); + + var secondScreen = harness.Terminal.ToString(); + Assert.DoesNotContain("Approval required 1 of 2", secondScreen, StringComparison.Ordinal); + Assert.Contains("Approval required Netclaw", secondScreen, StringComparison.Ordinal); + Assert.Contains("Decision Inspect call-b", secondScreen, StringComparison.Ordinal); + Assert.NotNull(harness.Focus.CurrentFocus); + await harness.StopAsync(runTask); + } + [Fact] public async Task CtrlO_ShowsApprovalSecurityContext() { @@ -542,6 +592,53 @@ await harness.WaitUntilAsync(() => await harness.StopAsync(runTask); } + [Fact] + public async Task ParallelSubagentApprovals_ShowDecisionAndWaitingStates() + { + var outputs = new SessionOutput[] + { + new SubAgentOutput + { + SessionId = SessionId, + TimestampMs = 1, + AgentName = new AgentName("reviewer-a"), + Phase = SubAgentPhase.Started, + RunId = new SubAgentRunId("run-a"), + ParentCallId = new ToolCallId("parent-a") + }, + new SubAgentOutput + { + SessionId = SessionId, + TimestampMs = 2, + AgentName = new AgentName("reviewer-b"), + Phase = SubAgentPhase.Started, + RunId = new SubAgentRunId("run-b"), + ParentCallId = new ToolCallId("parent-b") + }, + BuildApproval() with + { + CallId = new ToolCallId("parent-a/subagent-approval/approval-a") + }, + BuildApproval() with + { + CallId = new ToolCallId("parent-b/subagent-approval/approval-b") + } + }; + await using var harness = CreateHarness(outputs: outputs); + var runTask = harness.StartAsync(); + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("Approval required 1 of 2") + && harness.Terminal.Contains("reviewer-a") + && harness.Terminal.Contains("reviewer-b")); + + var screen = harness.Terminal.ToString(); + Assert.Contains("Decision Agent reviewer-a", screen, StringComparison.Ordinal); + Assert.Contains("Waiting Agent reviewer-b", screen, StringComparison.Ordinal); + Assert.Contains("reviewer-a requests permission", screen, StringComparison.Ordinal); + Assert.DoesNotContain("reviewer-b requests permission", screen, StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + [Fact] public async Task AssistantMarkdown_UsesPlainDisplayAndKeepsSemanticCopy() { diff --git a/src/Netclaw.Cli/Tui/ChatPresentation.cs b/src/Netclaw.Cli/Tui/ChatPresentation.cs index ffbd11a13..e7e39a487 100644 --- a/src/Netclaw.Cli/Tui/ChatPresentation.cs +++ b/src/Netclaw.Cli/Tui/ChatPresentation.cs @@ -111,6 +111,22 @@ internal sealed record ChatPresentationState public ToolInteractionRequest? PendingApproval => PendingApprovals.IsEmpty ? null : PendingApprovals.Peek(); + + public int PendingApprovalCount => PendingApprovals.Count(); + + public int ApprovalQueuePosition(string callId) + { + var position = 1; + foreach (var approval in PendingApprovals) + { + if (string.Equals(approval.CallId.Value, callId, StringComparison.Ordinal)) + return position; + + position++; + } + + return 0; + } } internal abstract record ChatPresentationEffect @@ -617,6 +633,9 @@ private static ChatPresentationState ShowApproval( ToolInteractionRequest approval, List effects) { + if (state.ApprovalQueuePosition(approval.CallId.Value) > 0) + return state; + effects.Add(new ChatPresentationEffect.ShowApproval(approval)); effects.Add(new ChatPresentationEffect.SetStatus("Approval required")); effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index d370bbf13..fd892b000 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -9,6 +9,7 @@ using Netclaw.Actors.Protocol; using Netclaw.Cli.Daemon; using Netclaw.Configuration; +using Netclaw.Tools; using R3; using Termina.Reactive; using static Netclaw.Actors.Sessions.SessionProtocol; @@ -48,6 +49,7 @@ public partial class ChatViewModel : ReactiveViewModel /// (prompt re-presented for retry) alike. /// private bool _isSubmittingInteraction; + private string? _submittedInteractionCallId; private IDisposable? _daemonOutputSubscription; private IDisposable? _daemonConnectionSubscription; // Per-session USAGE log writer. Mirrors HeadlessChannel's writer so the @@ -131,19 +133,39 @@ protected virtual Task InitializeSessionAsync() switch (output) { case ToolInteractionRequest interaction: - _pendingInteractions.Enqueue(interaction); + EnqueuePendingInteraction(interaction); RefreshApprovalOptions(); IsGenerating.Value = false; StatusMessage.Value = "Approval required"; break; + case ApprovalOutcomeOutput outcome: + RemovePendingInteraction(outcome.CallId.Value); + if (string.Equals( + _submittedInteractionCallId, + outcome.CallId.Value, + StringComparison.Ordinal)) + { + _submittedInteractionCallId = null; + _isSubmittingInteraction = false; + } + RefreshApprovalOptions(); + IsGenerating.Value = _pendingInteractions.Count == 0; + StatusMessage.Value = _pendingInteractions.Count == 0 + ? "Generating..." + : "Approval required"; + break; case TurnCompleted: _pendingInteractions.Clear(); + _submittedInteractionCallId = null; + _isSubmittingInteraction = false; RefreshApprovalOptions(); IsGenerating.Value = false; _ = SendNextQueuedTurnMessageAsync(); break; case ErrorOutput: _pendingInteractions.Clear(); + _submittedInteractionCallId = null; + _isSubmittingInteraction = false; RefreshApprovalOptions(); IsGenerating.Value = false; break; @@ -274,37 +296,29 @@ private async Task SubmitInteractionResponseAsync(string text) return; } - var pending = _pendingInteractions.Peek(); + await SubmitInteractionSelectionAsync(selectedKey); + } - try - { - await _daemonClient.EnsureSessionAsync(DaemonClient.TuiChannelType); - await _daemonClient.RespondToInteractionAsync(pending.CallId.Value, selectedKey); + public Task SubmitInteractionOptionAsync(string optionLabel) + { + if (CurrentInteraction is not { } interaction) + return Task.CompletedTask; - _pendingInteractions.Dequeue(); - RefreshApprovalOptions(); - IsGenerating.Value = _pendingInteractions.Count == 0; - StatusMessage.Value = _pendingInteractions.Count == 0 - ? "Generating..." - : "Approval required"; - RequestRedraw(); - } - catch (Exception ex) - { - _sessionReady = false; - IsGenerating.Value = false; - StatusMessage.Value = $"Approval response failed ({ex.Message}). Reconnecting..."; - RequestRedraw(); - _ = ConnectUntilReadyAsync(); - } + return SubmitInteractionOptionAsync(interaction.CallId, optionLabel); } - public Task SubmitInteractionOptionAsync(string optionLabel) + internal Task SubmitInteractionOptionAsync(ToolCallId expectedCallId, string optionLabel) { - if (CurrentInteraction is null) + if (CurrentInteraction is not { } interaction + || !string.Equals( + interaction.CallId.Value, + expectedCallId.Value, + StringComparison.Ordinal)) + { return Task.CompletedTask; + } - var option = CurrentInteraction.Options.FirstOrDefault(candidate => + var option = interaction.Options.FirstOrDefault(candidate => string.Equals(candidate.Label, optionLabel, StringComparison.Ordinal)); if (option is null) return Task.CompletedTask; @@ -322,10 +336,24 @@ public Task SubmitInteractionOptionAsync(string optionLabel) /// internal virtual Task DenyPendingInteractionAsync() { - if (CurrentInteraction is null) + if (CurrentInteraction is not { } interaction) return Task.CompletedTask; - var denyOption = CurrentInteraction.Options.FirstOrDefault(candidate => + return DenyPendingInteractionAsync(interaction.CallId); + } + + internal Task DenyPendingInteractionAsync(ToolCallId expectedCallId) + { + if (CurrentInteraction is not { } interaction + || !string.Equals( + interaction.CallId.Value, + expectedCallId.Value, + StringComparison.Ordinal)) + { + return Task.CompletedTask; + } + + var denyOption = interaction.Options.FirstOrDefault(candidate => string.Equals(candidate.Key.Value, ApprovalOptionKeys.Deny, StringComparison.Ordinal)); if (denyOption is null) return Task.CompletedTask; @@ -414,7 +442,7 @@ public void ToggleApprovalDetail() internal void SeedPendingInteractionForTesting(ToolInteractionRequest interaction) { _outputSubject.OnNext(interaction); - _pendingInteractions.Enqueue(interaction); + EnqueuePendingInteraction(interaction); RefreshApprovalOptions(); IsGenerating.Value = false; StatusMessage.Value = "Approval required"; @@ -597,29 +625,52 @@ protected virtual async Task SubmitInteractionSelectionAsync(string selectedKey) try { _isSubmittingInteraction = true; + _submittedInteractionCallId = pending.CallId.Value; await _daemonClient.EnsureSessionAsync(DaemonClient.TuiChannelType); await _daemonClient.RespondToInteractionAsync(pending.CallId.Value, selectedKey); - _pendingInteractions.Dequeue(); - RefreshApprovalOptions(); - IsGenerating.Value = _pendingInteractions.Count == 0; - StatusMessage.Value = _pendingInteractions.Count == 0 - ? "Generating..." - : "Approval required"; - RequestRedraw(); + if (string.Equals( + _submittedInteractionCallId, + pending.CallId.Value, + StringComparison.Ordinal)) + { + StatusMessage.Value = "Submitting decision..."; + RequestRedraw(); + } } catch (Exception ex) { + _submittedInteractionCallId = null; + _isSubmittingInteraction = false; _sessionReady = false; IsGenerating.Value = false; StatusMessage.Value = $"Approval response failed ({ex.Message}). Reconnecting..."; RequestRedraw(); _ = ConnectUntilReadyAsync(); } - finally + } + + private void EnqueuePendingInteraction(ToolInteractionRequest interaction) + { + if (_pendingInteractions.Any(pending => string.Equals( + pending.CallId.Value, + interaction.CallId.Value, + StringComparison.Ordinal))) { - _isSubmittingInteraction = false; + return; } + + _pendingInteractions.Enqueue(interaction); + } + + private void RemovePendingInteraction(string callId) + { + var remaining = _pendingInteractions + .Where(pending => !string.Equals(pending.CallId.Value, callId, StringComparison.Ordinal)) + .ToArray(); + _pendingInteractions.Clear(); + foreach (var pending in remaining) + _pendingInteractions.Enqueue(pending); } private void RefreshApprovalOptions() diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index f6cd37ffa..5ae3aa614 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -237,6 +237,7 @@ private void ApplyReduction(ChatReduction reduction) { var hadComposer = ShowsComposer(_state); var hadApproval = _state.PendingApproval is not null; + var priorApprovalCallId = _state.PendingApproval?.CallId.Value; _state = reduction.State; foreach (var effect in reduction.Effects) @@ -259,7 +260,11 @@ private void ApplyReduction(ChatReduction reduction) var hasApproval = _state.PendingApproval is not null; var hasComposer = ShowsComposer(_state); - if (hadApproval != hasApproval || hadComposer != hasComposer) + var approvalHeadChanged = !string.Equals( + priorApprovalCallId, + _state.PendingApproval?.CallId.Value, + StringComparison.Ordinal); + if (hadApproval != hasApproval || hadComposer != hasComposer || approvalHeadChanged) { if (!hasApproval) ClearApprovalList(); @@ -708,22 +713,29 @@ private ILayoutNode BuildToolActivity( int lineWidth) { var childRuns = agents.Where(value => value.ParentCallId == tool.CallId).ToList(); - var awaitsApproval = string.Equals( - _state.PendingApproval?.CallId.Value, - tool.CallId, - StringComparison.Ordinal); - var phase = awaitsApproval - ? "awaiting approval" + var approvalPosition = _state.ApprovalQueuePosition(tool.CallId); + var isCurrentApproval = approvalPosition == 1; + var waitsForApproval = approvalPosition > 1; + var phase = isCurrentApproval + ? "awaiting decision" + : waitsForApproval + ? $"decision {approvalPosition} of {_state.PendingApprovalCount}" : childRuns.Count switch { 0 => tool.Phase, 1 => $"orchestrating {childRuns[0].AgentName}", _ => $"orchestrating {childRuns.Count} agents" }; - var state = awaitsApproval ? "Decision" : ActivityState(tool.Phase); + var state = isCurrentApproval + ? "Decision" + : waitsForApproval + ? "Waiting" + : ActivityState(tool.Phase); if (string.Equals(state, "Live", StringComparison.Ordinal)) state = $"Live{new string('.', _thinkingFrame + 1)}"; - var phaseText = !awaitsApproval && string.Equals(phase, tool.Phase, StringComparison.OrdinalIgnoreCase) + var phaseText = !isCurrentApproval + && !waitsForApproval + && string.Equals(phase, tool.Phase, StringComparison.OrdinalIgnoreCase) ? string.Empty : $" {phase}"; var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; @@ -734,7 +746,11 @@ private ILayoutNode BuildToolActivity( .WithChild(new TextNode(ChatPresentationRenderer.OneLine( $"{state,-8} {action} · {tool.ToolName}{phaseText}{summary}", lineWidth)) - .WithForeground(ActivityColor(tool.Phase))); + .WithForeground(isCurrentApproval + ? ChatVisualTheme.Warning + : waitsForApproval + ? ChatVisualTheme.Muted + : ActivityColor(tool.Phase))); foreach (var run in childRuns) { rows.WithChild(BuildAgentActivity(run, lineWidth)); @@ -768,11 +784,14 @@ private ILayoutNode BuildDecisionGate(ToolInteractionRequest approval) var maximumHeight = ViewModel.IsApprovalDetailVisible.Value ? detailHeight + approval.Options.Count + 4 : approval.Options.Count + 5; + var queuePosition = _state.PendingApprovalCount > 1 + ? $" 1 of {_state.PendingApprovalCount}" + : string.Empty; var header = new PanelNode() .WithBorder(BorderStyle.None) .WithBackground(ChatVisualTheme.ApprovalHeader) .WithContent(new TextNode( - $"Approval required {ChatPresentationRenderer.ApprovalPath(_state, approval)}") + $"Approval required{queuePosition} {ChatPresentationRenderer.ApprovalPath(_state, approval)}") .WithForeground(ChatVisualTheme.Warning) .Bold()) .Height(1); @@ -830,7 +849,7 @@ private SelectionListNode EnsureApprovalList() var option = approval.Options.FirstOrDefault(candidate => string.Equals(ApprovalOptionLabel(candidate), selected[0], StringComparison.Ordinal)); if (option is not null) - _ = ViewModel.SubmitInteractionOptionAsync(option.Label); + _ = ViewModel.SubmitInteractionOptionAsync(approval.CallId, option.Label); } }) .DisposeWith(_approvalSubscriptions); @@ -926,16 +945,33 @@ private ILayoutNode WithViewportMargin(ILayoutNode content) .WithChild(Layouts.Empty().Fill()); } - private static ILayoutNode BuildAgentActivity(SubAgentActivityPresentation run, int lineWidth) + private ILayoutNode BuildAgentActivity(SubAgentActivityPresentation run, int lineWidth) { + var approvalPosition = SubAgentApprovalQueuePosition(run); + var isCurrentApproval = approvalPosition == 1; + var waitsForApproval = approvalPosition > 1; var summary = string.IsNullOrWhiteSpace(run.Summary) ? string.Empty : $" {run.Summary}"; var prefix = run.ParentCallId is null ? " " : " "; + var state = isCurrentApproval + ? "Decision" + : waitsForApproval + ? "Waiting" + : ActivityState(run.Phase); + var phase = isCurrentApproval + ? "awaiting decision" + : waitsForApproval + ? $"decision {approvalPosition} of {_state.PendingApprovalCount}" + : run.Phase; var rows = new List { new TextNode(ChatPresentationRenderer.OneLine( - $"{prefix}{ActivityState(run.Phase),-8} Agent {run.AgentName} {run.Phase}{summary}", + $"{prefix}{state,-8} Agent {run.AgentName} {phase}{summary}", lineWidth)) - .WithForeground(ActivityColor(run.Phase)) + .WithForeground(isCurrentApproval + ? ChatVisualTheme.Warning + : waitsForApproval + ? ChatVisualTheme.Muted + : ActivityColor(run.Phase)) }; if (run.ActiveToolName is not null) { @@ -949,6 +985,29 @@ private static ILayoutNode BuildAgentActivity(SubAgentActivityPresentation run, return Layouts.Vertical([.. rows]); } + private int SubAgentApprovalQueuePosition(SubAgentActivityPresentation run) + { + if (run.ParentCallId is null) + return 0; + + const string marker = "/subagent-approval/"; + var position = 1; + foreach (var approval in _state.PendingApprovals) + { + var callId = approval.CallId.Value; + var markerIndex = callId.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex > 0 + && string.Equals(callId[..markerIndex], run.ParentCallId, StringComparison.Ordinal)) + { + return position; + } + + position++; + } + + return 0; + } + private string BuildApprovalDetail(ToolInteractionRequest approval) { var lines = new List @@ -1102,10 +1161,10 @@ private int FindDefaultInspectorIndex() private void HandleEscape() { - if (_state.PendingApproval is not null) + if (_state.PendingApproval is { } approval) { _lastEscapeTimestamp = null; - _ = ViewModel.DenyPendingInteractionAsync(); + _ = ViewModel.DenyPendingInteractionAsync(approval.CallId); return; } From 79c5831320ce042f77ade7b6d3c3c2cfc6c60a8d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 11:36:42 -0500 Subject: [PATCH 23/39] test(chat): await the complete approval detail --- src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index e8f250616..b9ca1f404 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -303,7 +303,9 @@ public async Task CtrlO_ShowsApprovalSecurityContext() await harness.WaitUntilAsync(() => { screen = harness.Terminal.ToString(); - return screen.Contains("Patterns: dotnet", StringComparison.Ordinal) + return screen.Contains("Requester: Netclaw", StringComparison.Ordinal) + && screen.Contains("Action: Run shell_execute", StringComparison.Ordinal) + && screen.Contains("Patterns: dotnet", StringComparison.Ordinal) && screen.Contains("Verbs: dotnet", StringComparison.Ordinal) && screen.Contains("Directory: /work/netclaw", StringComparison.Ordinal) && screen.Contains("Complex command", StringComparison.Ordinal) From 20a92a37891c22411b4afd5a1b40d5c4a5236593 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 19:55:50 -0500 Subject: [PATCH 24/39] spec(chat): correct the active-turn contract --- docs/prd/PRD-004-cli-onboarding-and-config.md | 4 +- docs/spec/SPEC-004-cli-contract.md | 4 +- docs/ui/TUI-001-command-wireframes.md | 26 ++++++--- docs/ui/TUI-002-chat-visual-grammar.md | 10 +++- .../redesign-netclaw-chat-tui/design.md | 35 +++++++++++- .../redesign-netclaw-chat-tui/proposal.md | 9 ++- .../specs/netclaw-chat-tui/spec.md | 47 ++++++++++++++-- .../specs/netclaw-testing/spec.md | 19 ++++++- .../specs/tool-call-metadata/spec.md | 55 +++++++++++++++++++ .../redesign-netclaw-chat-tui/tasks.md | 5 ++ 10 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 openspec/changes/redesign-netclaw-chat-tui/specs/tool-call-metadata/spec.md diff --git a/docs/prd/PRD-004-cli-onboarding-and-config.md b/docs/prd/PRD-004-cli-onboarding-and-config.md index c6c457fd4..763d7f7b7 100644 --- a/docs/prd/PRD-004-cli-onboarding-and-config.md +++ b/docs/prd/PRD-004-cli-onboarding-and-config.md @@ -322,7 +322,9 @@ interactive TUI for agent conversations. The TUI SHALL: - Keep the settled transcript borderless in native terminal scrollback - Show model name, token usage, and context percentage in the Session Header - Use bare `Enter` to submit and `Shift+Enter` to add a newline -- Keep the Composer active while the agent works and send later prompts to the session queue +- Keep the Composer active while the agent works +- Show every later prompt in the ordered Queue Shelf +- Send all later prompts through the session queue for one FIFO follow-up model call - Show assistant text as each streaming delta arrives - Restore a saved draft after prompt history reaches its newest entry - Clear prompt text only after two Escape keys inside a `TimeProvider` window diff --git a/docs/spec/SPEC-004-cli-contract.md b/docs/spec/SPEC-004-cli-contract.md index 094e725a4..abc5ac124 100644 --- a/docs/spec/SPEC-004-cli-contract.md +++ b/docs/spec/SPEC-004-cli-contract.md @@ -112,7 +112,9 @@ Setup, config, provider, model, and session picker applications retain The chat composer uses bare `Enter` for submit and `Shift+Enter` for a newline. A model call does not disable or hide the composer. Later prompts enter the -session actor queue while the current turn runs. The live region shows assistant +session actor queue while the current turn runs. The actor retains all accepted +prompts in FIFO order and includes them in one follow-up model call. The client +does not start one turn for each queued prompt. The live region shows assistant text as each stream delta arrives. A pending approval owns input before the composer. `Ctrl+O` changes approval detail without a decision. diff --git a/docs/ui/TUI-001-command-wireframes.md b/docs/ui/TUI-001-command-wireframes.md index 34cd41e5f..c12cf3c74 100644 --- a/docs/ui/TUI-001-command-wireframes.md +++ b/docs/ui/TUI-001-command-wireframes.md @@ -130,11 +130,11 @@ the current live region. | Session Header | Shows session, model, context, and connection state | Printed when context changes | | Transcript | Holds immutable settled Turns | Terminal scrollback | | Turn | Groups one prompt with its settled events and reply | Immutable after settlement | -| Live Deck | Replaces the Composer during active work | Mutable live region | +| Live Deck | Shows current work above the bottom dock | Mutable live region | | Activity Group | Groups parallel tools and sub-agents for one turn | Live until every row settles | | Event Row | Shows one event identity, phase, summary, and detail state | Live or settled | | Decision Gate | Replaces the Composer for a pending approval | Live until a decision | -| Composer | Accepts the next prompt | Live while idle | +| Composer | Accepts the next prompt | Live except during a decision | | Hint Line | Shows only valid actions for the current mode | Live | | Inspector | Shows complete safe detail for one event or Turn | Temporary full-screen view | @@ -178,10 +178,18 @@ ACTIVITY 2 active · 1 complete ● tool read failure log 0.8s #call-c Working… Ctrl+C interrupt Ctrl+O detail Ctrl+Q quit + +QUEUED 2 messages + 1 Also inspect the failed test history. + 2 Then propose the smallest deterministic fix. + +MESSAGE + Ask Netclaw… ``` -The Live Deck replaces the Composer during an active turn. The first release -does not accept prompt type-ahead. +The Live Deck shows current work above the bottom dock. The Composer remains +available during an active turn. The Queue Shelf shows every accepted prompt. +The session actor includes the complete FIFO set in one follow-up model call. ### Decision Gate at 80 Columns @@ -245,11 +253,11 @@ available through the Inspector and semantic copy. ### Flow Control ``` -Composer --Enter--> Live Deck --settled events--> Transcript --ready--> Composer - | - +--approval--> Decision Gate --decision--> Live Deck - | - +--inspect--> Inspector --close--> queued output commit +Composer --Enter--> Live Deck --settled events--> Transcript + | | + | +--approval--> Decision Gate --decision--> Live Deck + | +--inspect--> Inspector --close--> queued output commit + +--Enter while live--> Queue Shelf --turn end--> one FIFO follow-up call ``` Settled events print once in chronological order. A settled event never returns diff --git a/docs/ui/TUI-002-chat-visual-grammar.md b/docs/ui/TUI-002-chat-visual-grammar.md index ef31b1713..4361f04d1 100644 --- a/docs/ui/TUI-002-chat-visual-grammar.md +++ b/docs/ui/TUI-002-chat-visual-grammar.md @@ -91,6 +91,8 @@ The design uses no more than three emphasis colors in one region. - Child tools and sub-agents use a six-cell indent. - The Composer uses two text rows plus the Pulse Line. - A settled Turn uses one blank line before the next Turn. +- A speaker change uses one blank line between the settled blocks. +- The bottom dock uses one blank line between its stacked interactive surfaces. ## Reply Block Grammar @@ -102,6 +104,8 @@ The Work Trace uses the model-supplied tool rationale as each action title. The tool name remains secondary metadata. The client does not infer an action title from tool arguments. It does not expose raw JSON in the Transcript. +A new tool call without a rationale fails before tool dispatch. +An old transcript entry can show that its rationale is unavailable. Examples: @@ -149,7 +153,9 @@ Enter sends a later prompt to the session queue. The Queue Shelf shows each accepted prompt above the Composer. The Queue Shelf does not interrupt the current Reply Block. -The prompt enters the next Turn after the current Turn ends. +All displayed prompts enter the next model call in FIFO order. +The session actor promotes the complete set together after the current Turn. +The client does not send one queued prompt after each completed Turn. A Decision Sheet is the only state that hides the Composer. This exception prevents prompt text from reaching an approval control. @@ -318,7 +324,7 @@ It keeps the speaker, action, outcome, queue, input, and decision state. ```text Composer --Enter--> Reply Block --Turn ends--> Settled Turn | | | - | +--tool--> Work Trace +--> next queued Turn + | +--tool--> Work Trace +--> one queued batch | +--approval--> Decision Sheet +--Enter while live--> Queue Shelf ``` diff --git a/openspec/changes/redesign-netclaw-chat-tui/design.md b/openspec/changes/redesign-netclaw-chat-tui/design.md index 469c7073e..680550f3b 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/design.md +++ b/openspec/changes/redesign-netclaw-chat-tui/design.md @@ -29,6 +29,8 @@ It also affects users who depend on terminal scrollback, keyboard input, approva - Keep settled transcript content immutable and easy to select. - Preserve approval context and the current `Ctrl+O` detail control. - Use `Shift+Enter` for a newline and `Enter` for prompt submission. +- Keep the Composer available during active work and show all queued prompts. +- Preserve the session actor's FIFO batch for active-turn prompts. - Add safe prompt history cancellation and semantic copy. - Restore structured settled history after a session resume. - Preserve released API, wire, persistence, and full-screen behavior. @@ -40,7 +42,6 @@ It also affects users who depend on terminal scrollback, keyboard input, approva - Change the default mode for a current Termina application. - Provide terminal-native search or scrollback inside full-screen mode. - Persist transient tool progress or raw thought text in model context. -- Support prompt type-ahead during an active turn in the first release. - Make terminal-native selection omit arbitrary characters in a selected row. - Change the daemon execution engine or its actor ownership model. @@ -232,6 +233,18 @@ Netclaw will configure the current text area with `WithNewlineModifier(ConsoleMo Bare `Enter` will submit the prompt. The native input path must preserve `Shift+Enter` through Kitty keyboard input or raw input. +The Composer will remain visible while a model, tool, or sub-agent works. +Each later prompt will use the current `SendMessage` path immediately. +The session actor will remain the batch owner. +Its `Processing` handler will retain accepted prompts in FIFO order. +The actor will drain the full buffer before one follow-up model call. +The client will not send one queued prompt after each completed turn. + +The Queue Shelf will show each prompt that waits behind the current turn. +A successful current-turn completion will promote the full displayed set. +A failed send will keep its prompt for the current reconnect path. +The reconnect path will retain FIFO order and will not discard a queue entry. + Termina will add a prompt-history cancellation API if the current component cannot restore drafts. The new API will not change a current text-area method signature. Netclaw will use it for Up and Down history navigation. @@ -315,6 +328,24 @@ Each review will use the video and selected lossless frame images. The reviewer will record material visual defects before the next checkpoint. The tapes will not become CI assets or permanent smoke tests. +### D13: Enforce rationale at the shared execution preflight + +Every generated tool schema already marks `_rationale` as a required string. +Some providers can still omit it from a tool call. +The shared executor preflight will reject a missing, blank, or non-string value. +The rejection will become the tool result for that call. +The tool will not execute and no approval prompt will appear. +The next model step can issue a corrected call with a rationale. + +The validation will apply to new execution only. +Persistence extraction and transcript reads will continue to accept a null +rationale from old records. The TUI will mark that old value as unavailable. +It will not infer intent from tool arguments. + +Parallel tool calls will retain per-call failure isolation. +A call without rationale will fail before dispatch. +A compliant sibling call can still execute. + ## Actor Boundaries and Persistence The session actor will remain the owner of session lifecycle and durable state. @@ -340,6 +371,7 @@ The actor will rebuild a bounded transcript from journal events and snapshots. - An invalid approval payload will block the decision and show an error. - A clipboard failure will retain the selected data and show a visible error. - A client disconnect will retain durable actor state and discard only transient UI state. +- A prompt send failure will retain the prompt for ordered reconnect delivery. - A process failure will rely on the current session recovery path and the settled timeline. - A resize proof failure will block inline mode release for that terminal class. @@ -390,7 +422,6 @@ An operator must select a different mode or package version explicitly. ## Open Questions - Which cursor sequence set remains reliable after primary-buffer resize on each supported terminal? -- Should the first release hide the composer for every active turn? - Should the inspector always use a temporary alternate buffer? - Which settled thought summary can the product policy retain? - Which terminal versions will define the supported native matrix? diff --git a/openspec/changes/redesign-netclaw-chat-tui/proposal.md b/openspec/changes/redesign-netclaw-chat-tui/proposal.md index d902e6ab5..c3e9c4eae 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/proposal.md +++ b/openspec/changes/redesign-netclaw-chat-tui/proposal.md @@ -23,6 +23,12 @@ the current defects. - Keep Termina full-screen mode as the default for existing applications. - Preserve compact and expanded approval states with `Ctrl+O`. - Use `Shift+Enter` for a newline and bare `Enter` for prompt submission. +- Keep the Composer available during active work and show each later prompt in + an ordered Queue Shelf. +- Send active-turn prompts through the current session input path so the actor + includes the full FIFO set in one follow-up model call. +- Reject each new tool call that lacks its required model rationale before + tool dispatch. - Add prompt draft restoration and double-Escape prompt clearance. - Preserve complete event detail through an inspector and semantic copy path. - Preserve structured event chronology after session resume. @@ -37,7 +43,6 @@ the current defects. - A replacement for terminal-native scrollback or search in inline mode. - A new daemon-side execution engine. - Persistence of ephemeral tool progress in model context or the actor journal. -- Background prompt type-ahead during an active turn in the first prototype. - A change to the default presentation mode for existing Termina applications. ## Capabilities @@ -57,6 +62,8 @@ the current defects. activity output. - `session-resume`: Restores structured settled events instead of role and text content alone. +- `tool-call-metadata`: Enforces the required rationale on new tool calls while + old transcript data remains readable. - `netclaw-testing`: Requires event-contract coverage and disposable visual proof for the chat surface. diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-chat-tui/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-chat-tui/spec.md index 5737ff29b..0665c08b1 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-chat-tui/spec.md +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-chat-tui/spec.md @@ -40,12 +40,12 @@ SHALL NOT require fixed full-screen coordinates. - **AND** the settled Transcript has no outer border - **AND** the Composer shows the current input contract -#### Scenario: Active turn replaces the Composer +#### Scenario: Active turn retains the Composer - **WHEN** a submitted turn remains active -- **THEN** the Live Deck replaces the Composer -- **AND** the Hint Line shows only active-turn actions -- **AND** the first prototype accepts no prompt type-ahead +- **THEN** the Live Deck shows current work above the bottom dock +- **AND** the Composer remains available for later prompts +- **AND** the Hint Line shows the active input actions ### Requirement: Settled transcript content is immutable @@ -193,6 +193,45 @@ original content after its compact display summary. - **WHEN** the operator presses Escape once and the window expires - **THEN** the prompt text remains unchanged +### Requirement: Active-turn prompts form one follow-up batch + +The client SHALL send each prompt through the current session input path while +the current turn remains active. The session actor SHALL retain each accepted +prompt in FIFO order. The Queue Shelf SHALL show every retained prompt. + +At the next turn boundary, the session actor SHALL drain the complete retained +set before one follow-up model call. It SHALL NOT start one model call for each +retained prompt. The client SHALL remove the complete promoted set from the +Queue Shelf together. + +If a send fails before admission, the client SHALL retain that prompt for the +ordered reconnect path. It SHALL show the reconnect state and SHALL NOT discard +the prompt. + +#### Scenario: Three prompts join one follow-up model call + +- **GIVEN** one model call remains active +- **WHEN** the operator submits prompts A, B, and C +- **THEN** the session actor retains A, B, and C in that order +- **AND** the Queue Shelf shows A, B, and C +- **AND** one follow-up model call includes A, B, and C in that order +- **AND** no separate model call starts for B or C + +#### Scenario: Current turn completes + +- **GIVEN** the Queue Shelf shows prompts A, B, and C +- **WHEN** the current turn completes +- **THEN** the complete displayed set leaves the Queue Shelf together +- **AND** the settled transcript retains A, B, and C in FIFO order + +#### Scenario: Queued prompt send fails + +- **GIVEN** the operator submits a prompt during an active turn +- **WHEN** session ingress rejects or cannot deliver the prompt +- **THEN** the client retains the prompt for ordered reconnect delivery +- **AND** the client shows a visible reconnect state +- **AND** the client does not report successful admission + ### Requirement: Inspector and copy use semantic event data The Inspector SHALL show complete tool arguments, tool results, error detail, diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md index dd87704ea..0e1be72f0 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md @@ -32,7 +32,8 @@ settlement, complete detail, and responsive layout without a live provider. Headless typed-key tests SHALL cover submit, `Shift+Enter`, prompt history, draft restoration, double Escape, approval denial, `Ctrl+O`, detail scroll, and -multiline paste. Time-based key sequences SHALL use `TimeProvider`. +multiline paste. They SHALL also cover the active-turn Queue Shelf and the +session actor's FIFO batch. Time-based key sequences SHALL use `TimeProvider`. #### Scenario: Shift Enter does not submit @@ -55,6 +56,22 @@ multiline paste. Time-based key sequences SHALL use `TimeProvider`. window - **THEN** the input clears without `Task.Delay` or `Thread.Sleep` +#### Scenario: Active-turn prompts use one model call + +- **GIVEN** one model call remains active +- **WHEN** the typed-key test submits three later prompts +- **THEN** the Queue Shelf shows all three prompts in FIFO order +- **AND** the session actor test observes one follow-up model call +- **AND** that model call contains all three prompts in FIFO order + +#### Scenario: Missing rationale fails before tool dispatch + +- **GIVEN** a new tool call omits `_rationale` +- **WHEN** the shared execution preflight validates the call +- **THEN** the test executor receives no invocation +- **AND** the model receives a correction result for that call +- **AND** no approval request occurs + ### Requirement: Disposable visual checkpoints prove the chat grammar Development review SHALL use temporary video tapes outside the repository. diff --git a/openspec/changes/redesign-netclaw-chat-tui/specs/tool-call-metadata/spec.md b/openspec/changes/redesign-netclaw-chat-tui/specs/tool-call-metadata/spec.md new file mode 100644 index 000000000..80f268d2d --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/tool-call-metadata/spec.md @@ -0,0 +1,55 @@ +## MODIFIED Requirements + +### Requirement: Rationale is required + +The `_rationale` field SHALL be a required string in every tool schema. Its +description SHALL instruct the model to state its intent in one sentence. + +The shared execution preflight SHALL reject a new tool call when `_rationale` +is absent, blank, or not a string. The tool SHALL NOT execute. The rejection +SHALL identify `_rationale` and ask the model to issue a corrected call. The +rejection SHALL occur before an approval request. + +The persistence extractor and transcript reader SHALL accept old records that +have no rationale. A client SHALL mark the old rationale as unavailable. It +SHALL NOT infer intent from arguments or other tool fields. + +#### Scenario: Model provides rationale on a tool call + +- **GIVEN** the model issues a new tool call +- **WHEN** the call includes a nonempty string `_rationale` +- **THEN** the preflight accepts the rationale +- **AND** the pipeline stores it on `ToolCallMeta` +- **AND** normal authorization and dispatch continue + +#### Scenario: New tool call omits rationale + +- **GIVEN** the model issues a new tool call without `_rationale` +- **WHEN** the shared execution preflight validates the call +- **THEN** it produces a correction result for that call +- **AND** the tool does not execute +- **AND** no approval request occurs + +#### Scenario: New tool call supplies an invalid rationale + +- **GIVEN** the model supplies a blank or non-string `_rationale` +- **WHEN** the shared execution preflight validates the call +- **THEN** it produces a correction result that names `_rationale` +- **AND** the tool does not execute + +#### Scenario: One parallel call omits rationale + +- **GIVEN** a parallel batch contains one compliant call and one call without + `_rationale` +- **WHEN** the pipeline executes the batch +- **THEN** the compliant call can execute +- **AND** the noncompliant call returns a correction result +- **AND** both calls retain their original call identities + +#### Scenario: Old transcript has no rationale + +- **GIVEN** an old settled tool record has no rationale +- **WHEN** a current client reads the transcript +- **THEN** the record remains readable +- **AND** the client marks its rationale as unavailable +- **AND** the client does not invent a rationale diff --git a/openspec/changes/redesign-netclaw-chat-tui/tasks.md b/openspec/changes/redesign-netclaw-chat-tui/tasks.md index d37631394..c692b7f98 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/tasks.md +++ b/openspec/changes/redesign-netclaw-chat-tui/tasks.md @@ -62,6 +62,8 @@ - [x] 5.8 Prove that Slack and other restricted subscribers do not receive the new activity. - [x] 5.9 Prove that transient activity does not enter model context or the actor journal. - [x] 5.10 Add DTO round-trip and old-payload fixtures for all additive fields. +- [ ] 5.11 Reject a missing, blank, or non-string rationale at the shared execution preflight. +- [ ] 5.12 Prove rejection, sibling isolation, no approval request, and legacy transcript compatibility. ## 6. Structured Session Resume @@ -105,6 +107,9 @@ - [x] 8.10 Queue inline output while the inspector owns the terminal and commit it after exit. - [x] 8.11 Add semantic copy for an event and a complete turn. - [x] 8.12 Add visible copy errors and keep the selected data after a failure. +- [x] 8.13 Keep the Composer visible and show every active-turn prompt in the Queue Shelf. +- [x] 8.14 Send active-turn prompts through the current session buffer and promote the full FIFO set together. +- [x] 8.15 Prove that three active-turn prompts produce one ordered follow-up model call. ## 9. Netclaw Command Integration From a837d55d38e968ee7caeb4fab604caa4f33d78d4 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 19:57:05 -0500 Subject: [PATCH 25/39] fix(chat): preserve the queued prompt batch --- .../Sessions/LlmSessionIntegrationTests.cs | 8 ++ .../Cli/DaemonClientSessionTests.cs | 96 +++++++++++++++++++ .../Tui/InlineChatPageTests.cs | 31 ++++-- src/Netclaw.Cli/Tui/ChatViewModel.cs | 50 +++++----- src/Netclaw.Cli/Tui/InlineChatPage.cs | 50 ++++++---- 5 files changed, 187 insertions(+), 48 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs index a16cdab6d..ca646a392 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs @@ -783,6 +783,14 @@ await sessionManager.Ask(new SendUserMessage // Only two LLM calls total Assert.Equal(2, _fakeChatClient.CallCount); + var followUpUserMessages = _fakeChatClient.ReceivedMessages[1] + .Where(message => message.Role == Microsoft.Extensions.AI.ChatRole.User) + .Select(message => message.Text) + .ToList(); + Assert.Equal( + ["Second message", "Third message"], + followUpUserMessages.TakeLast(2)); + await subscriber.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(300), cancellationToken: TestContext.Current.CancellationToken); } diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index 36eacbcfe..dd3d0e167 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -168,6 +168,102 @@ public async Task ChatViewModel_keeps_queue_head_until_approval_outcome_arrives( Assert.Equal("Approval required", viewModel.StatusMessage.Value); } + [Fact] + public async Task ChatViewModel_sends_all_active_turn_prompts_to_the_session_buffer() + { + var transport = new FakeDaemonHubTransport(); + await using var client = new DaemonClient( + "http://127.0.0.1:1", + transport, + reconnectDelays: [TimeSpan.Zero]); + using var viewModel = CreateViewModel(client); + await ActivateAsync(viewModel); + viewModel.IsGenerating.Value = true; + + await Task.WhenAll( + viewModel.SubmitAsync("prompt A"), + viewModel.SubmitAsync("prompt B"), + viewModel.SubmitAsync("prompt C")); + + var sends = transport.Invocations + .Where(invocation => string.Equals(invocation.Method, "SendMessage", StringComparison.Ordinal)) + .Select(invocation => Assert.IsType(invocation.Args[1])) + .ToList(); + Assert.Equal(["prompt A", "prompt B", "prompt C"], sends); + Assert.Equal(3, viewModel.QueuedTurnMessageCount.Value); + + transport.PushOutput(SessionOutputDtoMapper.ToDto(new TurnCompleted + { + SessionId = new SessionId("fake/session"), + TimestampMs = 1, + TurnNumber = new TurnNumber(1), + Outcome = TurnOutcome.Completed + })); + + Assert.Equal(0, viewModel.QueuedTurnMessageCount.Value); + Assert.True(viewModel.IsGenerating.Value); + Assert.Equal(3, transport.Invocations.Count(invocation => + string.Equals(invocation.Method, "SendMessage", StringComparison.Ordinal))); + } + + [Fact] + public async Task ChatViewModel_retries_a_rejected_active_turn_prompt_without_loss() + { + var accepted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sendAttempts = 0; + var transport = new FakeDaemonHubTransport + { + VoidInvokeHook = (method, _, _) => + { + if (!string.Equals(method, "SendMessage", StringComparison.Ordinal)) + return Task.CompletedTask; + + if (Interlocked.Increment(ref sendAttempts) == 1) + throw new IOException("test rejection"); + + accepted.TrySetResult(); + return Task.CompletedTask; + } + }; + await using var client = new DaemonClient( + "http://127.0.0.1:1", + transport, + reconnectDelays: [TimeSpan.Zero]); + using var viewModel = CreateViewModel(client); + await ActivateAsync(viewModel); + viewModel.IsGenerating.Value = true; + + await viewModel.SubmitAsync("retain this prompt"); + await accepted.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + var sends = transport.Invocations + .Where(invocation => string.Equals(invocation.Method, "SendMessage", StringComparison.Ordinal)) + .Select(invocation => Assert.IsType(invocation.Args[1])) + .ToList(); + Assert.Equal(["retain this prompt", "retain this prompt"], sends); + Assert.Equal(1, viewModel.QueuedTurnMessageCount.Value); + } + + private static ChatViewModel CreateViewModel(DaemonClient client) => new( + client, + TimeProvider.System, + new ModelCapabilities { ModelId = "test-model" }, + new ChatNavigationState(), + new NetclawPaths()); + + private static async Task ActivateAsync(ChatViewModel viewModel) + { + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = viewModel.SessionIdDisplay.Subscribe(value => + { + if (string.Equals(value, "fake/session", StringComparison.Ordinal)) + ready.TrySetResult(); + }); + + viewModel.OnActivated(); + await ready.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + } + private static ToolInteractionRequest Approval(string callId, long timestampMs) => new() { SessionId = new SessionId("fake/session"), diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index b9ca1f404..f692600b1 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -351,7 +351,7 @@ public async Task ApprovalDetail_PreservesSelectionAndScrollPositionAcrossCollap } [Fact] - public async Task Generation_KeepsTheComposerFocusedAndAcceptsAQueuedPrompt() + public async Task Generation_ShowsEveryQueuedPromptInOrder() { await using var harness = CreateHarness(); var runTask = harness.StartAsync(); @@ -365,12 +365,31 @@ await harness.WaitUntilAsync(() => && harness.Terminal.Contains("MESSAGE") && harness.Focus.CurrentFocus is not null); - harness.Input.EnqueueString("queue this next"); - harness.Input.EnqueueKey(ConsoleKey.Enter); + string[] prompts = ["queue this first", "queue this second", "queue this third"]; + foreach (var prompt in prompts) + { + harness.Input.EnqueueString(prompt); + harness.Input.EnqueueKey(ConsoleKey.Enter); + } - Assert.Equal("queue this next", - await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); - await harness.WaitUntilAsync(() => harness.Terminal.Contains("QUEUED 1 message")); + foreach (var prompt in prompts) + { + Assert.Equal(prompt, + await harness.ViewModel.ReadSubmissionAsync(harness.Cancellation.Token)); + } + + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("QUEUED 3 messages") + && harness.Terminal.Contains("1 queue this first") + && harness.Terminal.Contains("2 queue this second") + && harness.Terminal.Contains("3 queue this third")); + var screen = harness.Terminal.ToString(); + Assert.True( + screen.IndexOf("queue this first", StringComparison.Ordinal) + < screen.IndexOf("queue this second", StringComparison.Ordinal)); + Assert.True( + screen.IndexOf("queue this second", StringComparison.Ordinal) + < screen.IndexOf("queue this third", StringComparison.Ordinal)); await harness.StopAsync(runTask); } diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index fd892b000..c43e76209 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -38,8 +38,8 @@ public partial class ChatViewModel : ReactiveViewModel private readonly Subject _outputSubject = new(); private readonly Queue _pendingMessages = new(); - private readonly Queue _queuedTurnMessages = new(); private readonly Queue _pendingInteractions = new(); + private int _queuedTurnMessageCount; /// /// True while an interaction response is in flight to the daemon. Guards @@ -159,15 +159,18 @@ protected virtual Task InitializeSessionAsync() _submittedInteractionCallId = null; _isSubmittingInteraction = false; RefreshApprovalOptions(); - IsGenerating.Value = false; - _ = SendNextQueuedTurnMessageAsync(); + var promotedCount = PromoteQueuedTurnMessages(); + IsGenerating.Value = promotedCount > 0; + StatusMessage.Value = promotedCount > 0 + ? "Generating..." + : "Ready"; break; case ErrorOutput: _pendingInteractions.Clear(); _submittedInteractionCallId = null; _isSubmittingInteraction = false; RefreshApprovalOptions(); - IsGenerating.Value = false; + IsGenerating.Value = Volatile.Read(ref _queuedTurnMessageCount) > 0; break; } @@ -182,7 +185,6 @@ or DaemonConnectionState.Reconnecting or DaemonConnectionState.TransportClosed) { _sessionReady = false; - IsGenerating.Value = false; } // The initial connect path owns the first session attach. @@ -220,13 +222,9 @@ public virtual async Task SubmitAsync(string text) return; } - if (IsGenerating.Value) - { - _queuedTurnMessages.Enqueue(text); - QueuedTurnMessageCount.Value = _queuedTurnMessages.Count; - RequestRedraw(); - return; - } + var isActiveTurnPrompt = IsGenerating.Value; + if (isActiveTurnPrompt) + AddQueuedTurnMessage(); if (!_sessionReady || !_daemonClient.IsConnected) { @@ -239,18 +237,18 @@ public virtual async Task SubmitAsync(string text) return; } - IsGenerating.Value = true; - StatusMessage.Value = "Generating..."; + if (!isActiveTurnPrompt) + { + IsGenerating.Value = true; + StatusMessage.Value = "Generating..."; + } try { - await _daemonClient.EnsureSessionAsync(DaemonClient.TuiChannelType); - await _daemonClient.SendAsync(text); } catch (Exception ex) { - IsGenerating.Value = false; _sessionReady = false; IsInputEnabled.Value = true; _pendingMessages.Enqueue(text); @@ -265,14 +263,19 @@ public virtual void RequestAppShutdown() Shutdown(); } - private async Task SendNextQueuedTurnMessageAsync() + private void AddQueuedTurnMessage() { - if (_queuedTurnMessages.Count == 0) - return; + var count = Interlocked.Increment(ref _queuedTurnMessageCount); + QueuedTurnMessageCount.Value = count; + StatusMessage.Value = $"Queued {count} message(s)."; + RequestRedraw(); + } - var next = _queuedTurnMessages.Dequeue(); - QueuedTurnMessageCount.Value = _queuedTurnMessages.Count; - await SubmitAsync(next); + private int PromoteQueuedTurnMessages() + { + var count = Interlocked.Exchange(ref _queuedTurnMessageCount, 0); + QueuedTurnMessageCount.Value = 0; + return count; } private async Task SubmitInteractionResponseAsync(string text) @@ -530,6 +533,7 @@ public override void Dispose() SessionIdDisplay.Dispose(); UsageDisplay.Dispose(); UiVersion.Dispose(); + QueuedTurnMessageCount.Dispose(); IsApprovalDetailVisible.Dispose(); base.Dispose(); } diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 5ae3aa614..ac63a09ef 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -317,10 +317,20 @@ private ILayoutNode BuildLiveRegion() var content = Layouts.Vertical(); if (_state.Transcript.Count > 0) content.WithChild(Layouts.Empty().Height(1)); - content - .WithChild(BuildLiveReplyBlock()) - .WithChild(BuildSessionHeader()); - content.WithChild(BuildQueueShelf()); + var hasLiveReply = _state.ReplyPassages.Count > 0 + || _state.Tools.Count > 0 + || _state.SubAgents.Count > 0 + || !string.IsNullOrWhiteSpace(_state.ThoughtText); + content.WithChild(BuildLiveReplyBlock()); + if (hasLiveReply) + content.WithChild(Layouts.Empty().Height(1)); + content.WithChild(BuildSessionHeader()); + if (_queuedPromptDisplays.Count > 0) + { + content + .WithChild(BuildQueueShelf()) + .WithChild(Layouts.Empty().Height(1)); + } if (_state.PendingApproval is not null) content.WithChild(BuildDecisionGate(_state.PendingApproval)); @@ -593,23 +603,28 @@ private ILayoutNode BuildQueueShelf() var count = _queuedPromptDisplays.Count; var label = count == 1 ? "1 message" : $"{count} messages"; - var preview = ChatPresentationRenderer.OneLine( - _queuedPromptDisplays.Peek(), - Math.Max(20, ReadableWidth() - 20)); - var content = Layouts.Horizontal() - .WithChild(new TextNode($"QUEUED {label} ") + var content = Layouts.Vertical() + .WithChild(new TextNode($"QUEUED {label}") .WithForeground(ChatVisualTheme.Muted) - .Bold()) - .WithChild(new TextNode(preview) - .WithForeground(ChatVisualTheme.Text) - .Fill()); + .Bold()); + var index = 1; + foreach (var prompt in _queuedPromptDisplays) + { + var preview = ChatPresentationRenderer.OneLine( + prompt, + Math.Max(20, ReadableWidth() - 8)); + content.WithChild(new TextNode($"{index,2} {preview}") + .WithForeground(ChatVisualTheme.Text)); + index++; + } + return new PanelNode() .WithBorder(BorderStyle.None) .WithBackground(ChatVisualTheme.Surface) .WithPadding(1) .WithContent(content) .Width(ReadableWidth()) - .Height(3); + .Height(count + 3); } private ILayoutNode BuildLiveReplyBlock() @@ -1243,13 +1258,10 @@ public static ILayoutNode BuildStableBlock(ChatPresentationBlock block, int widt .WithChild(Layouts.Empty().Fill()); } - var stableBlock = Layouts.Vertical(); - if (block.Kind == ChatBlockKind.Assistant) - stableBlock.WithChild(Layouts.Empty().Height(1)); - stableBlock + var stableBlock = Layouts.Vertical() .WithChild(heading) .WithChild(content) - .WithChild(new TextNode(string.Empty)); + .WithChild(Layouts.Empty().Height(1)); if (leftMargin == 0) return stableBlock; From 0a30288ee8f95fdf0ec962b4c42fa20b5336d3af Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 20:14:05 -0500 Subject: [PATCH 26/39] fix(tools): require a rationale before execution --- .../redesign-netclaw-chat-tui/tasks.md | 4 +- .../Pipelines/MetaValidationAndNoticeTests.cs | 68 +++++++++++ .../Sessions/SubAgentSpawnIntegrationTests.cs | 24 +++- .../SubAgents/SpawnAgentStreamingTests.cs | 6 +- .../SubAgents/SubAgentActorTests.cs | 65 ++++++----- .../Tools/DispatchingToolExecutorTests.cs | 106 ++++++++++++------ .../Tools/MessyCommandOneTimeApprovalTests.cs | 4 +- .../Tools/ToolArgumentValidatorTests.cs | 53 ++++++++- .../Pipelines/SessionToolExecutionPipeline.cs | 6 +- .../Pipelines/ToolCallMetaExtractor.cs | 31 +++++ src/Netclaw.Actors/SubAgents/SubAgentActor.cs | 4 +- .../Tools/DispatchingToolExecutor.cs | 22 ++-- 12 files changed, 305 insertions(+), 88 deletions(-) diff --git a/openspec/changes/redesign-netclaw-chat-tui/tasks.md b/openspec/changes/redesign-netclaw-chat-tui/tasks.md index c692b7f98..67d87bedf 100644 --- a/openspec/changes/redesign-netclaw-chat-tui/tasks.md +++ b/openspec/changes/redesign-netclaw-chat-tui/tasks.md @@ -62,8 +62,8 @@ - [x] 5.8 Prove that Slack and other restricted subscribers do not receive the new activity. - [x] 5.9 Prove that transient activity does not enter model context or the actor journal. - [x] 5.10 Add DTO round-trip and old-payload fixtures for all additive fields. -- [ ] 5.11 Reject a missing, blank, or non-string rationale at the shared execution preflight. -- [ ] 5.12 Prove rejection, sibling isolation, no approval request, and legacy transcript compatibility. +- [x] 5.11 Reject a missing, blank, or non-string rationale at the shared execution preflight. +- [x] 5.12 Prove rejection, sibling isolation, no approval request, and legacy transcript compatibility. ## 6. Structured Session Resume diff --git a/src/Netclaw.Actors.Tests/Sessions/Pipelines/MetaValidationAndNoticeTests.cs b/src/Netclaw.Actors.Tests/Sessions/Pipelines/MetaValidationAndNoticeTests.cs index 8bc9f190c..56ad385a1 100644 --- a/src/Netclaw.Actors.Tests/Sessions/Pipelines/MetaValidationAndNoticeTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/Pipelines/MetaValidationAndNoticeTests.cs @@ -98,6 +98,35 @@ public Task ExecuteAsync(FunctionCallContent toolCall, ToolExecutionCont } } + private sealed class RequiredRationaleExecutor : IToolExecutor + { + public int ExecutionCount; + + public Task AuthorizeAsync( + FunctionCallContent toolCall, + ToolExecutionContext? context = null, + CancellationToken ct = default) + { + return Task.CompletedTask; + } + + public ToolArgumentRejection? ValidateToolCall(FunctionCallContent toolCall) + => ToolCallMetaExtractor.ValidateRequiredRationale( + toolCall.Arguments, + ToolCallMeta.ResolveExactMetaField) is { } error + ? new ToolArgumentRejection(error, "invalid_rationale") + : null; + + public Task ExecuteAsync( + FunctionCallContent toolCall, + ToolExecutionContext? context = null, + CancellationToken ct = default) + { + ExecutionCount++; + return Task.FromResult($"executed:{toolCall.CallId}"); + } + } + // ── Timeout hint is honored exactly (no clamp, no floor) ── [Fact] @@ -239,6 +268,45 @@ public async Task Malformed_metadata_returns_denial_without_execution() Assert.Contains("'_background'", result.Content); } + [Fact] + public async Task Missing_rationale_rejects_while_a_parallel_sibling_runs() + { + var executor = new RequiredRationaleExecutor(); + var probe = CreateTestProbe("rationale-isolation"); + var sessionId = new SessionId("D1/rationale-isolation"); + var toolCalls = new List + { + new("call-valid", "shell_execute", new Dictionary + { + ["Command"] = "echo valid", + ["_rationale"] = "Run the valid sibling." + }), + new("call-invalid", "shell_execute", new Dictionary + { + ["Command"] = "echo invalid" + }) + }; + + var pipelineTask = new SessionToolPipelineTestFixture(executor, toolCalls, sessionId, probe.Ref) + .WithTurnContext(InteractiveTurnContext(sessionId)) + .ExecuteAsync(TestContext.Current.CancellationToken); + + var completed = await probe.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + await pipelineTask.WaitAsync(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + + Assert.Equal(1, executor.ExecutionCount); + Assert.Equal(2, completed.ToolResults.Count); + Assert.Contains(completed.ToolResults, result => + result.ToolCallId == new ToolCallId("call-valid") + && result.Content == "executed:call-valid"); + Assert.Contains(completed.ToolResults, result => + result.ToolCallId == new ToolCallId("call-invalid") + && result.Content.Contains("'_rationale'", StringComparison.Ordinal) + && result.Content.Contains("NOT executed", StringComparison.Ordinal)); + } + [Fact] public async Task Non_integral_json_timeout_rejects_without_uncaught_throw() { diff --git a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs index 4bdd46c9a..42ac33c69 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SubAgentSpawnIntegrationTests.cs @@ -34,6 +34,18 @@ public class SubAgentSpawnIntegrationTests : LlmSessionTestBase private RecordingContextTool? _recordingFileReadTool; private RecordingContextTool? _recordingShellTool; + private static FunctionCallContent CreateToolCall( + string callId, + string name, + IDictionary arguments) + { + var callArguments = new Dictionary(arguments, StringComparer.Ordinal) + { + ["_rationale"] = "Verify the sub-agent session behavior." + }; + return new FunctionCallContent(callId, name, callArguments); + } + public SubAgentSpawnIntegrationTests(ITestOutputHelper output) : base(output) { } @@ -205,7 +217,7 @@ public async Task Spawn_agent_runs_under_session_and_emits_subagent_events() { _clientProvider.Main.ToolCallsOnFirstCall = [ - new FunctionCallContent( + CreateToolCall( "call-spawn", "spawn_agent", new Dictionary @@ -316,7 +328,7 @@ public async Task Spawn_agent_subagent_approval_uses_parent_authority_and_resume _clientProvider.Main.ToolCallsOnFirstCall = [ - new FunctionCallContent( + CreateToolCall( parentCallId, "spawn_agent", new Dictionary @@ -327,7 +339,7 @@ public async Task Spawn_agent_subagent_approval_uses_parent_authority_and_resume ]; _clientProvider.Compaction.ToolCallsOnFirstCall = [ - new FunctionCallContent( + CreateToolCall( childCallId, "shell_execute", new Dictionary @@ -422,7 +434,7 @@ public async Task Spawn_agent_subagent_approval_expires_after_parent_session_rec { _clientProvider.Main.ToolCallsOnFirstCall = [ - new FunctionCallContent( + CreateToolCall( "call-spawn-shell-expire", "spawn_agent", new Dictionary @@ -433,7 +445,7 @@ public async Task Spawn_agent_subagent_approval_expires_after_parent_session_rec ]; _clientProvider.Compaction.ToolCallsOnFirstCall = [ - new FunctionCallContent( + CreateToolCall( "call-subagent-shell-expire", "shell_execute", new Dictionary @@ -745,7 +757,7 @@ public async Task Routed_slash_ignores_skill_allowed_tools_for_runtime_authoriza { _clientProvider.Compaction.ToolCallsOnFirstCall = [ - new FunctionCallContent( + CreateToolCall( "call-read", "file_read", new Dictionary diff --git a/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs index f5359ea3e..e4ae4c19a 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SpawnAgentStreamingTests.cs @@ -98,7 +98,8 @@ public async Task Spawn_agent_streams_activity_through_executor_dispatch_to_watc new Dictionary { ["agent"] = "summarizer", - ["task"] = "Summarize the project." + ["task"] = "Summarize the project.", + ["_rationale"] = "Verify streamed sub-agent activity." }); // Drain the stream the way the production pipeline drains a self-monitoring @@ -191,7 +192,8 @@ public async Task Spawn_agent_self_monitoring_survives_quiet_window_after_first_ new Dictionary { ["agent"] = "summarizer", - ["task"] = "Summarize the project." + ["task"] = "Summarize the project.", + ["_rationale"] = "Verify self-monitored sub-agent activity." }); // spawn_agent is self-monitoring, so the parent drains it with no watchdog at diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index cc33ab553..627620fda 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -32,6 +32,21 @@ public class SubAgentActorTests : TestKit private static readonly TimeSpan ApprovalAskTimeout = TimeSpan.FromSeconds(30); public static bool IsPosix => !OperatingSystem.IsWindows(); + private static FunctionCallContent CreateToolCall(string callId, string name) + => CreateToolCall(callId, name, new Dictionary()); + + private static FunctionCallContent CreateToolCall( + string callId, + string name, + IDictionary arguments) + { + var callArguments = new Dictionary(arguments, StringComparer.Ordinal) + { + ["_rationale"] = "Verify the sub-agent behavior." + }; + return new FunctionCallContent(callId, name, callArguments); + } + public SubAgentActorTests(ITestOutputHelper output) : base(output: output) { } protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) @@ -290,7 +305,7 @@ public async Task Tool_call_executes_and_continues() { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-1", "greet", + CreateToolCall("call-1", "greet", new Dictionary { ["name"] = "World" }) ] }; @@ -321,7 +336,7 @@ public async Task Tool_model_input_image_is_attached_to_subagent_followup_call() onExecute: context => context.AddModelInputFile(imagePath, "diagram.png", "image/png")); var fakeClient = new FakeChatClient { - ToolCallsOnFirstCall = [new FunctionCallContent("call-image", "load_image")] + ToolCallsOnFirstCall = [CreateToolCall("call-image", "load_image")] }; var definition = CreateDefinition([fakeTool]); var agent = Sys.ActorOf(SubAgentActor.CreateProps(definition, fakeClient, PermissivePolicy())); @@ -356,7 +371,7 @@ public async Task Tool_execution_inherits_parent_session_and_project_directories { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-context", "inspect_context") + CreateToolCall("call-context", "inspect_context") ] }; @@ -388,7 +403,7 @@ public async Task Tool_execution_with_no_parent_project_directory_passes_null_th var fakeTool = new FakeNetclawTool("inspect_context", "ok"); var fakeClient = new FakeChatClient { - ToolCallsOnFirstCall = [new FunctionCallContent("call-no-project", "inspect_context")] + ToolCallsOnFirstCall = [CreateToolCall("call-no-project", "inspect_context")] }; var definition = CreateDefinition([fakeTool]); @@ -415,7 +430,7 @@ public async Task Tool_execution_inherits_parent_resolved_cwd_snapshot() var fakeTool = new FakeNetclawTool("inspect_context", "ok"); var fakeClient = new FakeChatClient { - ToolCallsOnFirstCall = [new FunctionCallContent("call-cwd", "inspect_context")] + ToolCallsOnFirstCall = [CreateToolCall("call-cwd", "inspect_context")] }; var agent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([fakeTool]), fakeClient, PermissivePolicy())); @@ -446,7 +461,7 @@ public async Task Tool_execution_with_null_parent_cwd_resolves_to_session_dir_or var fakeTool = new FakeNetclawTool("inspect_context", "ok"); var fakeClient = new FakeChatClient { - ToolCallsOnFirstCall = [new FunctionCallContent("call-null-cwd", "inspect_context")] + ToolCallsOnFirstCall = [CreateToolCall("call-null-cwd", "inspect_context")] }; var agent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([fakeTool]), fakeClient, PermissivePolicy())); @@ -477,7 +492,7 @@ public async Task Tool_execution_inherits_parent_cwd_when_child_has_no_project_o var fakeTool = new FakeNetclawTool("inspect_context", "ok"); var fakeClient = new FakeChatClient { - ToolCallsOnFirstCall = [new FunctionCallContent("call-inherit-only", "inspect_context")] + ToolCallsOnFirstCall = [CreateToolCall("call-inherit-only", "inspect_context")] }; var agent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([fakeTool]), fakeClient, PermissivePolicy())); @@ -504,7 +519,7 @@ public async Task Each_spawn_snapshots_its_own_parent_project_directory() var firstTool = new FakeNetclawTool("inspect_context", "ok"); var firstClient = new FakeChatClient { - ToolCallsOnFirstCall = [new FunctionCallContent("call-1", "inspect_context")] + ToolCallsOnFirstCall = [CreateToolCall("call-1", "inspect_context")] }; var firstAgent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([firstTool]), firstClient, PermissivePolicy())); @@ -522,7 +537,7 @@ public async Task Each_spawn_snapshots_its_own_parent_project_directory() var secondTool = new FakeNetclawTool("inspect_context", "ok"); var secondClient = new FakeChatClient { - ToolCallsOnFirstCall = [new FunctionCallContent("call-2", "inspect_context")] + ToolCallsOnFirstCall = [CreateToolCall("call-2", "inspect_context")] }; var secondAgent = Sys.ActorOf(SubAgentActor.CreateProps(CreateDefinition([secondTool]), secondClient, PermissivePolicy())); @@ -584,7 +599,7 @@ public async Task Approval_gated_tool_without_bridge_fails_subagent_without_exec { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-approval", "shell_execute", + CreateToolCall("call-approval", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }) ] }; @@ -616,7 +631,7 @@ public async Task Subagent_approval_request_carries_cwd_candidates_and_full_butt { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-cwd-prompt", "shell_execute", + CreateToolCall("call-cwd-prompt", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }) ] }; @@ -937,11 +952,11 @@ public async Task Approve_once_does_not_leak_between_subagent_tool_calls() var policy = CreateApprovalRequiredPolicy(); var fakeClient = new SequencedToolCallChatClient( [ - new FunctionCallContent( + CreateToolCall( "call-approval-1", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }), - new FunctionCallContent( + CreateToolCall( "call-approval-2", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }) @@ -978,7 +993,7 @@ public async Task SubAgent_does_not_timeout_while_awaiting_human_approval() { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-slow-approval", "shell_execute", + CreateToolCall("call-slow-approval", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }) ] }; @@ -1022,7 +1037,7 @@ public async Task SubAgent_surfaces_approval_wait_and_resolution_to_parent_strea { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-activity-approval", "shell_execute", + CreateToolCall("call-activity-approval", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }) ] }; @@ -1076,7 +1091,7 @@ public async Task SubAgent_cancels_promptly_on_external_cancellation_during_appr { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-cancel", "shell_execute", + CreateToolCall("call-cancel", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }) ] }; @@ -1128,9 +1143,9 @@ public async Task SubAgent_parallel_tool_calls_each_awaiting_approval() { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-par-1", "shell_execute", + CreateToolCall("call-par-1", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }), - new FunctionCallContent("call-par-2", "shell_execute", + CreateToolCall("call-par-2", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }) ] }; @@ -1176,7 +1191,7 @@ public async Task Rejected_approval_returns_tool_result_without_executing_tool( { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-rejected", "shell_execute", + CreateToolCall("call-rejected", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }) ] }; @@ -1208,7 +1223,7 @@ public async Task External_stop_during_approval_wait_replies_once_and_cancels_wa { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-stop", "shell_execute", + CreateToolCall("call-stop", "shell_execute", new Dictionary { ["Command"] = "git push origin main" }) ] }; @@ -1484,7 +1499,7 @@ public async Task Max_iterations_forces_text_response() { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-loop", "looper") + CreateToolCall("call-loop", "looper") ], AlwaysReturnToolCalls = true }; @@ -1621,7 +1636,7 @@ public void Classify_distinguishes_keepalives_from_substantive_progress() // Tool-call content is substantive. var toolCall = StreamingResponseReader.Classify( - new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [new FunctionCallContent("call-1", "inspect_context")] }, + new ChatResponseUpdate { Role = ChatRole.Assistant, Contents = [CreateToolCall("call-1", "inspect_context")] }, anySubstantiveSeen: false); Assert.True(toolCall.HasSubstantiveContent); @@ -1696,7 +1711,7 @@ public async Task Tool_execution_uses_session_scope_for_mcp_invocation() { ToolCallsOnFirstCall = [ - new FunctionCallContent( + CreateToolCall( "call-1", "browser_playwright/navigate_page", new Dictionary { ["url"] = "https://example.com" }) @@ -1869,7 +1884,7 @@ public async Task Successful_first_party_edit_is_returned_as_confirmed_child_act { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-edit", "file_edit", + CreateToolCall("call-edit", "file_edit", new Dictionary { ["Path"] = "src/Calculator.cs" }) ] }; @@ -1900,7 +1915,7 @@ public async Task Denied_first_party_edit_is_not_returned_as_confirmed_child_act { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-edit", "file_edit", + CreateToolCall("call-edit", "file_edit", new Dictionary { ["Path"] = "src/Calculator.cs" }) ] }; diff --git a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs index 08d2dfdb6..dfa21e1dc 100644 --- a/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/DispatchingToolExecutorTests.cs @@ -24,6 +24,18 @@ public class DispatchingToolExecutorTests private readonly DispatchingToolExecutor _executor; private readonly DispatchingToolExecutor _restrictedExecutor; + private static FunctionCallContent CreateToolCall( + string callId, + string name, + IDictionary arguments) + { + var callArguments = new Dictionary(arguments, StringComparer.Ordinal) + { + ["_rationale"] = "Verify the executor behavior." + }; + return new FunctionCallContent(callId, name, callArguments); + } + public DispatchingToolExecutorTests() { var baseConfig = new ToolConfig(); @@ -90,7 +102,7 @@ public async Task Verbose_tool_output_over_budget_is_windowed_and_spilled() try { // shell_execute declares the small verbose budget (2000); echo > 2000 chars. - var toolCall = new FunctionCallContent("call-spill", "shell_execute", + var toolCall = CreateToolCall("call-spill", "shell_execute", ToolInput.Create("Command", $"echo {new string('x', 3000)}")); var context = TestToolExecutionContext.CreateBound("slack/thread-1", sessionDir, new TestToolExecutionContextOptions { @@ -120,7 +132,7 @@ public async Task Spilled_output_is_redacted_before_write() try { // Secret + padding so it both redacts and exceeds the shell budget → spills. - var toolCall = new FunctionCallContent("call-redact", "shell_execute", + var toolCall = CreateToolCall("call-redact", "shell_execute", ToolInput.Create("Command", $"echo API_KEY=supersecret123 {new string('x', 3000)}")); var context = TestToolExecutionContext.CreateBound("slack/thread-1", sessionDir, new TestToolExecutionContextOptions { @@ -144,7 +156,7 @@ public async Task Spilled_output_is_redacted_before_write() public async Task Small_output_is_redacted_without_spilling() { // Redaction happens centrally for every result, spill or not. - var toolCall = new FunctionCallContent("call-r", "shell_execute", + var toolCall = CreateToolCall("call-r", "shell_execute", ToolInput.Create("Command", "echo API_KEY=secret123")); var context = TestToolExecutionContext.CreateBound("signalr/thread-1", null, new TestToolExecutionContextOptions { @@ -171,7 +183,7 @@ public async Task File_read_preserves_secret_values_for_model() await File.WriteAllTextAsync(file, """{"secretKey": "real-secret-value", "name": "myapp"}""", CancellationToken.None); - var toolCall = new FunctionCallContent("call-secret", "file_read", + var toolCall = CreateToolCall("call-secret", "file_read", ToolInput.Create("Path", file)); var context = TestToolExecutionContext.CreateBound("slack/thread-1", sessionDir, new TestToolExecutionContextOptions { @@ -193,7 +205,7 @@ await File.WriteAllTextAsync(file, public async Task Shell_output_still_redacts_secrets() { // Shell output continues to be redacted — only file tools suppress it. - var toolCall = new FunctionCallContent("call-shell-secret", "shell_execute", + var toolCall = CreateToolCall("call-shell-secret", "shell_execute", ToolInput.Create("Command", "echo API_KEY=secret123")); var context = TestToolExecutionContext.CreateBound("signalr/thread-1", null, new TestToolExecutionContextOptions { @@ -221,7 +233,7 @@ public async Task File_read_spill_file_is_redacted_even_when_model_result_is_not var bigContent = $$"""{"secretKey": "real-secret-value", "data": "{{new string('x', 15000)}}"}"""; await File.WriteAllTextAsync(file, bigContent, CancellationToken.None); - var toolCall = new FunctionCallContent("call-spill-secret", "file_read", + var toolCall = CreateToolCall("call-spill-secret", "file_read", ToolInput.Create("Path", file)); var context = TestToolExecutionContext.CreateBound("slack/thread-1", sessionDir, new TestToolExecutionContextOptions { @@ -260,7 +272,7 @@ public async Task Content_tool_under_default_budget_not_spilled() // small file is returned whole with no spill. var file = Path.Combine(sessionDir, "note.txt"); await File.WriteAllTextAsync(file, "hello content", CancellationToken.None); - var toolCall = new FunctionCallContent("call-content", "file_read", + var toolCall = CreateToolCall("call-content", "file_read", ToolInput.Create("Path", file)); var context = TestToolExecutionContext.CreateBound("slack/thread-1", sessionDir, new TestToolExecutionContextOptions { @@ -282,7 +294,7 @@ public async Task Content_tool_under_default_budget_not_spilled() [Fact] public async Task Routes_shell_execute() { - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-1", "shell_execute", ToolInput.Create("Command", "echo routed")); @@ -302,7 +314,7 @@ public async Task Routes_shell_execute() [Fact] public async Task Routes_file_read_missing_file() { - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-2", "file_read", ToolInput.Create("Path", "/nonexistent/file.txt")); @@ -321,7 +333,7 @@ public async Task Routes_file_read_missing_file() [Fact] public async Task Shell_execute_is_denied_outside_personal_context() { - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-deny", "shell_execute", ToolInput.Create("Command", "echo denied")); @@ -360,7 +372,7 @@ public async Task Shell_execute_is_denied_when_missing_from_personal_audience_pr commandPolicy, pathPolicy)); - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-shell-profile-deny", "shell_execute", ToolInput.Create("Command", "echo denied")); @@ -399,7 +411,7 @@ public async Task Shell_execute_is_denied_when_shell_mode_is_off_even_in_persona commandPolicy, pathPolicy)); - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-shell-off", "shell_execute", ToolInput.Create("Command", "echo denied")); @@ -424,7 +436,7 @@ public async Task Shell_execute_is_denied_when_shell_mode_is_off_even_in_persona [Fact] public async Task Shell_execute_is_allowed_in_personal_context() { - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-allow", "shell_execute", ToolInput.Create("Command", "echo allowed")); @@ -448,7 +460,7 @@ public async Task Shell_execute_is_allowed_in_personal_context() public async Task Approval_exempt_shell_candidates_report_allow_reason(string command) { var executor = CreateApprovalGatedShellExecutor(); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-approval-exempt", "shell_execute", ToolInput.Create("Command", command)); @@ -470,7 +482,7 @@ public async Task Approval_exempt_shell_candidates_report_allow_reason(string co public async Task Shell_approval_without_extracted_candidates_fails_closed(string command) { var executor = CreateApprovalGatedShellExecutor(); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-no-approval-candidates", "shell_execute", ToolInput.Create("Command", command)); @@ -501,7 +513,7 @@ public async Task Shell_parser_rejection_fails_closed_without_execution() Assert.Empty(matcher.ExtractCandidates(new ToolName("shell_execute"), arguments)); var executor = CreateApprovalGatedShellExecutor(); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-parser-rejection", "shell_execute", arguments); @@ -550,7 +562,7 @@ public async Task Authorization_evaluation_preserves_partial_approval_matches() new ShellCommandPolicy(), new ToolPathPolicy([])), approvalService); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-partial-approval", "shell_execute", ToolInput.Create("Command", "git status && git push")); @@ -617,7 +629,7 @@ public async Task Authorization_evaluation_prompts_only_for_exact_unapproved_can new ShellCommandPolicy(), new ToolPathPolicy([])), approvalService); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-exact-partial-approval", "shell_execute", ToolInput.Create("Command", "git status && git push")); @@ -1198,7 +1210,7 @@ public async Task Authorization_evaluation_preserves_directory_for_duplicate_ver new ShellCommandPolicy(), new ToolPathPolicy([])), approvalService); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-duplicate-verb-scopes", "shell_execute", ToolInput.Create( @@ -1265,7 +1277,7 @@ public async Task Authorization_evaluation_denies_inconsistent_candidate_result( new ShellCommandPolicy(), new ToolPathPolicy([])), approvalService); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-inconsistent-partial-approval", "shell_execute", ToolInput.Create("Command", "git status && git push")); @@ -1381,7 +1393,7 @@ public async Task Authorization_evaluation_rejects_inconsistent_all_approved_res new ShellCommandPolicy(), new ToolPathPolicy([])), approvalService); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-inconsistent-all-approved", "shell_execute", ToolInput.Create("Command", "git status && git push")); @@ -1422,7 +1434,7 @@ public async Task Authorization_evaluation_logs_allow_reason_before_execution() new ShellCommandPolicy(), new ToolPathPolicy([])), logger: logger); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-authorization-telemetry", "telemetry_probe", ToolInput.Empty()); @@ -1455,7 +1467,7 @@ public async Task File_read_is_denied_outside_session_directory_in_public_contex try { - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-file-read-deny", "file_read", ToolInput.Create("Path", filePath)); @@ -1486,7 +1498,7 @@ public async Task File_write_is_denied_outside_session_directory_in_team_context try { - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-file-write-deny", "file_write", ToolInput.Create("Path", filePath, "Content", "blocked")); @@ -1517,7 +1529,7 @@ public async Task Routes_file_write() var filePath = Path.Combine(Path.GetTempPath(), $"netclaw-dispatch-{Guid.NewGuid():N}.txt"); try { - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-3", "file_write", ToolInput.Create("Path", filePath, "Content", "dispatch test")); @@ -1544,7 +1556,7 @@ public async Task Routes_file_write() [Fact] public async Task Unknown_tool_returns_error_string() { - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-4", "unknown_tool", ToolInput.Create("arg", "value")); @@ -1644,7 +1656,7 @@ public async Task Mcp_tool_is_denied_when_server_not_allowed_for_audience() new ShellCommandPolicy(), new ToolPathPolicy([]))); - var toolCall = new FunctionCallContent("call-mcp-deny", "memorizer/search_memories", ToolInput.Empty()); + var toolCall = CreateToolCall("call-mcp-deny", "memorizer/search_memories", ToolInput.Empty()); var context = TestToolExecutionContext.CreateBound("slack/thread-1", null, new TestToolExecutionContextOptions { Audience = TrustAudience.Team, @@ -1691,7 +1703,7 @@ public async Task One_time_approval_allows_immediate_retry_only() pathPolicy), approvalService); - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-approve-once", "shell_execute", // Use a non-side-effect verb (echo/printf/:/true/false @@ -1759,7 +1771,7 @@ public async Task One_time_approval_bypasses_policy_for_matching_shell_patterns( commandPolicy, pathPolicy)); - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-approve-once-bypass", "shell_execute", ToolInput.Create("Command", "echo bypass")); @@ -1865,7 +1877,7 @@ public async Task One_time_approval_bypasses_policy_for_path_aware_file_patterns new ToolPathPolicy([]), fileApprovalMatcher: new FilePathApprovalMatcher(controlPlaneRoot))); - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-file-approve-once-bypass", "file_write", ToolInput.Create("Path", targetPath, "Content", "approved once")); @@ -1888,7 +1900,7 @@ public async Task One_time_approval_bypasses_policy_for_path_aware_file_patterns Assert.Contains("Successfully wrote", retryResult, StringComparison.Ordinal); Assert.True(File.Exists(targetPath)); - var secondCall = new FunctionCallContent( + var secondCall = CreateToolCall( "call-file-approve-once-bypass-second", "file_write", ToolInput.Create("Path", secondPath, "Content", "different path")); @@ -1971,7 +1983,7 @@ await approvalService.RecordApprovalAsync( cwd: null, TestContext.Current.CancellationToken); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-filtered-once", "shell_execute", ToolInput.Create("Command", command)); @@ -2051,7 +2063,7 @@ public async Task Persistent_approval_hit_records_audit_context_without_promptin InteractiveApproval = TestToolExecutionContext.InteractiveApproval(true) }); - var call = new FunctionCallContent( + var call = CreateToolCall( "call-audit", "shell_execute", ToolInput.Create("Command", "git status")); @@ -2112,7 +2124,7 @@ public async Task Session_approval_allows_same_session_but_not_different_session new ToolPathPolicy([])), approvalService); - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-session-approve", "shell_execute", // Non-side-effect verb so the approval flow under test @@ -2228,7 +2240,7 @@ public async Task Mcp_session_approval_recorded_under_canonical_name_authorizes_ // The LLM emits tool_use with the sanitized alias — mirror that // here. The registry's two-form lookup (introduced in PR #1134) // resolves it back to the same adapter. - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( "call-mcp-approve-session", sanitizedAlias, ToolInput.Empty()); @@ -2270,7 +2282,7 @@ await approvalService.RecordApprovalAsync( // Same call dispatched by the canonical name must also resolve // — the registry accepts both forms, so the gate should // authorize either way. - var canonicalToolCall = new FunctionCallContent( + var canonicalToolCall = CreateToolCall( "call-mcp-approve-session-canonical", canonicalName, ToolInput.Empty()); @@ -2313,7 +2325,7 @@ public async Task Background_job_control_does_not_contact_approval_service(bool "slack/thread-1", null, new TestToolExecutionContextOptions { Audience = TrustAudience.Personal }); - var toolCall = new FunctionCallContent( + var toolCall = CreateToolCall( $"call-job-{cancel}", CheckBackgroundJobTool.ToolName, ToolInput.Create("JobId", "abc123", "Cancel", cancel)); @@ -2354,6 +2366,26 @@ private static DispatchingToolExecutor CreateApprovalGatedShellExecutor( logger: logger); } + [Fact] + public async Task Missing_rationale_rejects_before_the_approval_service() + { + var executor = CreateApprovalGatedShellExecutor(); + var context = CreateInteractivePersonalContext("signalr/rationale-rejection"); + var toolCall = new FunctionCallContent( + "call-missing-rationale", + "shell_execute", + ToolInput.Create("Command", "echo should-not-run")); + + var result = await executor.ExecuteAsync( + toolCall, + context, + TestContext.Current.CancellationToken); + + Assert.Contains("'_rationale'", result); + Assert.Contains("NOT executed", result); + Assert.DoesNotContain("should-not-run", result); + } + private static ToolExecutionContext CreateInteractivePersonalContext(string sessionId) => TestToolExecutionContext.CreateBound( sessionId, diff --git a/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs b/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs index fe206dedd..6ffb13ec9 100644 --- a/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/MessyCommandOneTimeApprovalTests.cs @@ -98,7 +98,9 @@ public async Task ApprovedOnce_on_messy_command_satisfies_one_time_bypass() "shell_execute", ToolInput.Create( "Command", - "for i in $(printf '1 2 3'); do echo \"$i\"; done")); + "for i in $(printf '1 2 3'); do echo \"$i\"; done", + "_rationale", + "Verify one-time approval for a complex command.")); var context = TestToolExecutionContext.CreateBound("signalr/thread-1", null, new TestToolExecutionContextOptions { diff --git a/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs b/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs index b35cd7a26..9199f8df0 100644 --- a/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/ToolArgumentValidatorTests.cs @@ -66,7 +66,12 @@ private async Task ExecuteShellAsync(IDictionary args) Directory.CreateDirectory(sessionDir); try { - var toolCall = new FunctionCallContent("call-1", "shell_execute", args); + var callArgs = new Dictionary(args, StringComparer.Ordinal); + if (!callArgs.Keys.Any(key => + string.Equals(ToolArgumentHelper.ResolveMetaField(key), "_rationale", StringComparison.Ordinal))) + callArgs["_rationale"] = "Validate the tool argument contract."; + + var toolCall = new FunctionCallContent("call-1", "shell_execute", callArgs); return await _executor.ExecuteAsync( toolCall, PersonalContext(sessionDir), TestContext.Current.CancellationToken); } @@ -76,6 +81,42 @@ private async Task ExecuteShellAsync(IDictionary args) } } + [Fact] + public void Missing_rationale_rejects_before_execution() + { + var rejection = _executor.ValidateToolCall(new FunctionCallContent( + "call-missing-rationale", + "shell_execute", + new Dictionary { ["Command"] = "echo should-not-run" })); + + Assert.NotNull(rejection); + Assert.Equal("invalid_rationale", rejection!.DenyReason); + Assert.Contains("'_rationale'", rejection.Message); + Assert.Contains("non-empty string", rejection.Message); + Assert.Contains("NOT executed", rejection.Message); + } + + [Fact] + public void Blank_null_and_non_string_rationales_reject() + { + object?[] invalidValues = [null, " ", 42, false]; + + foreach (var invalidValue in invalidValues) + { + var rejection = _executor.ValidateToolCall(new FunctionCallContent( + "call-invalid-rationale", + "shell_execute", + new Dictionary + { + ["Command"] = "echo should-not-run", + ["_rationale"] = invalidValue + })); + + Assert.NotNull(rejection); + Assert.Equal("invalid_rationale", rejection!.DenyReason); + } + } + [Fact] public async Task TimeoutSeconds_accepted_and_consumed_as_meta_field() { @@ -294,7 +335,8 @@ public async Task Mcp_tools_exempt_from_native_validation() result = await executor.ExecuteAsync( new FunctionCallContent("call-mcp", "memorizer/store", new Dictionary { - ["TotallyUnknownKey"] = "value" + ["TotallyUnknownKey"] = "value", + ["_rationale"] = "Verify the MCP validation boundary." }), TestToolExecutionContext.CreateUnbound(), ct: TestContext.Current.CancellationToken); @@ -345,7 +387,12 @@ public void Mcp_conflicting_meta_spellings_rejected_as_ambiguous() public void InterpretToolCall_valid_extracts_meta_and_strips_keys() { var interp = _executor.InterpretToolCall(new FunctionCallContent("c", "shell_execute", - new Dictionary { ["Command"] = "echo hi", ["TimeoutSeconds"] = 300 })); + new Dictionary + { + ["Command"] = "echo hi", + ["TimeoutSeconds"] = 300, + ["_rationale"] = "Verify meta extraction." + })); Assert.Null(interp.Rejection); Assert.Equal(300, interp.Meta?.TimeoutHintSeconds); diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index fa065f2f9..3be83855c 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -428,6 +428,8 @@ private async Task ExecuteSingleToolAsync( string? sessionScratchDenialDirectory, ModelInputBatchBudget modelInputBudget) { + var originalToolCall = tc; + // Single execution-preflight seam, shared with the sub-agent path via // IToolExecutor.InterpretToolCall: validate the ORIGINAL arguments (parse // sentinel, invalid/ambiguous meta values, unrecognized keys) and, on @@ -685,7 +687,7 @@ void EmitToolActivity(ToolActivityUpdate update) } resultText = await ExecuteToolAttemptAsync( - _executor, tc, context, timeout, _timeProvider, EmitToolActivity, batch.CancellationToken); + _executor, originalToolCall, context, timeout, _timeProvider, EmitToolActivity, batch.CancellationToken); sw.Stop(); } @@ -825,7 +827,7 @@ ToolAgentCorrection.SessionScratchSuggested scratchCorrection } resultText = await ExecuteToolAttemptAsync( - _executor, tc, context, timeout, _timeProvider, EmitToolActivity, batch.CancellationToken); + _executor, originalToolCall, context, timeout, _timeProvider, EmitToolActivity, batch.CancellationToken); sw.Stop(); } diff --git a/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs b/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs index d9fa4d64c..2471bc65f 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs @@ -15,6 +15,10 @@ namespace Netclaw.Actors.Sessions.Pipelines; /// internal static class ToolCallMetaExtractor { + private const string RequiredRationaleError = + "Error: Required meta argument '_rationale' must be a non-empty string. " + + "Supply one sentence that states the tool call intent. The tool was NOT executed."; + /// /// Maps a key to its canonical meta field (schema-aware for the executor, /// exact for persistence). Defaults to exact. See @@ -91,4 +95,31 @@ public static (ToolCallMeta? Meta, FunctionCallContent CleanedToolCall) Extract( return valueError; } + + /// + /// Requires a usable rationale for a new tool execution. + /// Transcript extraction remains tolerant of old calls without this field. + /// + public static string? ValidateRequiredRationale( + IDictionary? arguments, Func resolveMeta) + { + if (arguments is null || arguments.Count == 0) + return RequiredRationaleError; + + foreach (var kvp in arguments) + { + if (!string.Equals(resolveMeta(kvp.Key), "_rationale", StringComparison.Ordinal)) + continue; + + return kvp.Value switch + { + string value when !string.IsNullOrWhiteSpace(value) => null, + JsonElement { ValueKind: JsonValueKind.String } value + when !string.IsNullOrWhiteSpace(value.GetString()) => null, + _ => RequiredRationaleError + }; + } + + return RequiredRationaleError; + } } diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index 4c4b94916..840d9476d 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -1314,7 +1314,7 @@ executor is ISessionScratchRetryAwareExecutor scratchAwareExecutor } try { - var result = await executor.ExecuteAsync(cleanedTc, toolContext, ct); + var result = await executor.ExecuteAsync(tc, toolContext, ct); return BuildToolResult( cleanedTc, result, @@ -1413,7 +1413,7 @@ ToolAgentCorrection.SessionScratchSuggested scratchCorrection // across parallel tool calls or later iterations. var retryContext = CreatePerToolExecutionContext(executionContext, meta); retryContext.Approval.SeedOneTimeApproval(tc.Name, OneTimeApprovalKeys.Create(ctx)); - var result = await executor.ExecuteAsync(cleanedTc, retryContext, ct); + var result = await executor.ExecuteAsync(tc, retryContext, ct); return BuildToolResult( cleanedTc, result, diff --git a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs index b241eeeac..34502aabb 100644 --- a/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs +++ b/src/Netclaw.Actors/Tools/DispatchingToolExecutor.cs @@ -86,6 +86,9 @@ public ToolCallInterpretation InterpretToolCall(FunctionCallContent toolCall) if (ValidateArguments(toolCall.Arguments, resolveMeta) is { } rejection) return rejection; + if (ToolCallMetaExtractor.ValidateRequiredRationale(toolCall.Arguments, resolveMeta) is { } rationaleError) + return new ToolArgumentRejection(rationaleError, "invalid_rationale"); + if (registered is not McpToolAdapter && ToolArgumentValidator.ValidateArgumentKeys(registered, toolCall.Arguments) is { } keyError) return new ToolArgumentRejection(keyError, "unrecognized_argument"); @@ -141,12 +144,10 @@ public async Task ExecuteAsync(FunctionCallContent toolCall, ToolExecuti return $"Unknown tool: {toolCall.Name}"; } - // Pre-dispatch validation runs before authorization so a doomed call - // never raises an approval prompt. This is the shared seam: callers that - // bypass the session pipeline (sub-agents, direct callers) get the same - // protection here. The pipeline preflights via ValidateToolCall too, so - // for that path this is a cheap idempotent re-check. - if (ValidateToolCall(toolCall) is { } rejection) + // Interpret the original call before authorization. This keeps required + // metadata available for validation and removes it before tool dispatch. + var interpretation = InterpretToolCall(toolCall); + if (interpretation.Rejection is { } rejection) { _logger.LogWarning( "Rejected tool call ({Reason}): {ToolName} — {Error}", @@ -154,6 +155,8 @@ public async Task ExecuteAsync(FunctionCallContent toolCall, ToolExecuti return rejection.Message; } + toolCall = interpretation.Cleaned; + var tool = await GetAuthorizedToolAsync(toolCall, context, ct); var sw = Stopwatch.StartNew(); @@ -228,8 +231,9 @@ public async IAsyncEnumerable ExecuteStreamAsync( yield break; } - // Same pre-authorization validation as the non-streaming path. - if (ValidateToolCall(toolCall) is { } rejection) + // Use the same atomic validation and extraction as the non-streaming path. + var interpretation = InterpretToolCall(toolCall); + if (interpretation.Rejection is { } rejection) { _logger.LogWarning( "Rejected tool call ({Reason}): {ToolName} — {Error}", @@ -238,6 +242,8 @@ public async IAsyncEnumerable ExecuteStreamAsync( yield break; } + toolCall = interpretation.Cleaned; + // Authorization throws (ToolApprovalRequiredException / ToolAccessDeniedException) // before the first item is produced; the tool-execution pipeline handles // those exactly as it does for the non-streaming path. From f79dfe1e8cbe462e7ff1c16b3fec25f3d2db84d4 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 20:16:39 -0500 Subject: [PATCH 27/39] test(chat): verify settled block spacing --- src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index f692600b1..1a59c2469 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -453,9 +453,9 @@ public async Task StableTranscript_UsesPrimaryScrollbackWithoutAnOuterBorder() var runTask = harness.StartAsync(); await harness.WaitUntilAsync(() => harness.Terminal.Contains("A stable answer")); - Assert.Equal(string.Empty, harness.Terminal.GetLine(0)); - Assert.Equal(" Netclaw", harness.Terminal.GetLine(1)); - Assert.Equal(" A stable answer", harness.Terminal.GetLine(2)); + Assert.Equal(" Netclaw", harness.Terminal.GetLine(0)); + Assert.Equal(" A stable answer", harness.Terminal.GetLine(1)); + Assert.Equal(string.Empty, harness.Terminal.GetLine(2)); AssertHasNoDecorativeTrim(harness.Terminal.ToString()); await harness.StopAsync(runTask); } From 9839c7bfba8375c997db83636259703b56e3a3c9 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 20:36:12 -0500 Subject: [PATCH 28/39] fix(chat): preserve the thinking pulse for queued prompts --- src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs | 2 ++ src/Netclaw.Cli/Program.cs | 2 +- src/Netclaw.Cli/Tui/ChatViewModel.cs | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index dd3d0e167..6e9c00945 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -179,6 +179,7 @@ public async Task ChatViewModel_sends_all_active_turn_prompts_to_the_session_buf using var viewModel = CreateViewModel(client); await ActivateAsync(viewModel); viewModel.IsGenerating.Value = true; + viewModel.StatusMessage.Value = "Generating..."; await Task.WhenAll( viewModel.SubmitAsync("prompt A"), @@ -191,6 +192,7 @@ await Task.WhenAll( .ToList(); Assert.Equal(["prompt A", "prompt B", "prompt C"], sends); Assert.Equal(3, viewModel.QueuedTurnMessageCount.Value); + Assert.Equal("Generating...", viewModel.StatusMessage.Value); transport.PushOutput(SessionOutputDtoMapper.ToDto(new TurnCompleted { diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 9c76f73b5..4c465dbdc 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -1414,7 +1414,7 @@ static void WriteChatHelp() Console.WriteLine(" Enter Send the prompt"); Console.WriteLine(" Shift+Enter Add a line to the prompt"); Console.WriteLine(" Up / Down Recall prompts and restore the current draft"); - Console.WriteLine(" Esc Esc Clear the prompt"); + Console.WriteLine(" Esc x2 Clear the prompt"); Console.WriteLine(" Ctrl+O Open the Inspector or expand an approval"); Console.WriteLine(" Y / Shift+Y Copy an Inspector event or its complete turn"); Console.WriteLine(" Ctrl+Q Exit chat"); diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index c43e76209..9ec707ddf 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -267,7 +267,6 @@ private void AddQueuedTurnMessage() { var count = Interlocked.Increment(ref _queuedTurnMessageCount); QueuedTurnMessageCount.Value = count; - StatusMessage.Value = $"Queued {count} message(s)."; RequestRedraw(); } From 1d9e9767e6d826244b76d36c2aa8bc55c9e71be6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 20:52:17 -0500 Subject: [PATCH 29/39] fix(chat): report inline host failures --- .../Tui/ChatHostGuardTests.cs | 49 +++++++++++++++++++ src/Netclaw.Cli/Program.cs | 20 +++++++- src/Netclaw.Cli/Tui/ChatHostGuard.cs | 34 +++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 src/Netclaw.Cli.Tests/Tui/ChatHostGuardTests.cs create mode 100644 src/Netclaw.Cli/Tui/ChatHostGuard.cs diff --git a/src/Netclaw.Cli.Tests/Tui/ChatHostGuardTests.cs b/src/Netclaw.Cli.Tests/Tui/ChatHostGuardTests.cs new file mode 100644 index 000000000..811bdf6bf --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/ChatHostGuardTests.cs @@ -0,0 +1,49 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +// Copyright (c) Petabridge, LLC. All rights reserved. +// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information. + +using Netclaw.Cli.Tui; +using Xunit; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class ChatHostGuardTests +{ + [Fact] + public async Task Host_failure_is_visible_and_writes_the_crash_log() + { + var error = new StringWriter(); + Exception? logged = null; + var failure = new InvalidOperationException("inline terminal unavailable"); + + var started = await ChatHostGuard.TryRunAsync( + () => Task.FromException(failure), + error, + ex => logged = ex); + + Assert.False(started); + Assert.Same(failure, logged); + Assert.Contains("chat UI could not run", error.ToString(), StringComparison.Ordinal); + Assert.Contains(failure.Message, error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Successful_host_does_not_write_an_error_or_crash_log() + { + var error = new StringWriter(); + Exception? logged = null; + + var started = await ChatHostGuard.TryRunAsync( + () => Task.CompletedTask, + error, + ex => logged = ex); + + Assert.True(started); + Assert.Null(logged); + Assert.Equal(string.Empty, error.ToString()); + } +} diff --git a/src/Netclaw.Cli/Program.cs b/src/Netclaw.Cli/Program.cs index 4c465dbdc..a78e71fd8 100644 --- a/src/Netclaw.Cli/Program.cs +++ b/src/Netclaw.Cli/Program.cs @@ -1130,7 +1130,12 @@ static async Task RunAsync(string[] args) } using (var app = webBuilder.Build()) - await RunTerminaHostAsync(app); + { + if (mode == "chat") + await RunChatHostAsync(app); + else + await RunTerminaHostAsync(app); + } if (mode == "sessions" && navState.ChatLaunchRequested) await RunInlineChatHostAsync(args, navState.ResumeSessionId); @@ -1155,7 +1160,18 @@ static async Task RunInlineChatHostAsync(string[] args, string? resumeSessionId) }); using var app = builder.Build(); - await RunTerminaHostAsync(app); + await RunChatHostAsync(app); +} + +static async Task RunChatHostAsync(IHost host) +{ + var started = await ChatHostGuard.TryRunAsync( + () => RunTerminaHostAsync(host), + Console.Error, + WriteCrashLog); + + if (!started) + Environment.ExitCode = 1; } static void ConfigureNativeSelection(TerminaBuilder termina) diff --git a/src/Netclaw.Cli/Tui/ChatHostGuard.cs b/src/Netclaw.Cli/Tui/ChatHostGuard.cs new file mode 100644 index 000000000..c56315ed7 --- /dev/null +++ b/src/Netclaw.Cli/Tui/ChatHostGuard.cs @@ -0,0 +1,34 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +// Copyright (c) Petabridge, LLC. All rights reserved. +// Licensed under the Apache 2.0 license. See LICENSE file in the project root for full license information. + +namespace Netclaw.Cli.Tui; + +internal static class ChatHostGuard +{ + public static async Task TryRunAsync( + Func runHostAsync, + TextWriter error, + Action writeCrashLog) + { + ArgumentNullException.ThrowIfNull(runHostAsync); + ArgumentNullException.ThrowIfNull(error); + ArgumentNullException.ThrowIfNull(writeCrashLog); + + try + { + await runHostAsync().ConfigureAwait(false); + return true; + } + catch (Exception ex) + { + writeCrashLog(ex); + await error.WriteLineAsync($"netclaw: chat UI could not run: {ex.Message}").ConfigureAwait(false); + return false; + } + } +} From 73eb7b7618ecb7865fa96d910eb584e4336fcc67 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 21:01:17 -0500 Subject: [PATCH 30/39] test(approvals): align typed policy fixtures --- src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs | 2 ++ src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs index cb037f7c3..61adaabd5 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs @@ -1367,6 +1367,8 @@ await sessionManager.Ask(new JoinSession(subscriberB) SenderId = new SenderId("local-user") }, ActorRefs.Nobody); + await ExpectApprovalOutcomeAsync(subscriberB, callId, ApprovalOptionKeys.Deny); + var toolResult = await subscriberB.ExpectMsgAsync( TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); await subscriberB.ExpectMsgAsync( diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 627620fda..7df590df5 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -1406,7 +1406,8 @@ private static FunctionCallContent ScratchCall(string callId) => new(callId, ShellTool.ToolName, new Dictionary { ["Command"] = "gh api repos/example/project", - ["WorkingDirectory"] = "/tmp" + ["WorkingDirectory"] = "/tmp", + ["_rationale"] = "Verify the session scratch correction." }); private static FunctionCallContent ProjectScopeCall(string callId, string workingDirectory) From 51bfd33615216f09924b4fc0c175ca37b762ed28 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 21:48:49 -0500 Subject: [PATCH 31/39] fix(chat): preserve queued prompt batches --- .../Protocol/SerializationRoundTripTests.cs | 44 +++++ .../Sessions/LlmSessionIntegrationTests.cs | 20 ++ .../Sessions/SessionStateTests.cs | 53 ++++++ .../Serialization/NetclawProtoMapper.cs | 22 ++- .../Protos/netclaw_messages.proto | 2 + .../Sessions/LlmSessionActor.cs | 90 ++++++--- .../SessionMemoryCheckpointFactory.cs | 13 +- .../Sessions/SessionProtocol.Events.cs | 14 ++ src/Netclaw.Actors/Sessions/SessionState.cs | 11 +- .../Cli/DaemonClientSessionTests.cs | 86 +++++++++ src/Netclaw.Cli/Tui/ChatViewModel.cs | 172 +++++++++++++----- 11 files changed, 443 insertions(+), 84 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs index 901123f77..4ccd37d91 100644 --- a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs @@ -132,6 +132,50 @@ public void TurnRecorded_round_trips() Assert.Equal(original.RecordedAtMs, result.RecordedAtMs); } + [Fact] + public void TurnRecorded_round_trips_all_user_messages_in_order() + { + var original = new TurnRecorded + { + SessionId = new SessionId("test/user-message-batch"), + UserMessage = new SerializableChatMessage { Role = ChatRole.User, Content = "Second" }, + UserMessages = + [ + new SerializableChatMessage { Role = ChatRole.User, Content = "First" }, + new SerializableChatMessage { Role = ChatRole.User, Content = "Second" } + ], + AssistantReply = new SerializableChatMessage { Role = ChatRole.Assistant, Content = "Done" }, + RecordedAtMs = 42 + }; + + var result = RoundTrip(original); + + Assert.Equal(["First", "Second"], result.UserMessages.Select(message => message.Content)); + Assert.Equal("Second", result.UserMessage.Content); + } + + [Fact] + public void ToolBatchStarted_round_trips_all_user_messages_in_order() + { + var original = new ToolBatchStarted + { + SessionId = new SessionId("test/tool-user-message-batch"), + UserMessage = new SerializableChatMessage { Role = ChatRole.User, Content = "Second" }, + UserMessages = + [ + new SerializableChatMessage { Role = ChatRole.User, Content = "First" }, + new SerializableChatMessage { Role = ChatRole.User, Content = "Second" } + ], + AssistantMessage = new SerializableChatMessage { Role = ChatRole.Assistant, Content = "" }, + StartedAtMs = 42 + }; + + var result = RoundTrip(original); + + Assert.Equal(["First", "Second"], result.UserMessages.Select(message => message.Content)); + Assert.Equal("Second", result.UserMessage.Content); + } + [Fact] public void TurnRecorded_round_trips_structured_transcript_entries() { diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs index ca646a392..515b480a9 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs @@ -792,6 +792,26 @@ await sessionManager.Ask(new SendUserMessage followUpUserMessages.TakeLast(2)); await subscriber.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(300), cancellationToken: TestContext.Current.CancellationToken); + + var escapedId = Uri.EscapeDataString(sessionId.Value); + var child = await Sys.ActorSelection($"/user/session-manager/{escapedId}") + .ResolveOne(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken); + Watch(child); + Sys.Stop(child); + await ExpectTerminatedAsync(child, cancellationToken: TestContext.Current.CancellationToken); + + var recoverySubscriber = CreateTestProbe("adapter-batch-recovery"); + var recovered = await sessionManager.Ask(new JoinSession(recoverySubscriber) + { + SessionId = sessionId, + Filter = OutputFilter.Full + }, TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + await recoverySubscriber.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(recovered.RecentTranscript!, entry => + entry.Type == SessionTranscriptEntryTypes.User && entry.Text == "Second message"); + Assert.Contains(recovered.RecentTranscript!, entry => + entry.Type == SessionTranscriptEntryTypes.User && entry.Text == "Third message"); } [Fact] diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs index 0fa69e829..0954bb1a0 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionStateTests.cs @@ -52,6 +52,59 @@ public void Apply_TurnRecorded_adds_messages_and_increments_turn() Assert.Equal(1, next.TurnCount); } + [Fact] + public void Apply_TurnRecorded_restores_all_batched_user_messages() + { + var state = SessionState.Empty.Apply(new TurnRecorded + { + SessionId = TestSessionId, + UserMessage = new SerializableChatMessage { Role = ChatRole.User, Content = "Second" }, + UserMessages = + [ + new SerializableChatMessage { Role = ChatRole.User, Content = "First" }, + new SerializableChatMessage { Role = ChatRole.User, Content = "Second" } + ], + AssistantReply = new SerializableChatMessage { Role = ChatRole.Assistant, Content = "Done" }, + RecordedAtMs = 42 + }); + + Assert.Equal( + ["First", "Second", "Done"], + state.History.Select(message => message.Content)); + Assert.Equal( + [SessionTranscriptEntryTypes.User, SessionTranscriptEntryTypes.User, SessionTranscriptEntryTypes.Assistant], + state.RecentTranscript.Select(entry => entry.Type)); + Assert.Equal( + ["First", "Second", "Done"], + state.RecentTranscript.Select(entry => entry.Text)); + Assert.Equal(1, state.TurnCount); + } + + [Fact] + public void Turn_checkpoint_keeps_all_batched_user_messages() + { + var turn = new TurnRecorded + { + SessionId = TestSessionId, + UserMessage = new SerializableChatMessage { Role = ChatRole.User, Content = "Second" }, + UserMessages = + [ + new SerializableChatMessage { Role = ChatRole.User, Content = "First" }, + new SerializableChatMessage { Role = ChatRole.User, Content = "Second" } + ], + AssistantReply = new SerializableChatMessage { Role = ChatRole.Assistant, Content = "Done" } + }; + + var checkpoint = SessionMemoryCheckpointFactory.ForTurnComplete( + TestSessionId, + turn, + "trusted-instance", + "personal"); + + Assert.Equal("First\n\nSecond", checkpoint.UserContent); + Assert.Equal("User: First\n\nSecond\nAssistant: Done", checkpoint.Content); + } + [Fact] public void Apply_TurnRecorded_is_cumulative() { diff --git a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs index b9c951707..aab13487c 100644 --- a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs +++ b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs @@ -272,6 +272,7 @@ internal static Proto.TurnRecordedProto ToProto(TurnRecorded evt) if (evt.SourceBackgroundJobId is { } backgroundJobId) proto.SourceBackgroundJobId = backgroundJobId.Value; proto.TranscriptEntries.AddRange(evt.TranscriptEntries.Select(ToProto)); + proto.UserMessages.AddRange(evt.UserMessages.Select(ToProto)); return proto; } @@ -283,7 +284,8 @@ internal static Proto.TurnRecordedProto ToProto(TurnRecorded evt) RecordedAtMs = proto.RecordedAtMs, SourceReminderId = proto.HasSourceReminderId ? new ReminderId(proto.SourceReminderId) : (ReminderId?)null, SourceBackgroundJobId = proto.HasSourceBackgroundJobId ? new BackgroundJobId(proto.SourceBackgroundJobId) : (BackgroundJobId?)null, - TranscriptEntries = proto.TranscriptEntries.Select(FromProto).ToArray() + TranscriptEntries = proto.TranscriptEntries.Select(FromProto).ToArray(), + UserMessages = proto.UserMessages.Select(FromProto).ToArray() }; // ── SessionTitleSet ── @@ -331,18 +333,24 @@ internal static Proto.SessionCompactedProto ToProto(SessionCompacted evt) // ── Tool batch / approval events ── - internal static Proto.ToolBatchStartedProto ToProto(ToolBatchStarted evt) => new() + internal static Proto.ToolBatchStartedProto ToProto(ToolBatchStarted evt) { - SessionId = ToProto(evt.SessionId), - UserMessage = ToProto(evt.UserMessage), - AssistantMessage = ToProto(evt.AssistantMessage), - StartedAtMs = evt.StartedAtMs - }; + var proto = new Proto.ToolBatchStartedProto + { + SessionId = ToProto(evt.SessionId), + UserMessage = ToProto(evt.UserMessage), + AssistantMessage = ToProto(evt.AssistantMessage), + StartedAtMs = evt.StartedAtMs + }; + proto.UserMessages.AddRange(evt.UserMessages.Select(ToProto)); + return proto; + } internal static ToolBatchStarted FromProto(Proto.ToolBatchStartedProto proto) => new() { SessionId = FromProto(proto.SessionId), UserMessage = FromProto(proto.UserMessage), + UserMessages = proto.UserMessages.Select(FromProto).ToArray(), AssistantMessage = FromProto(proto.AssistantMessage), StartedAtMs = proto.StartedAtMs }; diff --git a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto index 191b2c9d1..b35e4c0a8 100644 --- a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto +++ b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto @@ -142,6 +142,7 @@ message TurnRecordedProto { optional string source_reminder_id = 5; optional string source_background_job_id = 6; repeated SessionTranscriptEntryProto transcript_entries = 7; + repeated SerializableChatMessageProto user_messages = 8; } message SessionTitleSetProto { @@ -165,6 +166,7 @@ message ToolBatchStartedProto { SerializableChatMessageProto user_message = 2; SerializableChatMessageProto assistant_message = 3; int64 started_at_ms = 4; + repeated SerializableChatMessageProto user_messages = 5; } message ToolCallRecordedProto { diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 39ea1cc03..cd5b30ec6 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -76,6 +76,7 @@ public sealed class LlmSessionActor : ReceivePersistentActor, IWithTimers // Transient state (not persisted) private readonly List _buffer = []; + private readonly List _currentTurnUserMessages = []; // In-flight reminder/background-job dedup (transient; rebuilt from journal on recovery). private readonly InFlightTurnDedup _inFlightDedup = new(); private readonly SessionSubscriberManager _subscribers = new(); @@ -1009,12 +1010,7 @@ private void HandleToolExecutionCompleted(ToolExecutionCompleted msg) { TurnLog().Info("turn_mid_loop_buffer_drain count={BufferCount} iteration={Iteration}", _buffer.Count, _turnState.ToolIterationCount); - foreach (var buffered in _buffer) - { - var refs = buffered.MediaReferences.Count > 0 ? buffered.MediaReferences : null; - _state = _state.AddUserMessage(buffered.Content, refs); - } - _buffer.Clear(); + AppendBufferedUserMessages(); } switch (budgetStatus) @@ -1393,6 +1389,14 @@ private void RollBackCurrentTurnIntoBuffer() Content = candidate.Content ?? string.Empty, MediaReferences = candidate.MediaReferences }); + for (var userIndex = _currentTurnUserMessages.Count - 1; userIndex >= 0; userIndex--) + { + if (!ReferenceEquals(_currentTurnUserMessages[userIndex], candidate)) + continue; + + _currentTurnUserMessages.RemoveAt(userIndex); + break; + } _state = _state with { History = _state.History.GetRange(0, i) }; return; } @@ -1424,12 +1428,7 @@ private void DrainBufferOrReady() if (hadBufferedMessages) { _log.Info("Post-compaction: draining {BufferCount} buffered message(s)", _buffer.Count); - foreach (var buffered in _buffer) - { - var refs = buffered.MediaReferences.Count > 0 ? buffered.MediaReferences : null; - _state = _state.AddUserMessage(buffered.Content, refs); - } - _buffer.Clear(); + AppendBufferedUserMessages(); } if (resumeToolLoop || hadBufferedMessages) @@ -1887,6 +1886,7 @@ private void HandleToolCallResponse( { SessionId = _sessionId, UserMessage = userMsg, + UserMessages = SnapshotCurrentTurnUserMessages(userMsg), AssistantMessage = assistantMsg, StartedAtMs = NowMs() }, evt => @@ -2148,6 +2148,7 @@ private void HandleTextResponse( Role = Protocol.ChatRole.User, Content = string.Empty }, + UserMessages = SnapshotCurrentTurnUserMessages(userMsg), AssistantReply = reply, RecordedAtMs = recordedAtMs, SourceReminderId = _currentTurnSource?.ReminderId, @@ -2185,6 +2186,7 @@ private void HandleTextResponse( _settledTurnEntries.Clear(); _transcriptToolCalls.Clear(); + _currentTurnUserMessages.Clear(); EmitResponseOutputs(lastMessage, usage, includeText: true, includeThinking: true); MaybeSnapshot(); @@ -2225,9 +2227,12 @@ private IReadOnlyList BuildTurnTranscriptEntries( long recordedAtMs) { var turnId = _activeTurnId?.Value; + var transcriptStart = _currentTurnUserMessages.Count > 0 + ? _currentTurnUserMessages[0] + : userMessage; var extracted = SessionTranscriptExtractor.ExtractTurn( _state.History, - userMessage, + transcriptStart, assistantReply, turnId, recordedAtMs); @@ -2256,6 +2261,17 @@ private IReadOnlyList BuildTurnTranscriptEntries( return entries; } + private IReadOnlyList SnapshotCurrentTurnUserMessages( + SerializableChatMessage? fallback) + { + if (_currentTurnUserMessages.Count > 0) + return _currentTurnUserMessages.ToArray(); + + return fallback is null + ? Array.Empty() + : [fallback]; + } + private void DrainBufferedMessagesOrBecomeReady() { if (_restartDrainRequested) @@ -2269,13 +2285,7 @@ private void DrainBufferedMessagesOrBecomeReady() if (_buffer.Count > 0) { TurnLog().Info("turn_buffer_drain count={BufferCount}", _buffer.Count); - foreach (var buffered in _buffer) - { - var refs = buffered.MediaReferences.Count > 0 ? buffered.MediaReferences : null; - _state = _state.AddUserMessage(buffered.Content, refs); - } - - _buffer.Clear(); + AppendBufferedUserMessages(); _recallManager.ResetForNewTurn(); // New user input — resolve recall fresh FireLlmCall(); // Already in Processing — no transition needed, just fired a new LLM call @@ -2286,6 +2296,18 @@ private void DrainBufferedMessagesOrBecomeReady() TransitionTo(SessionPhase.Ready); } + private void AppendBufferedUserMessages() + { + foreach (var buffered in _buffer) + { + var refs = buffered.MediaReferences.Count > 0 ? buffered.MediaReferences : null; + _state = _state.AddUserMessage(buffered.Content, refs); + _currentTurnUserMessages.Add(_state.History[^1]); + } + + _buffer.Clear(); + } + private void HandleDeliveryFailedWhenReady(DeliveryFailed msg) { if (_deliveryRetry.EligibleTurnNumber != msg.TurnNumber) @@ -2449,6 +2471,7 @@ private void ContinueIncomingUserMessage(SendUserMessage cmd) _observerActor?.Tell(cmd); _turnState.ResetForNewTurn(); + _currentTurnUserMessages.Clear(); _discoveredToolCache.PrepareForNewTurn( _config.Tuning.DiscoveredToolRetentionTurns, _config.Tuning.DiscoveredToolMaxCount, @@ -2458,6 +2481,7 @@ private void ContinueIncomingUserMessage(SendUserMessage cmd) return; _state = _state.AddUserMessage(userContent, mediaRefs.Count > 0 ? mediaRefs : null); + _currentTurnUserMessages.Add(_state.History[^1]); TryReplyAck(); _recallManager.ResetForNewTurn(); _compactionOverflowRetryCount = 0; @@ -3247,6 +3271,7 @@ private bool HandleInlineSlashCommand(SkillEntry skill, string remainder, IReadO : remainder; _state = _state.AddUserMessage(effectiveUserContent, mediaRefs.Count > 0 ? mediaRefs : null); + _currentTurnUserMessages.Add(_state.History[^1]); TryReplyAck(); _recallManager.ResetForNewTurn(); @@ -3349,6 +3374,7 @@ private bool TryHandleRoutedSlashCommand(SkillEntry skill, string remainder, IRe : remainder; _state = _state.AddUserMessage(effectiveTask, mediaRefs.Count > 0 ? mediaRefs : null); + _currentTurnUserMessages.Add(_state.History[^1]); TryReplyAck(); _recallManager.ResetForNewTurn(); @@ -3453,6 +3479,7 @@ private void HandleRoutedSkillExecutionCompleted(RoutedSkillExecutionCompleted m Role = Protocol.ChatRole.User, Content = string.Empty }, + UserMessages = SnapshotCurrentTurnUserMessages(userMsg), AssistantReply = assistantReply, RecordedAtMs = recordedAtMs, SourceReminderId = _currentTurnSource?.ReminderId, @@ -3490,6 +3517,7 @@ private void HandleRoutedSkillExecutionCompleted(RoutedSkillExecutionCompleted m _settledTurnEntries.Clear(); _transcriptToolCalls.Clear(); + _currentTurnUserMessages.Clear(); EmitOutput(new TextOutput(msg.Result.Output) { @@ -3622,11 +3650,13 @@ private void ApplyTurnRecorded(TurnRecorded evt) }).AppendTranscript(evt) .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages))) .CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); + _currentTurnUserMessages.Clear(); return; } _state = _state.Apply(evt) .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages)); + _currentTurnUserMessages.Clear(); } private void ApplyToolBatchStarted(ToolBatchStarted evt) @@ -3637,8 +3667,15 @@ private void ApplyToolBatchStarted(ToolBatchStarted evt) private void ApplyToolBatchHistory(ToolBatchStarted evt) { - if (_state.FindLastUserMessage() != evt.UserMessage) - _state = _state with { History = _state.History.Add(evt.UserMessage) }; + if (_currentTurnUserMessages.Count == 0) + { + var userMessages = evt.UserMessages.Count > 0 + ? evt.UserMessages + : [evt.UserMessage]; + if (_state.FindLastUserMessage() != userMessages[^1]) + _state = _state with { History = _state.History.AddRange(userMessages) }; + _currentTurnUserMessages.AddRange(userMessages); + } if (!_state.History.Contains(evt.AssistantMessage)) _state = _state with { History = _state.History.Add(evt.AssistantMessage) }; @@ -4688,6 +4725,7 @@ private void FailCurrentTurn(string errorMessage, Exception cause, ErrorCategory { SessionId = _sessionId, UserMessage = userMessage, + UserMessages = SnapshotCurrentTurnUserMessages(userMessage), AssistantReply = assistantReply, RecordedAtMs = recordedAtMs, SourceReminderId = _currentTurnSource?.ReminderId, @@ -4719,6 +4757,7 @@ private void FailCurrentTurn(string errorMessage, Exception cause, ErrorCategory .CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); _settledTurnEntries.Clear(); _transcriptToolCalls.Clear(); + _currentTurnUserMessages.Clear(); EmitOutput(errorOutput); EmitOutput(new TurnCompleted @@ -5060,12 +5099,7 @@ private void CompleteToolBatch(int resultCount) { TurnLog().Info("turn_mid_loop_buffer_drain count={BufferCount} iteration={Iteration}", _buffer.Count, _turnState.ToolIterationCount); - foreach (var buffered in _buffer) - { - var refs = buffered.MediaReferences.Count > 0 ? buffered.MediaReferences : null; - _state = _state.AddUserMessage(buffered.Content, refs); - } - _buffer.Clear(); + AppendBufferedUserMessages(); } switch (budgetStatus) diff --git a/src/Netclaw.Actors/Sessions/SessionMemoryCheckpointFactory.cs b/src/Netclaw.Actors/Sessions/SessionMemoryCheckpointFactory.cs index ed5a5e450..a6ddf7ca9 100644 --- a/src/Netclaw.Actors/Sessions/SessionMemoryCheckpointFactory.cs +++ b/src/Netclaw.Actors/Sessions/SessionMemoryCheckpointFactory.cs @@ -75,12 +75,18 @@ public static MemoryCheckpointPayload ForTurnComplete( TurnRecorded turn, string boundary, string audience) - => new( + { + var userContent = string.Join( + "\n\n", + (turn.UserMessages.Count > 0 ? turn.UserMessages : [turn.UserMessage]) + .Select(message => message.Content)); + + return new( SessionId: sessionId.Value, TriggerType: CheckpointTriggerType.TurnComplete.ToWireValue(), Source: "session", - Content: $"User: {turn.UserMessage.Content}\nAssistant: {turn.AssistantReply.Content}", - UserContent: turn.UserMessage.Content, + Content: $"User: {userContent}\nAssistant: {turn.AssistantReply.Content}", + UserContent: userContent, AssistantContent: turn.AssistantReply.Content, IsExplicitRequest: false, HasVerifiedToolFinding: false, @@ -94,4 +100,5 @@ public static MemoryCheckpointPayload ForTurnComplete( Kind: MemoryKind.Document.ToWireValue(), Title: "turn-completion", UpdateSemantics: "append-document"); + } } diff --git a/src/Netclaw.Actors/Sessions/SessionProtocol.Events.cs b/src/Netclaw.Actors/Sessions/SessionProtocol.Events.cs index 03697926c..e86403000 100644 --- a/src/Netclaw.Actors/Sessions/SessionProtocol.Events.cs +++ b/src/Netclaw.Actors/Sessions/SessionProtocol.Events.cs @@ -26,6 +26,13 @@ public sealed record TurnRecorded : ISessionEvent public SerializableChatMessage UserMessage { get; init; } = new(); + /// + /// All user messages that the actor submitted in this turn. + /// Empty records use for legacy replay. + /// + public IReadOnlyList UserMessages { get; init; } = + Array.Empty(); + public SerializableChatMessage AssistantReply { get; init; } = new(); public long RecordedAtMs { get; init; } @@ -72,6 +79,13 @@ public sealed record ToolBatchStarted : ISessionEvent public SerializableChatMessage UserMessage { get; init; } = new(); + /// + /// All user messages that caused this tool batch. + /// Empty records use for legacy replay. + /// + public IReadOnlyList UserMessages { get; init; } = + Array.Empty(); + public SerializableChatMessage AssistantMessage { get; init; } = new(); public long StartedAtMs { get; init; } diff --git a/src/Netclaw.Actors/Sessions/SessionState.cs b/src/Netclaw.Actors/Sessions/SessionState.cs index 8487439ea..430503dd2 100644 --- a/src/Netclaw.Actors/Sessions/SessionState.cs +++ b/src/Netclaw.Actors/Sessions/SessionState.cs @@ -106,9 +106,13 @@ public SessionState Apply(TurnRecorded evt) // Background-job dedup/remove/prune is delegated to the single shared // helper so the replay path here and the live turn-completion path in // LlmSessionActor cannot drift. + var userMessages = evt.UserMessages.Count > 0 + ? evt.UserMessages + : [evt.UserMessage]; + return (AppendTranscript(evt) with { - History = History.Add(evt.UserMessage).Add(evt.AssistantReply), + History = History.AddRange(userMessages).Add(evt.AssistantReply), TurnCount = TurnCount + 1, ProcessedReminderIds = processedReminders }).CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); @@ -116,10 +120,13 @@ public SessionState Apply(TurnRecorded evt) public SessionState AppendTranscript(TurnRecorded evt) { + var userMessages = evt.UserMessages.Count > 0 + ? evt.UserMessages + : [evt.UserMessage]; var entries = evt.TranscriptEntries.Count > 0 ? evt.TranscriptEntries : SessionTranscriptExtractor.Extract( - [evt.UserMessage, evt.AssistantReply], + userMessages.Append(evt.AssistantReply), timestampMs: evt.RecordedAtMs); return this with { RecentTranscript = RecentTranscript.AddRange(entries) }; diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index 6e9c00945..5248a7f9a 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -91,6 +91,51 @@ public async Task ChatViewModel_initial_resume_uses_one_session_attach() Assert.Equal(1, state.EnsureCount); } + [Fact] + public async Task ChatViewModel_retains_the_resume_id_after_a_transient_attach_failure() + { + const string resumedSessionId = "signalr/resume-target"; + var requestedSessionIds = new List(); + var failFirstAttach = true; + var transport = new FakeDaemonHubTransport + { + EnsureSessionResponder = args => + { + var requested = args[0] as string; + requestedSessionIds.Add(requested); + if (failFirstAttach) + { + failFirstAttach = false; + throw new IOException("test attach failure"); + } + + return new SessionEnsureResultDto(resumedSessionId, false); + } + }; + await using var client = new DaemonClient( + "http://127.0.0.1:1", + transport, + reconnectDelays: [TimeSpan.Zero]); + using var viewModel = new ChatViewModel( + client, + TimeProvider.System, + new ModelCapabilities { ModelId = "test-model" }, + new ChatNavigationState { ResumeSessionId = resumedSessionId }, + new NetclawPaths()); + var attached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = viewModel.SessionIdDisplay.Subscribe(value => + { + if (string.Equals(value, resumedSessionId, StringComparison.Ordinal)) + attached.TrySetResult(); + }); + + viewModel.OnActivated(); + await attached.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + + Assert.True(requestedSessionIds.Count >= 2); + Assert.All(requestedSessionIds, requested => Assert.Equal(resumedSessionId, requested)); + } + [Fact] public async Task RespondToInteractionAsync_invokes_hub_method() { @@ -246,6 +291,47 @@ public async Task ChatViewModel_retries_a_rejected_active_turn_prompt_without_lo Assert.Equal(1, viewModel.QueuedTurnMessageCount.Value); } + [Fact] + public async Task ChatViewModel_retains_the_queue_head_when_a_reconnect_flush_fails() + { + var accepted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sendAttempts = 0; + var transport = new FakeDaemonHubTransport + { + VoidInvokeHook = (method, _, _) => + { + if (!string.Equals(method, "SendMessage", StringComparison.Ordinal)) + return Task.CompletedTask; + + if (Interlocked.Increment(ref sendAttempts) < 3) + throw new IOException("test rejection"); + + accepted.TrySetResult(); + return Task.CompletedTask; + } + }; + await using var client = new DaemonClient( + "http://127.0.0.1:1", + transport, + reconnectDelays: [TimeSpan.Zero]); + using var viewModel = CreateViewModel(client); + await ActivateAsync(viewModel); + viewModel.IsGenerating.Value = true; + + await viewModel.SubmitAsync("retain the queue head"); + await accepted.Task.WaitAsync(TimeSpan.FromSeconds(8), TestContext.Current.CancellationToken); + + var sends = transport.Invocations + .Where(invocation => string.Equals(invocation.Method, "SendMessage", StringComparison.Ordinal)) + .Select(invocation => Assert.IsType(invocation.Args[1])) + .ToList(); + Assert.Equal( + ["retain the queue head", "retain the queue head", "retain the queue head"], + sends); + Assert.Equal(1, viewModel.QueuedTurnMessageCount.Value); + Assert.True(viewModel.IsGenerating.Value); + } + private static ChatViewModel CreateViewModel(DaemonClient client) => new( client, TimeProvider.System, diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index 9ec707ddf..dfe5e2e50 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -38,6 +38,8 @@ public partial class ChatViewModel : ReactiveViewModel private readonly Subject _outputSubject = new(); private readonly Queue _pendingMessages = new(); + private readonly object _pendingMessagesGate = new(); + private readonly SemaphoreSlim _sessionAttachGate = new(1, 1); private readonly Queue _pendingInteractions = new(); private int _queuedTurnMessageCount; @@ -58,7 +60,7 @@ public partial class ChatViewModel : ReactiveViewModel // cache analysis and eval tooling that anchors on the per-session log // silently gets no data from TUI turns (issue #1173). private StreamWriter? _usageLog; - private bool _sessionReady; + private volatile bool _sessionReady; private int _connectAttempts; private readonly ObservableCollection _approvalOptions = []; @@ -184,14 +186,14 @@ protected virtual Task InitializeSessionAsync() or DaemonConnectionState.Reconnecting or DaemonConnectionState.TransportClosed) { - _sessionReady = false; + SetSessionReady(false); } // The initial connect path owns the first session attach. // A later Connected event restores an existing attached session. if (evt.State is DaemonConnectionState.Connected && SessionIdDisplay.Value is not null - && !_sessionReady) + && !IsSessionReady()) { _ = EnsureSessionAndFlushAsync(); } @@ -226,12 +228,11 @@ public virtual async Task SubmitAsync(string text) if (isActiveTurnPrompt) AddQueuedTurnMessage(); - if (!_sessionReady || !_daemonClient.IsConnected) + if (TryEnqueuePendingMessageWhenUnavailable(text, out var pendingCount)) { - _pendingMessages.Enqueue(text); - IsGenerating.Value = false; + IsGenerating.Value = isActiveTurnPrompt; IsInputEnabled.Value = true; - StatusMessage.Value = $"Queued {_pendingMessages.Count} message(s). Reconnecting..."; + StatusMessage.Value = $"Queued {pendingCount} message(s). Reconnecting..."; RequestRedraw(); _ = ConnectUntilReadyAsync(); return; @@ -249,9 +250,8 @@ public virtual async Task SubmitAsync(string text) } catch (Exception ex) { - _sessionReady = false; IsInputEnabled.Value = true; - _pendingMessages.Enqueue(text); + SetNotReadyAndEnqueuePendingMessage(text); StatusMessage.Value = $"Send failed ({ex.Message}). Reconnecting..."; RequestRedraw(); _ = ConnectUntilReadyAsync(); @@ -279,7 +279,7 @@ private int PromoteQueuedTurnMessages() private async Task SubmitInteractionResponseAsync(string text) { - if (!_sessionReady || !_daemonClient.IsConnected) + if (!IsSessionReady() || !_daemonClient.IsConnected) { StatusMessage.Value = "Approval required. Reconnecting..."; RequestRedraw(); @@ -547,7 +547,7 @@ private async Task ConnectUntilReadyAsync() TimeSpan.FromSeconds(10) }; - while (!_sessionReady) + while (!IsSessionReady()) { try { @@ -568,43 +568,127 @@ private async Task ConnectUntilReadyAsync() private async Task EnsureSessionAndFlushAsync() { - // On the first call, use ResumeSessionAsync if a resume ID was provided. - // After that, DaemonClient has the session ID cached, so use EnsureSessionAsync - // to avoid redundant resume calls on reconnect. - var resumeId = _resumeSessionId; - _resumeSessionId = null; - var sessionId = resumeId is not null - ? await _daemonClient.ResumeSessionAsync(resumeId, DaemonClient.TuiChannelType) - : await _daemonClient.EnsureSessionAsync(DaemonClient.TuiChannelType); - SessionIdDisplay.Value = sessionId; - OpenUsageLogIfNeeded(sessionId); - _sessionReady = true; - IsInputEnabled.Value = true; - _connectAttempts = 0; - - while (_pendingMessages.Count > 0) + await _sessionAttachGate.WaitAsync(); + try { - var pending = _pendingMessages.Dequeue(); - await _daemonClient.SendAsync(pending); - } + if (IsSessionReady() && _daemonClient.IsConnected) + return; + + // Keep the resume ID until the attach succeeds. A transient attach + // failure must not create a replacement session on the next attempt. + var resumeId = _resumeSessionId; + var sessionId = resumeId is not null + ? await _daemonClient.ResumeSessionAsync(resumeId, DaemonClient.TuiChannelType) + : await _daemonClient.EnsureSessionAsync(DaemonClient.TuiChannelType); + if (resumeId is not null) + _resumeSessionId = null; + + SessionIdDisplay.Value = sessionId; + OpenUsageLogIfNeeded(sessionId); + IsInputEnabled.Value = true; + _connectAttempts = 0; + + while (true) + { + string? pending; + lock (_pendingMessagesGate) + { + pending = _pendingMessages.Count == 0 + ? null + : _pendingMessages.Peek(); + } + + if (pending is not null) + { + await _daemonClient.SendAsync(pending); + lock (_pendingMessagesGate) + { + if (_pendingMessages.Count == 0 + || !string.Equals(_pendingMessages.Peek(), pending, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The pending message queue changed during its ordered flush."); + } + + _pendingMessages.Dequeue(); + } + + continue; + } + + // Auto-send a hidden trigger before this client becomes ready. + // Clear it only after the daemon accepts it. + if (_initialMessage is not null) + { + var trigger = _initialMessage; + IsGenerating.Value = true; + StatusMessage.Value = "Generating..."; + RequestRedraw(); + await _daemonClient.SendAsync(trigger); + _initialMessage = null; + continue; + } + + lock (_pendingMessagesGate) + { + if (_pendingMessages.Count > 0) + continue; + + _sessionReady = true; + break; + } + } + + if (!IsGenerating.Value) + StatusMessage.Value = "Ready"; - // Auto-send hidden trigger message (e.g., onboarding interview prompt). - // Not rendered as a user bubble — the LLM's greeting is the first visible thing. - if (_initialMessage is not null) - { - var trigger = _initialMessage; - _initialMessage = null; - IsGenerating.Value = true; - StatusMessage.Value = "Generating..."; RequestRedraw(); - await _daemonClient.SendAsync(trigger); - return; } + catch + { + SetSessionReady(false); + throw; + } + finally + { + _sessionAttachGate.Release(); + } + } - if (!IsGenerating.Value) - StatusMessage.Value = "Ready"; + private bool IsSessionReady() + { + lock (_pendingMessagesGate) + return _sessionReady; + } - RequestRedraw(); + private void SetSessionReady(bool value) + { + lock (_pendingMessagesGate) + _sessionReady = value; + } + + private bool TryEnqueuePendingMessageWhenUnavailable(string text, out int pendingCount) + { + lock (_pendingMessagesGate) + { + if (_sessionReady && _daemonClient.IsConnected) + { + pendingCount = _pendingMessages.Count; + return false; + } + + _pendingMessages.Enqueue(text); + pendingCount = _pendingMessages.Count; + return true; + } + } + + private void SetNotReadyAndEnqueuePendingMessage(string text) + { + lock (_pendingMessagesGate) + { + _sessionReady = false; + _pendingMessages.Enqueue(text); + } } protected virtual async Task SubmitInteractionSelectionAsync(string selectedKey) @@ -615,7 +699,7 @@ protected virtual async Task SubmitInteractionSelectionAsync(string selectedKey) if (_isSubmittingInteraction) return; - if (!_sessionReady || !_daemonClient.IsConnected) + if (!IsSessionReady() || !_daemonClient.IsConnected) { StatusMessage.Value = "Approval required. Reconnecting..."; RequestRedraw(); @@ -645,7 +729,7 @@ protected virtual async Task SubmitInteractionSelectionAsync(string selectedKey) { _submittedInteractionCallId = null; _isSubmittingInteraction = false; - _sessionReady = false; + SetSessionReady(false); IsGenerating.Value = false; StatusMessage.Value = $"Approval response failed ({ex.Message}). Reconnecting..."; RequestRedraw(); From a7cad77b215f54326f39e5a8664e61ba715b4d11 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 21:51:09 -0500 Subject: [PATCH 32/39] fix(evals): report the built binary version --- evals/run-evals.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/evals/run-evals.sh b/evals/run-evals.sh index 4937ad612..54536c108 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -129,8 +129,6 @@ check_prerequisites() { RESULTS_DB="" fi - NETCLAW_VER=$("$NETCLAW_BIN" --version 2>/dev/null | head -1 || echo "unknown") - if [[ "$RUNS" -lt 1 ]]; then echo "ERROR: NETCLAW_EVAL_RUNS must be >= 1 (got: $RUNS)" >&2 exit 1 @@ -2263,6 +2261,7 @@ main() { echo "ERROR: CLI binary not found at '$NETCLAW_BIN'" >&2 exit 1 fi + NETCLAW_VER=$("$NETCLAW_BIN" --version 2>/dev/null | head -1 || echo "unknown") start_eval_daemon init_db From 9d507d320f28dc91b5c390e4ba87191279cb382d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 22:00:47 -0500 Subject: [PATCH 33/39] fix(evals): read the daemon log by UTC date --- evals/run-evals.sh | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/evals/run-evals.sh b/evals/run-evals.sh index 54536c108..b7e45f1c2 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -142,7 +142,7 @@ check_prerequisites() { if command -v sqlite3 >/dev/null 2>&1; then RESULTS_DB="$RESULTS_DIR/results.db" fi - DAEMON_LOG="$EVAL_HOME/logs/daemon-$(date +%F).log" + DAEMON_LOG="$EVAL_HOME/logs/daemon-$(date -u +%F).log" trap 'cleanup_eval_env' EXIT } @@ -763,7 +763,6 @@ check_daemon_alive() { fi } - run_prompt() { local prompt="$1" local output_format="${2:-text}" @@ -772,9 +771,7 @@ run_prompt() { STDOUT_FILE="$TMPDIR_EVAL/stdout_${ts}.txt" STDERR_FILE="$TMPDIR_EVAL/stderr_${ts}.txt" - # Record daemon log position before the prompt (the daemon writes to a - # daily-rotating file at /root/.netclaw/logs/daemon-YYYY-MM-DD.log, and - # the container bind-mounts that directory from $EVAL_HOME/logs). + # Record the UTC daemon log position before the prompt. if [[ -f "$DAEMON_LOG" ]]; then DAEMON_LOG_LINES_BEFORE=$(wc -l < "$DAEMON_LOG") else From 8aaf70ce109b7ba40ff825625fa09533a554ca83 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Wed, 12 Aug 2026 22:07:35 -0500 Subject: [PATCH 34/39] fix(evals): parse recall counts with portable awk --- evals/run-evals.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/evals/run-evals.sh b/evals/run-evals.sh index b7e45f1c2..3eac536c1 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -1234,8 +1234,12 @@ assert_memory_checkpoint_enqueue() { assert_memory_recall_filters() { # After overfetch fix: at least one candidate selection should reduce the set. daemon_log_tail | awk ' - match($0, /rawCount=([0-9]+).*selectedCount=([0-9]+)/, m) { - if ((m[1] + 0) > (m[2] + 0)) { + match($0, /rawCount=[0-9]+/) { + raw = substr($0, RSTART + 9, RLENGTH - 9) + 0 + if (match($0, /selectedCount=[0-9]+/)) { + selected = substr($0, RSTART + 14, RLENGTH - 14) + 0 + } + if (raw > selected) { found = 1 } } From fc2e230dcc2cabfbe792c786b862ca898258a716 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 09:05:19 -0500 Subject: [PATCH 35/39] feat(chat): show the queued message lifecycle --- .../Protocol/SerializationRoundTripTests.cs | 45 +++++++ .../Sessions/LlmSessionIntegrationTests.cs | 111 ++++++++++++++++++ .../Sessions/SessionSubscriberManagerTests.cs | 27 +++++ .../Protocol/SessionOutputDto.cs | 13 ++ .../Protocol/SessionOutputDtoMapper.cs | 58 +++++++++ .../Protocol/SessionSubscription.cs | 6 + .../Sessions/LlmSessionActor.cs | 38 ++++++ .../Sessions/SessionProtocol.Outputs.cs | 31 +++++ .../Cli/DaemonClientSessionTests.cs | 57 +++++++-- .../Tui/InlineChatPageTests.cs | 56 ++++++++- src/Netclaw.Cli/Daemon/DaemonClient.cs | 28 ++++- src/Netclaw.Cli/Tui/ChatPresentation.cs | 53 ++++++++- src/Netclaw.Cli/Tui/ChatViewModel.cs | 107 +++++++++++++---- src/Netclaw.Cli/Tui/InlineChatPage.cs | 83 ++++++++++--- .../Gateway/SessionRegistryTests.cs | 32 +++++ src/Netclaw.Daemon/Gateway/SessionHub.cs | 10 ++ src/Netclaw.Daemon/Gateway/SessionRegistry.cs | 30 ++++- .../Gateway/SignalRSessionActor.cs | 3 +- 18 files changed, 724 insertions(+), 64 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs index 4ccd37d91..3b6720adb 100644 --- a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs @@ -287,6 +287,51 @@ public void Session_output_DTO_round_trips_parallel_and_approval_fields() Assert.Equal(ApprovalOptionKeys.DenyKey, approvalResult.SelectedKey); } + [Fact] + public void Session_output_DTO_round_trips_user_message_lifecycle_fields() + { + var queued = new UserMessageQueuedOutput + { + SessionId = new SessionId("test/wire"), + TimestampMs = 3, + MessageId = "tui:message-1", + TurnId = new Netclaw.Actors.Protocol.TurnId("turn-3"), + QueueDepth = 2 + }; + var queuedResult = Assert.IsType( + SessionOutputDtoMapper.FromDto(SessionOutputDtoMapper.ToDto(queued))); + Assert.Equal(queued.MessageId, queuedResult.MessageId); + Assert.Equal(queued.TurnId, queuedResult.TurnId); + Assert.Equal(queued.QueueDepth, queuedResult.QueueDepth); + + var pulled = new UserMessagesPulledOutput + { + SessionId = new SessionId("test/wire"), + TimestampMs = 4, + BatchId = "batch-3", + TurnId = new Netclaw.Actors.Protocol.TurnId("turn-3"), + Messages = + [ + new PulledUserMessage("tui:message-1", "First correction"), + new PulledUserMessage("tui:message-2", "Second correction") + ] + }; + var pulledResult = Assert.IsType( + SessionOutputDtoMapper.FromDto(SessionOutputDtoMapper.ToDto(pulled))); + Assert.Equal(pulled.BatchId, pulledResult.BatchId); + Assert.Equal(pulled.TurnId, pulledResult.TurnId); + Assert.Equal(pulled.Messages, pulledResult.Messages); + + Assert.Throws(() => SessionOutputDtoMapper.FromDto( + new SessionOutputDto + { + Type = SessionOutputTypes.UserMessagesPulled, + SessionId = "test/wire", + MessageBatchId = "batch-4", + TurnId = "turn-4" + })); + } + [Fact] public void Old_TurnRecorded_proto_reads_with_an_empty_transcript() { diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs index 515b480a9..d112dcc13 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs @@ -814,6 +814,100 @@ await sessionManager.Ask(new SendUserMessage entry.Type == SessionTranscriptEntryTypes.User && entry.Text == "Third message"); } + [Fact] + public async Task Active_turn_messages_emit_ordered_queue_and_pull_receipts() + { + _fakeChatClient.ToolCallsOnFirstCall = + [ + new FunctionCallContent("call-load", "load_tool", new Dictionary + { + ["Name"] = "browser_chrome_devtools/navigate_page", + ["_rationale"] = "Load the browser tool for the test." + }) + ]; + _fakeToolExecutor.Results["load_tool"] = "browser_chrome_devtools/navigate_page"; + var responseGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _fakeChatClient.NextResponseGate = responseGate; + + var sessionId = new SessionId("signalr/message-lifecycle"); + var sessionManager = ActorRegistry.Get(); + var subscriber = CreateTestProbe("message-lifecycle-sub"); + await sessionManager.Ask(new JoinSession(subscriber) + { + SessionId = sessionId, + Filter = OutputFilter.Full | OutputFilter.MessageLifecycle + }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + + await sessionManager.Ask(new SendUserMessage + { + SessionId = sessionId, + Content = "Inspect the page" + }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + await _fakeChatClient.FirstCallEntered.Task.WaitAsync( + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + await sessionManager.Ask(new SendUserMessage + { + SessionId = sessionId, + Content = "Use the dev branch", + Source = CreateSignalRSource("tui:message-1") + }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + await sessionManager.Ask(new SendUserMessage + { + SessionId = sessionId, + Content = "Check the compact layout", + Source = CreateSignalRSource("tui:message-2") + }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + + var firstQueued = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + var secondQueued = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("tui:message-1", firstQueued.MessageId); + Assert.Equal(1, firstQueued.QueueDepth); + Assert.Equal("tui:message-2", secondQueued.MessageId); + Assert.Equal(2, secondQueued.QueueDepth); + Assert.Equal(firstQueued.TurnId, secondQueued.TurnId); + + responseGate.TrySetResult(); + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + var pulled = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(firstQueued.TurnId, pulled.TurnId); + Assert.Equal( + ["tui:message-1", "tui:message-2"], + pulled.Messages.Select(message => message.MessageId)); + Assert.Equal( + ["Use the dev branch", "Check the compact layout"], + pulled.Messages.Select(message => message.Content)); + + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(6), + cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(6), + cancellationToken: TestContext.Current.CancellationToken); + + var nextCallUsers = _fakeChatClient.ReceivedMessages[1] + .Where(message => message.Role == Microsoft.Extensions.AI.ChatRole.User) + .Select(message => message.Text) + .ToList(); + Assert.Equal( + ["Use the dev branch", "Check the compact layout"], + nextCallUsers.TakeLast(2)); + } + [Fact] public async Task Discovered_tools_are_retained_then_expire_after_lease_window() { @@ -1779,6 +1873,23 @@ await sessionManager.Ask(new SendUserMessage await subscriber.ExpectMsgAsync(TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); } + private MessageSource CreateSignalRSource(string messageId) => new() + { + ChannelType = ChannelType.SignalR, + SenderId = new SenderId("local-user"), + MessageId = messageId, + Audience = TrustAudience.Personal, + Boundary = TrustBoundary.TrustedInstance, + Principal = PrincipalClassification.Operator, + Provenance = new SourceProvenance( + TransportAuthenticity.LocalProcess, + PayloadTaint.Trusted) + { + SourceKind = new Netclaw.Actors.Channels.SourceKind("tui") + }, + ReceivedAt = _timeProvider.GetUtcNow() + }; + private MessageSource ReminderSource(string reminderId) => new() { ChannelType = ChannelType.Slack, diff --git a/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs b/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs index f1e5cffbc..0741303f9 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs @@ -121,4 +121,31 @@ public async Task Tool_and_subagent_activity_require_the_tool_calls_filter() await full.ExpectMsgAsync(cancellationToken: TestContext.Current.CancellationToken); await restricted.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken); } + + [Fact] + public async Task Message_lifecycle_requires_an_explicit_filter() + { + var manager = new SessionSubscriberManager(); + var full = CreateTestProbe("full"); + var lifecycle = CreateTestProbe("message-lifecycle"); + var sessionId = new SessionId("channel/thread"); + + manager.AddOrUpdate(full.Ref, OutputFilter.Full); + manager.AddOrUpdate(lifecycle.Ref, OutputFilter.Full | OutputFilter.MessageLifecycle); + + manager.Emit(new UserMessageQueuedOutput + { + SessionId = sessionId, + MessageId = "tui:message-1", + TurnId = new TurnId("turn-1"), + QueueDepth = 1 + }, OutputFilter.MessageLifecycle); + + var queued = await lifecycle.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("tui:message-1", queued.MessageId); + await full.ExpectNoMsgAsync( + TimeSpan.FromMilliseconds(100), + TestContext.Current.CancellationToken); + } } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index df4ade9d6..cc0196dde 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -30,6 +30,8 @@ public static class SessionOutputTypes public const string SubAgent = "subagent"; public const string BufferFlush = "buffer_flush"; public const string ProcessingState = "processing_state"; + public const string UserMessageQueued = "user_message_queued"; + public const string UserMessagesPulled = "user_messages_pulled"; public const string Compaction = "compaction"; public const string SessionJoined = "session_joined"; public const string ToolInteraction = "tool_interaction"; @@ -43,6 +45,11 @@ public static class SessionOutputTypes /// public sealed record ChatMessageDto(string Role, string Content); +/// +/// One user message in an agent-pull receipt. +/// +public sealed record PulledUserMessageDto(string MessageId, string Content); + /// /// Wire-safe DTO for session output. Flattens the discriminated union /// () into a single serializable type for @@ -103,6 +110,12 @@ public sealed record SessionOutputDto public bool? IsProcessing { get; init; } public bool? ProcessingStateRequired { get; init; } + // User message lifecycle + public string? MessageId { get; init; } + public int? QueueDepth { get; init; } + public string? MessageBatchId { get; init; } + public List? PulledUserMessages { get; init; } + // Compaction public int? MessagesBefore { get; init; } public int? MessagesAfter { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index ca87d013f..c252410e4 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -183,6 +183,28 @@ public static class SessionOutputDtoMapper ProcessingStateRequired = msg.IsRequired }, + UserMessageQueuedOutput msg => new SessionOutputDto + { + Type = SessionOutputTypes.UserMessageQueued, + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + MessageId = msg.MessageId, + TurnId = msg.TurnId.Value, + QueueDepth = msg.QueueDepth + }, + + UserMessagesPulledOutput msg => new SessionOutputDto + { + Type = SessionOutputTypes.UserMessagesPulled, + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + MessageBatchId = msg.BatchId, + TurnId = msg.TurnId.Value, + PulledUserMessages = msg.Messages + .Select(message => new PulledUserMessageDto(message.MessageId, message.Content)) + .ToList() + }, + CompactionOutput msg => new SessionOutputDto { Type = SessionOutputTypes.Compaction, @@ -364,6 +386,25 @@ public static SessionOutput FromDto(SessionOutputDto dto) TimestampMs = dto.TimestampMs, IsRequired = dto.ProcessingStateRequired ?? false }, + SessionOutputTypes.UserMessageQueued => new UserMessageQueuedOutput + { + SessionId = sessionId, + TimestampMs = dto.TimestampMs, + MessageId = RequireLifecycleValue(dto.MessageId, nameof(dto.MessageId)), + TurnId = new TurnId(RequireLifecycleValue(dto.TurnId, nameof(dto.TurnId))), + QueueDepth = dto.QueueDepth + ?? throw new InvalidOperationException("A queue lifecycle event requires a queue depth.") + }, + SessionOutputTypes.UserMessagesPulled => new UserMessagesPulledOutput + { + SessionId = sessionId, + TimestampMs = dto.TimestampMs, + BatchId = RequireLifecycleValue(dto.MessageBatchId, nameof(dto.MessageBatchId)), + TurnId = new TurnId(RequireLifecycleValue(dto.TurnId, nameof(dto.TurnId))), + Messages = RequirePulledMessages(dto.PulledUserMessages) + .Select(message => new PulledUserMessage(message.MessageId, message.Content)) + .ToList() + }, SessionOutputTypes.Compaction => new CompactionOutput { SessionId = sessionId, @@ -438,6 +479,23 @@ private static SubAgentRunOutcome ParseSubAgentOutcome(string? value, bool? succ return success == false ? SubAgentRunOutcome.Failed : SubAgentRunOutcome.Completed; } + private static string RequireLifecycleValue(string? value, string fieldName) + { + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidOperationException($"A message lifecycle event requires '{fieldName}'."); + return value; + } + + private static IReadOnlyList RequirePulledMessages( + IReadOnlyList? messages) + { + if (messages is null || messages.Count == 0) + throw new InvalidOperationException("An agent pull event requires at least one message."); + if (messages.Any(message => string.IsNullOrWhiteSpace(message.MessageId))) + throw new InvalidOperationException("An agent pull event requires each message identity."); + return messages; + } + private static SubAgentOutput MapSubAgentOutput(SessionOutputDto dto, SessionId sessionId) { var phase = dto.Phase?.ToLowerInvariant() switch diff --git a/src/Netclaw.Actors/Protocol/SessionSubscription.cs b/src/Netclaw.Actors/Protocol/SessionSubscription.cs index 71651a362..ef925ea21 100644 --- a/src/Netclaw.Actors/Protocol/SessionSubscription.cs +++ b/src/Netclaw.Actors/Protocol/SessionSubscription.cs @@ -46,6 +46,12 @@ public enum OutputFilter /// — semantic busy/idle state for channel-native indicators. ProcessingState = 1 << 6, + /// + /// and — + /// transient user-message admission and agent-pull receipts. + /// + MessageLifecycle = 1 << 7, + // ── Convenience presets ── /// Final text replies only — suitable for adapters that post once (Slack). diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index cd5b30ec6..918137bec 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -508,6 +508,7 @@ private void Processing() _deliveryRetry.Clear(); _log.Info("Buffering user message (LLM call in progress)"); _buffer.Add(cmd); + EmitUserMessageQueued(cmd); TryReplyAck(); }); @@ -1164,6 +1165,7 @@ private void Compacting() _log.Info("Buffering user message (compaction in progress)"); _buffer.Add(cmd); + EmitUserMessageQueued(cmd); TryReplyAck(); }); @@ -2298,6 +2300,13 @@ private void DrainBufferedMessagesOrBecomeReady() private void AppendBufferedUserMessages() { + var pulledMessages = _buffer + .Where(buffered => !string.IsNullOrWhiteSpace(buffered.Source?.MessageId)) + .Select(buffered => new PulledUserMessage( + buffered.Source!.MessageId!, + buffered.Content)) + .ToArray(); + foreach (var buffered in _buffer) { var refs = buffered.MediaReferences.Count > 0 ? buffered.MediaReferences : null; @@ -2306,6 +2315,35 @@ private void AppendBufferedUserMessages() } _buffer.Clear(); + + if (pulledMessages.Length == 0) + return; + + var turnId = _activeTurnId + ?? throw new InvalidOperationException("A buffered message batch requires an active turn identity."); + EmitOutput(new UserMessagesPulledOutput + { + SessionId = _sessionId, + BatchId = IdGen.ShortId(), + TurnId = turnId, + Messages = pulledMessages + }, OutputFilter.MessageLifecycle); + } + + private void EmitUserMessageQueued(SendUserMessage cmd) + { + if (string.IsNullOrWhiteSpace(cmd.Source?.MessageId)) + return; + + var turnId = _activeTurnId + ?? throw new InvalidOperationException("A queued message requires an active turn identity."); + EmitOutput(new UserMessageQueuedOutput + { + SessionId = _sessionId, + MessageId = cmd.Source.MessageId, + TurnId = turnId, + QueueDepth = _buffer.Count + }, OutputFilter.MessageLifecycle); } private void HandleDeliveryFailedWhenReady(DeliveryFailed msg) diff --git a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs index 49504c265..f2e3ce9f3 100644 --- a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs +++ b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs @@ -346,6 +346,37 @@ public sealed record ProcessingStateOutput(bool IsProcessing) : SessionOutput public bool IsRequired { get; init; } } + /// + /// Signals that the session actor accepted a user message into its active-turn buffer. + /// Requires . + /// + public sealed record UserMessageQueuedOutput : SessionOutput + { + public required string MessageId { get; init; } + + public required Protocol.TurnId TurnId { get; init; } + + public required int QueueDepth { get; init; } + } + + /// + /// One user message that the agent pulled from the active-turn buffer. + /// + public sealed record PulledUserMessage(string MessageId, string Content); + + /// + /// Signals that the session actor pulled one ordered user-message batch into model context. + /// Requires . + /// + public sealed record UserMessagesPulledOutput : SessionOutput + { + public required string BatchId { get; init; } + + public required Protocol.TurnId TurnId { get; init; } + + public required IReadOnlyList Messages { get; init; } + } + /// /// Session context was compacted to stay within the context window. /// Lifecycle — always delivered regardless of . diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index 5248a7f9a..ddcad967f 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -214,7 +214,7 @@ public async Task ChatViewModel_keeps_queue_head_until_approval_outcome_arrives( } [Fact] - public async Task ChatViewModel_sends_all_active_turn_prompts_to_the_session_buffer() + public async Task ChatViewModel_keeps_prompts_until_the_agent_pulls_each_identity() { var transport = new FakeDaemonHubTransport(); await using var client = new DaemonClient( @@ -227,15 +227,19 @@ public async Task ChatViewModel_sends_all_active_turn_prompts_to_the_session_buf viewModel.StatusMessage.Value = "Generating..."; await Task.WhenAll( - viewModel.SubmitAsync("prompt A"), - viewModel.SubmitAsync("prompt B"), - viewModel.SubmitAsync("prompt C")); + viewModel.SubmitAsync("prompt A", "tui:a"), + viewModel.SubmitAsync("prompt B", "tui:b"), + viewModel.SubmitAsync("prompt C", "tui:c")); var sends = transport.Invocations - .Where(invocation => string.Equals(invocation.Method, "SendMessage", StringComparison.Ordinal)) - .Select(invocation => Assert.IsType(invocation.Args[1])) + .Where(invocation => string.Equals(invocation.Method, "SendMessageWithId", StringComparison.Ordinal)) + .Select(invocation => ( + Id: Assert.IsType(invocation.Args[1]), + Text: Assert.IsType(invocation.Args[2]))) .ToList(); - Assert.Equal(["prompt A", "prompt B", "prompt C"], sends); + Assert.Equal( + [("tui:a", "prompt A"), ("tui:b", "prompt B"), ("tui:c", "prompt C")], + sends); Assert.Equal(3, viewModel.QueuedTurnMessageCount.Value); Assert.Equal("Generating...", viewModel.StatusMessage.Value); @@ -247,10 +251,45 @@ await Task.WhenAll( Outcome = TurnOutcome.Completed })); - Assert.Equal(0, viewModel.QueuedTurnMessageCount.Value); + Assert.Equal(3, viewModel.QueuedTurnMessageCount.Value); Assert.True(viewModel.IsGenerating.Value); Assert.Equal(3, transport.Invocations.Count(invocation => - string.Equals(invocation.Method, "SendMessage", StringComparison.Ordinal))); + string.Equals(invocation.Method, "SendMessageWithId", StringComparison.Ordinal))); + + transport.PushOutput(SessionOutputDtoMapper.ToDto(new UserMessagesPulledOutput + { + SessionId = new SessionId("fake/session"), + TimestampMs = 2, + BatchId = "other-client-batch", + TurnId = new Netclaw.Actors.Protocol.TurnId("turn-2"), + Messages = [new PulledUserMessage("other:message", "Other client prompt")] + })); + Assert.Equal(3, viewModel.QueuedTurnMessageCount.Value); + + transport.PushOutput(SessionOutputDtoMapper.ToDto(new UserMessagesPulledOutput + { + SessionId = new SessionId("fake/session"), + TimestampMs = 3, + BatchId = "batch-1", + TurnId = new Netclaw.Actors.Protocol.TurnId("turn-2"), + Messages = + [ + new PulledUserMessage("tui:a", "prompt A"), + new PulledUserMessage("tui:b", "prompt B") + ] + })); + Assert.Equal(1, viewModel.QueuedTurnMessageCount.Value); + + transport.PushOutput(SessionOutputDtoMapper.ToDto(new UserMessagesPulledOutput + { + SessionId = new SessionId("fake/session"), + TimestampMs = 4, + BatchId = "batch-2", + TurnId = new Netclaw.Actors.Protocol.TurnId("turn-2"), + Messages = [new PulledUserMessage("tui:c", "prompt C")] + })); + Assert.Equal(0, viewModel.QueuedTurnMessageCount.Value); + Assert.True(viewModel.IsGenerating.Value); } [Fact] diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index 1a59c2469..0876f759f 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -380,9 +380,49 @@ await harness.WaitUntilAsync(() => await harness.WaitUntilAsync(() => harness.Terminal.Contains("QUEUED 3 messages") - && harness.Terminal.Contains("1 queue this first") - && harness.Terminal.Contains("2 queue this second") - && harness.Terminal.Contains("3 queue this third")); + && harness.Terminal.Contains("1 sending queue this first") + && harness.Terminal.Contains("2 sending queue this second") + && harness.Terminal.Contains("3 sending queue this third")); + + for (var index = 0; index < prompts.Length; index++) + { + harness.ViewModel.Emit(new UserMessageQueuedOutput + { + SessionId = SessionId, + TimestampMs = index + 1, + MessageId = harness.ViewModel.MessageIdFor(prompts[index]), + TurnId = new Netclaw.Actors.Protocol.TurnId("turn-1"), + QueueDepth = index + 1 + }); + } + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("1 queued queue this first") + && harness.Terminal.Contains("2 queued queue this second") + && harness.Terminal.Contains("3 queued queue this third")); + + harness.ViewModel.Emit(new TextDeltaOutput("I will inspect the current state.") + { + SessionId = SessionId, + TimestampMs = 5 + }); + harness.ViewModel.Emit(new UserMessagesPulledOutput + { + SessionId = SessionId, + TimestampMs = 6, + BatchId = "batch-1", + TurnId = new Netclaw.Actors.Protocol.TurnId("turn-1"), + Messages = + [ + new PulledUserMessage(harness.ViewModel.MessageIdFor(prompts[0]), prompts[0]), + new PulledUserMessage(harness.ViewModel.MessageIdFor(prompts[1]), prompts[1]) + ] + }); + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("QUEUED 1 message") + && harness.Terminal.Contains("1 queued queue this third") + && harness.Terminal.Contains("Pulled by agent · 2 messages") + && harness.Terminal.Contains("NETCLAW LIVE")); + var screen = harness.Terminal.ToString(); Assert.True( screen.IndexOf("queue this first", StringComparison.Ordinal) @@ -1128,6 +1168,7 @@ private sealed class TestChatViewModel : ChatViewModel private readonly IReadOnlyList _outputs; private readonly ToolInteractionRequest? _approval; private readonly Channel _submissions = Channel.CreateUnbounded(); + private readonly Dictionary _messageIds = new(StringComparer.Ordinal); private readonly Channel _approvalSelections = Channel.CreateUnbounded(); public TestChatViewModel( @@ -1173,6 +1214,15 @@ public override Task SubmitAsync(string text) return Task.CompletedTask; } + public override Task SubmitAsync(string text, string messageId) + { + _messageIds[text] = messageId; + _submissions.Writer.TryWrite(text); + return Task.CompletedTask; + } + + public string MessageIdFor(string text) => _messageIds[text]; + protected override Task SubmitInteractionSelectionAsync(string selectedKey) { _approvalSelections.Writer.TryWrite(selectedKey); diff --git a/src/Netclaw.Cli/Daemon/DaemonClient.cs b/src/Netclaw.Cli/Daemon/DaemonClient.cs index 25ecdd0b4..77110de67 100644 --- a/src/Netclaw.Cli/Daemon/DaemonClient.cs +++ b/src/Netclaw.Cli/Daemon/DaemonClient.cs @@ -183,7 +183,22 @@ public async Task SendAsync(string text, CancellationToken cancellationToken = d throw new InvalidOperationException("Only non-empty text messages are currently supported."); var ack = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - await PostAsync(new SendCommand(text, ack, cancellationToken), cancellationToken); + await PostAsync(new SendCommand(text, null, ack, cancellationToken), cancellationToken); + await ack.Task.WaitAsync(cancellationToken); + } + + public async Task SendWithIdAsync( + string text, + string messageId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(text)) + throw new InvalidOperationException("Only non-empty text messages are currently supported."); + if (string.IsNullOrWhiteSpace(messageId)) + throw new InvalidOperationException("Only non-empty message IDs are supported."); + + var ack = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await PostAsync(new SendCommand(text, messageId, ack, cancellationToken), cancellationToken); await ack.Task.WaitAsync(cancellationToken); } @@ -308,7 +323,10 @@ private async Task ProcessAsync(ClientCommand command) using var op = LinkOperation(c.Token); await EnsureConnectedAsync(op.Token); await ReattachIfNeededAsync(op.Token); - await InvokeAsync("SendMessage", [RequireSession(), c.Text], op.Token); + if (c.MessageId is null) + await InvokeAsync("SendMessage", [RequireSession(), c.Text], op.Token); + else + await InvokeAsync("SendMessageWithId", [RequireSession(), c.MessageId, c.Text], op.Token); c.Ack.TrySetResult(); break; } @@ -647,7 +665,11 @@ private sealed record EnsureSessionCommand( TaskCompletionSource Reply, CancellationToken Token) : ClientCommand; - private sealed record SendCommand(string Text, TaskCompletionSource Ack, CancellationToken Token) : ClientCommand; + private sealed record SendCommand( + string Text, + string? MessageId, + TaskCompletionSource Ack, + CancellationToken Token) : ClientCommand; private sealed record RespondCommand( string CallId, diff --git a/src/Netclaw.Cli/Tui/ChatPresentation.cs b/src/Netclaw.Cli/Tui/ChatPresentation.cs index e7e39a487..37542b207 100644 --- a/src/Netclaw.Cli/Tui/ChatPresentation.cs +++ b/src/Netclaw.Cli/Tui/ChatPresentation.cs @@ -60,6 +60,13 @@ internal sealed record ReplyPassagePresentation( bool IsFinal, ImmutableList ToolCallIds); +internal sealed record AgentPullPresentation( + string BatchId, + string TurnId, + long TimestampMs, + int AfterPassageIndex, + ImmutableList Messages); + internal sealed record SubAgentActivityPresentation( string RunId, string? ParentCallId, @@ -95,6 +102,8 @@ internal sealed record ChatPresentationState public ImmutableList ReplyPassages { get; init; } = []; + public ImmutableList AgentPulls { get; init; } = []; + public string ThoughtText { get; init; } = string.Empty; public int TurnNumber { get; init; } = 1; @@ -171,6 +180,8 @@ public static ChatReduction Reduce(ChatPresentationState state, SessionOutput ou CompactionOutput compaction => Commit(state, CompactionBlock(compaction, state.CurrentTurnId), effects), ToolInteractionRequest approval => ShowApproval(state, approval, effects), ApprovalOutcomeOutput approval => ResolveApproval(state, approval, effects), + UserMessageQueuedOutput => state, + UserMessagesPulledOutput pulled => RecordAgentPull(state, pulled), TurnCompleted completed => CompleteTurn(state, completed, effects), ProcessingStateOutput processing => state with { IsProcessing = processing.IsProcessing }, SessionTitleOutput title => CommitTitle(state, title, effects), @@ -184,7 +195,7 @@ public static ChatReduction Reduce(ChatPresentationState state, SessionOutput ou if (output is TextDeltaOutput or TextOutput or ThinkingDeltaOutput or ThinkingOutput or ToolCallOutput or ToolActivityOutput or ToolResultOutput - or ProcessingStateOutput) + or ProcessingStateOutput or UserMessageQueuedOutput or UserMessagesPulledOutput) { effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); } @@ -445,6 +456,31 @@ private static ChatPresentationState FinalizeThought( return state with { ThoughtText = text }; } + private static ChatPresentationState RecordAgentPull( + ChatPresentationState state, + UserMessagesPulledOutput output) + { + if (state.AgentPulls.Any(pull => string.Equals( + pull.BatchId, + output.BatchId, + StringComparison.Ordinal))) + { + return state; + } + + var pull = new AgentPullPresentation( + output.BatchId, + output.TurnId.Value, + output.TimestampMs, + state.ReplyPassages.Count - 1, + output.Messages.ToImmutableList()); + return state with + { + CurrentTurnId = output.TurnId.Value, + AgentPulls = state.AgentPulls.Add(pull) + }; + } + private static ChatPresentationState StartTool( ChatPresentationState state, ToolCallOutput output) @@ -723,6 +759,7 @@ private static ChatPresentationState CompleteTurn( ThoughtText = string.Empty, PendingApprovals = ImmutableQueue.Empty, CompletedApprovals = [], + AgentPulls = [], IsProcessing = false, TurnNumber = Math.Max(state.TurnNumber + 1, completed.TurnNumber.Value + 1), CurrentTurnId = null @@ -758,7 +795,9 @@ private static ChatPresentationBlock BuildSettledReply( 1 => "1 decision", _ => $"{state.CompletedApprovals.Count} decisions" }; - var receiptParts = new[] { toolReceipt, agentReceipt, approvalReceipt } + var pulledMessageCount = state.AgentPulls.Sum(pull => pull.Messages.Count); + var pullReceipt = CountLabel(pulledMessageCount, "follow-up", "follow-ups"); + var receiptParts = new[] { toolReceipt, agentReceipt, approvalReceipt, pullReceipt } .Where(value => value.Length > 0) .ToArray(); var receipt = receiptParts.Length == 0 @@ -790,6 +829,12 @@ private static ChatPresentationBlock BuildSettledReply( detailParts.Add($"Decisions:\n{string.Join("\n\n", state.CompletedApprovals.Select( approval => approval.Detail ?? approval.Summary))}"); } + if (state.AgentPulls.Count > 0) + { + var pullDetail = state.AgentPulls.Select(pull => + $"Pulled by agent:\n{string.Join("\n", pull.Messages.Select(message => message.Content))}"); + detailParts.Add($"User steering:\n{string.Join("\n\n", pullDetail)}"); + } var detail = string.Join("\n\n", new[] { prose } .Concat(detailParts) @@ -801,7 +846,9 @@ private static ChatPresentationBlock BuildSettledReply( ? state.Tools.Values.Min(tool => tool.StartedAtMs) : state.SubAgents.Count > 0 ? state.SubAgents.Values.Min(run => run.StartedAtMs) - : state.CompletedApprovals.Min(approval => approval.TimestampMs); + : state.CompletedApprovals.Count > 0 + ? state.CompletedApprovals.Min(approval => approval.TimestampMs) + : state.AgentPulls.Min(pull => pull.TimestampMs); return new ChatPresentationBlock( $"turn:{state.TurnNumber}:reply", ChatBlockKind.Assistant, diff --git a/src/Netclaw.Cli/Tui/ChatViewModel.cs b/src/Netclaw.Cli/Tui/ChatViewModel.cs index dfe5e2e50..399aa8a02 100644 --- a/src/Netclaw.Cli/Tui/ChatViewModel.cs +++ b/src/Netclaw.Cli/Tui/ChatViewModel.cs @@ -37,11 +37,13 @@ public partial class ChatViewModel : ReactiveViewModel private string? _initialMessage; private readonly Subject _outputSubject = new(); - private readonly Queue _pendingMessages = new(); + private readonly Queue _pendingMessages = new(); private readonly object _pendingMessagesGate = new(); private readonly SemaphoreSlim _sessionAttachGate = new(1, 1); private readonly Queue _pendingInteractions = new(); - private int _queuedTurnMessageCount; + private readonly object _queuedTurnMessagesGate = new(); + private readonly HashSet _queuedTurnMessageIds = new(StringComparer.Ordinal); + private int _legacyQueuedTurnMessageCount; /// /// True while an interaction response is in flight to the daemon. Guards @@ -161,18 +163,23 @@ protected virtual Task InitializeSessionAsync() _submittedInteractionCallId = null; _isSubmittingInteraction = false; RefreshApprovalOptions(); - var promotedCount = PromoteQueuedTurnMessages(); - IsGenerating.Value = promotedCount > 0; - StatusMessage.Value = promotedCount > 0 + var queuedCount = CompleteLegacyQueuedTurnMessages(); + IsGenerating.Value = queuedCount > 0; + StatusMessage.Value = queuedCount > 0 ? "Generating..." : "Ready"; break; + case UserMessagesPulledOutput pulled: + RecordPulledTurnMessages(pulled.Messages); + IsGenerating.Value = true; + StatusMessage.Value = "Generating..."; + break; case ErrorOutput: _pendingInteractions.Clear(); _submittedInteractionCallId = null; _isSubmittingInteraction = false; RefreshApprovalOptions(); - IsGenerating.Value = Volatile.Read(ref _queuedTurnMessageCount) > 0; + IsGenerating.Value = GetQueuedTurnMessageCount() > 0; break; } @@ -213,7 +220,17 @@ or DaemonConnectionState.Reconnecting /// /// Submit user text to the session pipeline. /// - public virtual async Task SubmitAsync(string text) + public virtual Task SubmitAsync(string text) => SubmitCoreAsync(text, null); + + public virtual Task SubmitAsync(string text, string messageId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(messageId); + return SubmitCoreAsync(text, messageId); + } + + internal static string CreateUserMessageId() => $"tui:{Guid.NewGuid():N}"; + + private async Task SubmitCoreAsync(string text, string? messageId) { if (string.IsNullOrWhiteSpace(text)) return; @@ -226,9 +243,10 @@ public virtual async Task SubmitAsync(string text) var isActiveTurnPrompt = IsGenerating.Value; if (isActiveTurnPrompt) - AddQueuedTurnMessage(); + AddQueuedTurnMessage(messageId); - if (TryEnqueuePendingMessageWhenUnavailable(text, out var pendingCount)) + var pendingMessage = new PendingUserMessage(text, messageId); + if (TryEnqueuePendingMessageWhenUnavailable(pendingMessage, out var pendingCount)) { IsGenerating.Value = isActiveTurnPrompt; IsInputEnabled.Value = true; @@ -246,12 +264,12 @@ public virtual async Task SubmitAsync(string text) try { - await _daemonClient.SendAsync(text); + await SendUserMessageAsync(pendingMessage); } catch (Exception ex) { IsInputEnabled.Value = true; - SetNotReadyAndEnqueuePendingMessage(text); + SetNotReadyAndEnqueuePendingMessage(pendingMessage); StatusMessage.Value = $"Send failed ({ex.Message}). Reconnecting..."; RequestRedraw(); _ = ConnectUntilReadyAsync(); @@ -263,18 +281,53 @@ public virtual void RequestAppShutdown() Shutdown(); } - private void AddQueuedTurnMessage() + private void AddQueuedTurnMessage(string? messageId) { - var count = Interlocked.Increment(ref _queuedTurnMessageCount); + int count; + lock (_queuedTurnMessagesGate) + { + if (messageId is null) + _legacyQueuedTurnMessageCount++; + else if (!_queuedTurnMessageIds.Add(messageId)) + throw new InvalidOperationException($"The queued message ID '{messageId}' is not unique."); + + count = _legacyQueuedTurnMessageCount + _queuedTurnMessageIds.Count; + } + QueuedTurnMessageCount.Value = count; RequestRedraw(); } - private int PromoteQueuedTurnMessages() + private void RecordPulledTurnMessages(IReadOnlyList pulledMessages) + { + int remaining; + lock (_queuedTurnMessagesGate) + { + foreach (var pulled in pulledMessages) + _queuedTurnMessageIds.Remove(pulled.MessageId); + remaining = _legacyQueuedTurnMessageCount + _queuedTurnMessageIds.Count; + } + + QueuedTurnMessageCount.Value = remaining; + } + + private int CompleteLegacyQueuedTurnMessages() + { + int remaining; + lock (_queuedTurnMessagesGate) + { + _legacyQueuedTurnMessageCount = 0; + remaining = _queuedTurnMessageIds.Count; + } + + QueuedTurnMessageCount.Value = remaining; + return remaining; + } + + private int GetQueuedTurnMessageCount() { - var count = Interlocked.Exchange(ref _queuedTurnMessageCount, 0); - QueuedTurnMessageCount.Value = 0; - return count; + lock (_queuedTurnMessagesGate) + return _legacyQueuedTurnMessageCount + _queuedTurnMessageIds.Count; } private async Task SubmitInteractionResponseAsync(string text) @@ -590,7 +643,7 @@ private async Task EnsureSessionAndFlushAsync() while (true) { - string? pending; + PendingUserMessage? pending; lock (_pendingMessagesGate) { pending = _pendingMessages.Count == 0 @@ -600,11 +653,11 @@ private async Task EnsureSessionAndFlushAsync() if (pending is not null) { - await _daemonClient.SendAsync(pending); + await SendUserMessageAsync(pending); lock (_pendingMessagesGate) { if (_pendingMessages.Count == 0 - || !string.Equals(_pendingMessages.Peek(), pending, StringComparison.Ordinal)) + || _pendingMessages.Peek() != pending) { throw new InvalidOperationException("The pending message queue changed during its ordered flush."); } @@ -666,7 +719,7 @@ private void SetSessionReady(bool value) _sessionReady = value; } - private bool TryEnqueuePendingMessageWhenUnavailable(string text, out int pendingCount) + private bool TryEnqueuePendingMessageWhenUnavailable(PendingUserMessage message, out int pendingCount) { lock (_pendingMessagesGate) { @@ -676,21 +729,27 @@ private bool TryEnqueuePendingMessageWhenUnavailable(string text, out int pendin return false; } - _pendingMessages.Enqueue(text); + _pendingMessages.Enqueue(message); pendingCount = _pendingMessages.Count; return true; } } - private void SetNotReadyAndEnqueuePendingMessage(string text) + private void SetNotReadyAndEnqueuePendingMessage(PendingUserMessage message) { lock (_pendingMessagesGate) { _sessionReady = false; - _pendingMessages.Enqueue(text); + _pendingMessages.Enqueue(message); } } + private Task SendUserMessageAsync(PendingUserMessage message) => message.MessageId is null + ? _daemonClient.SendAsync(message.Text) + : _daemonClient.SendWithIdAsync(message.Text, message.MessageId); + + private sealed record PendingUserMessage(string Text, string? MessageId); + protected virtual async Task SubmitInteractionSelectionAsync(string selectedKey) { if (CurrentInteraction is null) diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index ac63a09ef..a8797a3d7 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -51,7 +51,7 @@ public sealed class InlineChatPage : ReactivePage private ChatPresentationState _state = ChatPresentationState.Empty; private Task _commitTail = Task.CompletedTask; private readonly List _deferredInspectorCommits = []; - private readonly Queue _queuedPromptDisplays = new(); + private readonly List _queuedPromptDisplays = []; private long? _lastEscapeTimestamp; private int _inspectorIndex; private bool _inspectorOpen; @@ -121,9 +121,6 @@ protected override void OnBound() _liveRegion.Invalidate(); }) .DisposeWith(Subscriptions); - ViewModel.QueuedTurnMessageCount - .Subscribe(count => Post(() => PromoteQueuedPrompt(count))) - .DisposeWith(Subscriptions); ViewModel.Input.OfType() .Subscribe(_ => _liveRegion.Invalidate()) .DisposeWith(Subscriptions); @@ -199,11 +196,12 @@ private void SubmitPrompt(string text) { _promptInput.Clear(); _lastEscapeTimestamp = null; + var messageId = ChatViewModel.CreateUserMessageId(); if (ViewModel.IsGenerating.Value) { - _queuedPromptDisplays.Enqueue(text); + _queuedPromptDisplays.Add(new QueuedPromptDisplay(messageId, text, false)); _liveRegion.Invalidate(); - _ = ViewModel.SubmitAsync(text); + _ = ViewModel.SubmitAsync(text, messageId); return; } @@ -211,25 +209,35 @@ private void SubmitPrompt(string text) _state, text, _timeProvider.GetUtcNow().ToUnixTimeMilliseconds())); - _ = ViewModel.SubmitAsync(text); + _ = ViewModel.SubmitAsync(text, messageId); } - private void PromoteQueuedPrompt(int queuedCount) + private void ApplyMessageLifecycle(SessionOutput output) { - while (_queuedPromptDisplays.Count > queuedCount) + switch (output) { - var prompt = _queuedPromptDisplays.Dequeue(); - ApplyReduction(ChatPresentationReducer.RecordUserPrompt( - _state, - prompt, - _timeProvider.GetUtcNow().ToUnixTimeMilliseconds())); + case UserMessageQueuedOutput queued: + { + var index = _queuedPromptDisplays.FindIndex(prompt => + string.Equals(prompt.MessageId, queued.MessageId, StringComparison.Ordinal)); + if (index >= 0) + _queuedPromptDisplays[index] = _queuedPromptDisplays[index] with { IsAccepted = true }; + break; + } + case UserMessagesPulledOutput pulled: + { + var pulledIds = pulled.Messages + .Select(message => message.MessageId) + .ToHashSet(StringComparer.Ordinal); + _queuedPromptDisplays.RemoveAll(prompt => pulledIds.Contains(prompt.MessageId)); + break; + } } - - _liveRegion.Invalidate(); } private void ApplyOutput(SessionOutput output) { + ApplyMessageLifecycle(output); ApplyReduction(ChatPresentationReducer.Reduce(_state, output)); } @@ -320,6 +328,7 @@ private ILayoutNode BuildLiveRegion() var hasLiveReply = _state.ReplyPassages.Count > 0 || _state.Tools.Count > 0 || _state.SubAgents.Count > 0 + || _state.AgentPulls.Count > 0 || !string.IsNullOrWhiteSpace(_state.ThoughtText); content.WithChild(BuildLiveReplyBlock()); if (hasLiveReply) @@ -611,9 +620,10 @@ private ILayoutNode BuildQueueShelf() foreach (var prompt in _queuedPromptDisplays) { var preview = ChatPresentationRenderer.OneLine( - prompt, - Math.Max(20, ReadableWidth() - 8)); - content.WithChild(new TextNode($"{index,2} {preview}") + prompt.Text, + Math.Max(20, ReadableWidth() - 18)); + var state = prompt.IsAccepted ? "queued" : "sending"; + content.WithChild(new TextNode($"{index,2} {state,-7} {preview}") .WithForeground(ChatVisualTheme.Text)); index++; } @@ -632,6 +642,7 @@ private ILayoutNode BuildLiveReplyBlock() var hasReply = _state.ReplyPassages.Count > 0 || _state.Tools.Count > 0 || _state.SubAgents.Count > 0 + || _state.AgentPulls.Count > 0 || !string.IsNullOrWhiteSpace(_state.ThoughtText); if (!hasReply) return Layouts.Empty(); @@ -655,6 +666,12 @@ private ILayoutNode BuildLiveReplyBlock() .ToList(); var renderedAgents = new HashSet(StringComparer.Ordinal); var renderedTools = new HashSet(StringComparer.Ordinal); + var renderedPulls = new HashSet(StringComparer.Ordinal); + foreach (var pull in _state.AgentPulls.Where(pull => pull.AfterPassageIndex < 0)) + { + reply.WithChild(BuildAgentPull(pull, lineWidth)); + renderedPulls.Add(pull.BatchId); + } foreach (var passage in _state.ReplyPassages.OrderBy(value => value.Index)) { if (!string.IsNullOrWhiteSpace(passage.Text)) @@ -693,6 +710,13 @@ private ILayoutNode BuildLiveReplyBlock() renderedTools.Add(tool.CallId); } } + + foreach (var pull in _state.AgentPulls.Where(pull => + pull.AfterPassageIndex == passage.Index)) + { + reply.WithChild(BuildAgentPull(pull, lineWidth)); + renderedPulls.Add(pull.BatchId); + } } foreach (var tool in _state.Tools.Values @@ -703,6 +727,8 @@ private ILayoutNode BuildLiveReplyBlock() } foreach (var run in agents.Where(value => !renderedAgents.Contains(value.RunId))) reply.WithChild(BuildAgentActivity(run, lineWidth)); + foreach (var pull in _state.AgentPulls.Where(pull => !renderedPulls.Contains(pull.BatchId))) + reply.WithChild(BuildAgentPull(pull, lineWidth)); var maximumHeight = Math.Max(5, Math.Min(18, _terminal.Height / 2)); _assistantStream ??= new ScrollableContainerNode() @@ -721,6 +747,23 @@ private ILayoutNode BuildLiveReplyBlock() .HeightAuto(min: 3, max: maximumHeight); } + private static ILayoutNode BuildAgentPull(AgentPullPresentation pull, int lineWidth) + { + var countLabel = pull.Messages.Count == 1 ? "1 message" : $"{pull.Messages.Count} messages"; + var rows = Layouts.Vertical() + .WithChild(new TextNode($"Pulled by agent · {countLabel}") + .WithForeground(ChatVisualTheme.Muted)); + foreach (var message in pull.Messages) + { + rows.WithChild(new TextNode(ChatPresentationRenderer.OneLine( + $" {message.Content}", + lineWidth)) + .WithForeground(ChatVisualTheme.Text)); + } + + return rows; + } + private ILayoutNode BuildToolActivity( ToolActivityPresentation tool, IReadOnlyCollection agents, @@ -1219,6 +1262,8 @@ _ when status.StartsWith("Connection failed", StringComparison.Ordinal) => ChatV private bool ShowsComposer(ChatPresentationState state) => state.PendingApproval is null; + + private sealed record QueuedPromptDisplay(string MessageId, string Text, bool IsAccepted); } internal static partial class ChatPresentationRenderer diff --git a/src/Netclaw.Daemon.Tests/Gateway/SessionRegistryTests.cs b/src/Netclaw.Daemon.Tests/Gateway/SessionRegistryTests.cs index 6c7c111b3..571cedbf6 100644 --- a/src/Netclaw.Daemon.Tests/Gateway/SessionRegistryTests.cs +++ b/src/Netclaw.Daemon.Tests/Gateway/SessionRegistryTests.cs @@ -212,6 +212,38 @@ public async Task SendMessage_uses_untrusted_defaults_when_no_principal_provided Assert.Equal(TransportAuthenticity.Unknown, enqueue.Input.Provenance!.TransportAuthenticity); } + [Fact] + public async Task SendMessageWithId_preserves_the_client_message_identity() + { + var capturing = new CapturingRequiredActor(); + var registry = BuildRegistry(actorProvider: capturing); + var sessionId = await registry.CreateSessionAsync("conn-1", "tui"); + + await registry.SendMessageWithIdAsync( + "conn-1", + sessionId, + "tui:message-1", + "Use the release branch"); + + var enqueue = capturing.Messages.OfType().Single(); + Assert.Equal("tui:message-1", enqueue.Input.MessageId); + var content = Assert.Single(enqueue.Input.Contents.OfType()); + Assert.Equal("Use the release branch", content.Text); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("bad\nidentity")] + public async Task SendMessageWithId_rejects_an_invalid_message_identity(string messageId) + { + var registry = BuildRegistry(); + var sessionId = await registry.CreateSessionAsync("conn-1", "tui"); + + await Assert.ThrowsAsync(() => + registry.SendMessageWithIdAsync("conn-1", sessionId, messageId, "hello")); + } + /// /// Stub implementation of that returns /// for all requests. Used to isolate diff --git a/src/Netclaw.Daemon/Gateway/SessionHub.cs b/src/Netclaw.Daemon/Gateway/SessionHub.cs index 1754d9407..60d9a6d6f 100644 --- a/src/Netclaw.Daemon/Gateway/SessionHub.cs +++ b/src/Netclaw.Daemon/Gateway/SessionHub.cs @@ -75,6 +75,16 @@ public Task SendMessage(string sessionId, string text) return _registry.SendMessageAsync(Context.ConnectionId, sessionId, text, Context.User); } + public Task SendMessageWithId(string sessionId, string messageId, string text) + { + return _registry.SendMessageWithIdAsync( + Context.ConnectionId, + sessionId, + messageId, + text, + Context.User); + } + public Task RespondToInteraction(string sessionId, string callId, string selectedKey) { return _registry.RespondToInteractionAsync(Context.ConnectionId, sessionId, callId, selectedKey, Context.User); diff --git a/src/Netclaw.Daemon/Gateway/SessionRegistry.cs b/src/Netclaw.Daemon/Gateway/SessionRegistry.cs index 92c649ca6..98825d6fe 100644 --- a/src/Netclaw.Daemon/Gateway/SessionRegistry.cs +++ b/src/Netclaw.Daemon/Gateway/SessionRegistry.cs @@ -195,7 +195,32 @@ public async Task AttachSessionAsync(string connectionId, string sessionId, Clai /// /// Pushes a user message into an existing session's input queue. /// - public async Task SendMessageAsync(string connectionId, string sessionId, string text, ClaimsPrincipal? principal = null) + public Task SendMessageAsync(string connectionId, string sessionId, string text, ClaimsPrincipal? principal = null) + => SendMessageCoreAsync(connectionId, sessionId, text, null, principal); + + public Task SendMessageWithIdAsync( + string connectionId, + string sessionId, + string messageId, + string text, + ClaimsPrincipal? principal = null) + { + if (string.IsNullOrWhiteSpace(messageId)) + throw new HubException("The message ID must not be blank."); + if (messageId.Length > 128) + throw new HubException("The message ID must not exceed 128 characters."); + if (messageId.Any(char.IsControl)) + throw new HubException("The message ID must not contain control characters."); + + return SendMessageCoreAsync(connectionId, sessionId, text, messageId, principal); + } + + private async Task SendMessageCoreAsync( + string connectionId, + string sessionId, + string text, + string? clientMessageId, + ClaimsPrincipal? principal) { var callerConnectionId = ParseConnectionId(connectionId); var requestedSessionId = ParseSessionId(sessionId); @@ -213,7 +238,8 @@ public async Task SendMessageAsync(string connectionId, string sessionId, string var identity = _mapper.Map(principal); - var signalrMessageId = $"signalr:{callerConnectionId.Value}:{_timeProvider.GetUtcNow().ToUnixTimeMilliseconds()}:{Guid.NewGuid():N}"; + var signalrMessageId = clientMessageId + ?? $"signalr:{callerConnectionId.Value}:{_timeProvider.GetUtcNow().ToUnixTimeMilliseconds()}:{Guid.NewGuid():N}"; if (signalrMessageId.Length > 128) signalrMessageId = signalrMessageId[..128]; diff --git a/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs b/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs index df4ca7450..4eaa1e50b 100644 --- a/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs +++ b/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs @@ -67,7 +67,8 @@ public static Props CreateProps(string entityId, ISessionPipeline pipeline, private SessionPipelineOptions BuildOptions() => new() { - ChannelType = _channelType + ChannelType = _channelType, + Filter = OutputFilter.Full | OutputFilter.MessageLifecycle }; private void Initializing() From 437f2db0b4b2b627f10bc19f2e3775e7072a9d10 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 09:18:40 -0500 Subject: [PATCH 36/39] fix(chat): bound invalid rationale retries --- .../Protocol/SerializationRoundTripTests.cs | 13 +++++ .../Sessions/LlmSessionIntegrationTests.cs | 54 +++++++++++++++++++ .../Pipelines/MetaValidationAndNoticeTests.cs | 2 + .../Sessions/ToolExecutionIntegrationTests.cs | 15 ++++++ .../Sessions/TurnStateTrackerTests.cs | 28 ++++++++++ .../Protocol/SessionOutputDto.cs | 1 + .../Protocol/SessionOutputDtoMapper.cs | 12 +++-- .../Sessions/ActiveToolBatchTracker.cs | 23 +++++--- .../Sessions/Handlers/TurnStateTracker.cs | 34 ++++++++++++ src/Netclaw.Actors/Sessions/LlmMessages.cs | 1 + .../Sessions/LlmSessionActor.cs | 50 ++++++++++++++--- .../Pipelines/SessionToolExecutionPipeline.cs | 16 ++++-- .../Pipelines/ToolCallMetaExtractor.cs | 5 +- .../Sessions/SessionProtocol.Outputs.cs | 6 +++ .../Tui/ChatPresentationReducerTests.cs | 38 +++++++++++++ src/Netclaw.Cli/Tui/ChatPresentation.cs | 43 ++++++++++----- src/Netclaw.Cli/Tui/InlineChatPage.cs | 7 ++- 17 files changed, 309 insertions(+), 39 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs index 3b6720adb..995fd1456 100644 --- a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs @@ -263,6 +263,19 @@ public void Session_output_DTO_round_trips_parallel_and_approval_fields() Assert.Equal(2, toolResult.BatchSize); Assert.Equal("Find the relevant source", toolResult.Rationale); + var rejectedToolResult = new ToolResultOutput + { + SessionId = new SessionId("test/wire"), + TimestampMs = 2, + CallId = new ToolCallId("call-rejected"), + ToolName = new ToolName("search"), + Result = "The tool was not executed.", + FailureCode = "invalid_rationale" + }; + var rejectedResult = Assert.IsType( + SessionOutputDtoMapper.FromDto(SessionOutputDtoMapper.ToDto(rejectedToolResult))); + Assert.Equal("invalid_rationale", rejectedResult.FailureCode); + var oldToolResult = Assert.IsType(SessionOutputDtoMapper.FromDto(new SessionOutputDto { Type = SessionOutputTypes.ToolCall, diff --git a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs index d112dcc13..2f92de84d 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs @@ -908,6 +908,60 @@ await subscriber.ExpectMsgAsync( nextCallUsers.TakeLast(2)); } + [Fact] + public async Task Repeated_invalid_rationales_disable_tools_after_three_iterations() + { + _fakeToolExecutor.RequireRationale = true; + for (var index = 1; index <= 3; index++) + { + _fakeChatClient.PlannedResponses.Enqueue( + [ + new FunctionCallContent($"call-{index}", "load_tool", + new Dictionary + { + ["Name"] = "browser_chrome_devtools/navigate_page" + }) + ]); + } + + var sessionId = new SessionId("signalr/invalid-rationale-limit"); + var sessionManager = ActorRegistry.Get(); + var subscriber = CreateTestProbe("invalid-rationale-limit-sub"); + await sessionManager.Ask(new JoinSession(subscriber) + { + SessionId = sessionId, + Filter = OutputFilter.Full + }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + + await sessionManager.Ask(new SendUserMessage + { + SessionId = sessionId, + Content = "Load the browser tool" + }, TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); + + for (var index = 1; index <= 3; index++) + { + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + var result = await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("invalid_rationale", result.FailureCode); + } + + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + await subscriber.ExpectMsgAsync( + TimeSpan.FromSeconds(3), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(4, _fakeChatClient.CallCount); + Assert.Empty(_fakeChatClient.ReceivedToolNames[3]); + } + [Fact] public async Task Discovered_tools_are_retained_then_expire_after_lease_window() { diff --git a/src/Netclaw.Actors.Tests/Sessions/Pipelines/MetaValidationAndNoticeTests.cs b/src/Netclaw.Actors.Tests/Sessions/Pipelines/MetaValidationAndNoticeTests.cs index 56ad385a1..3ded3ac74 100644 --- a/src/Netclaw.Actors.Tests/Sessions/Pipelines/MetaValidationAndNoticeTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/Pipelines/MetaValidationAndNoticeTests.cs @@ -305,6 +305,8 @@ public async Task Missing_rationale_rejects_while_a_parallel_sibling_runs() result.ToolCallId == new ToolCallId("call-invalid") && result.Content.Contains("'_rationale'", StringComparison.Ordinal) && result.Content.Contains("NOT executed", StringComparison.Ordinal)); + Assert.Equal("invalid_rationale", completed.ToolFailureCodes["call-invalid"]); + Assert.False(completed.ToolFailureCodes.ContainsKey("call-valid")); } [Fact] diff --git a/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs index 7ec28b6ad..eeb66a3b7 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs @@ -11,7 +11,9 @@ using Netclaw.Actors.Hosting; using Netclaw.Actors.Protocol; using Netclaw.Actors.Sessions; +using Netclaw.Actors.Sessions.Pipelines; using Netclaw.Actors.Tools; +using Netclaw.Tools; using Xunit; using static Netclaw.Actors.Sessions.SessionProtocol; @@ -352,6 +354,19 @@ internal sealed class FakeToolExecutor : IToolExecutor /// Tool names that should throw on execution. public HashSet FailForTools { get; } = []; + public bool RequireRationale { get; set; } + + public ToolArgumentRejection? ValidateToolCall(FunctionCallContent toolCall) + { + if (!RequireRationale) + return null; + + var error = ToolCallMetaExtractor.ValidateRequiredRationale( + toolCall.Arguments, + ToolCallMeta.ResolveExactMetaField); + return error is null ? null : new ToolArgumentRejection(error, "invalid_rationale"); + } + public Task AuthorizeAsync(FunctionCallContent toolCall, Netclaw.Tools.ToolExecutionContext context, CancellationToken ct = default) { if (FailForTools.Contains(toolCall.Name)) diff --git a/src/Netclaw.Actors.Tests/Sessions/TurnStateTrackerTests.cs b/src/Netclaw.Actors.Tests/Sessions/TurnStateTrackerTests.cs index f1cb69682..5522dcf84 100644 --- a/src/Netclaw.Actors.Tests/Sessions/TurnStateTrackerTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/TurnStateTrackerTests.cs @@ -156,4 +156,32 @@ public void RawCallVolume_DoesNotControlTheLimit() Assert.Equal(1, tracker.ToolIterationCount); Assert.Equal(100, tracker.ToolCallCount); } + + [Fact] + public void Three_invalid_rationale_iterations_disable_more_tools() + { + var tracker = new TurnStateTracker(); + + Assert.IsType( + tracker.EvaluateInvalidRationaleResults(1, 1)); + Assert.IsType( + tracker.EvaluateInvalidRationaleResults(1, 1)); + var stop = Assert.IsType( + tracker.EvaluateInvalidRationaleResults(1, 1)); + + Assert.Contains("omitted", stop.NudgeText, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void A_mixed_tool_batch_resets_the_invalid_rationale_sequence() + { + var tracker = new TurnStateTracker(); + + tracker.EvaluateInvalidRationaleResults(1, 1); + tracker.EvaluateInvalidRationaleResults(1, 1); + Assert.IsType( + tracker.EvaluateInvalidRationaleResults(1, 2)); + Assert.IsType( + tracker.EvaluateInvalidRationaleResults(1, 1)); + } } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index cc0196dde..1ecfb136e 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -76,6 +76,7 @@ public sealed record SessionOutputDto public string? ToolName { get; init; } public string? ArgumentsJson { get; init; } public string? Result { get; init; } + public string? ToolFailureCode { get; init; } public string? TurnId { get; init; } public string? ActivityPhase { get; init; } public string? ActivitySummary { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index c252410e4..871f94ef6 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -62,7 +62,8 @@ public static class SessionOutputDtoMapper ArgumentsJson = msg.ArgumentsJson, ToolBatchId = msg.BatchId, ToolBatchSize = msg.BatchSize, - ToolRationale = msg.Rationale + ToolRationale = msg.Rationale, + ToolFailureCode = msg.FailureCode }, ToolActivityOutput msg => new SessionOutputDto @@ -84,7 +85,8 @@ public static class SessionOutputDtoMapper TimestampMs = msg.TimestampMs, CallId = msg.CallId.Value, ToolName = msg.ToolName.Value, - Result = msg.Result + Result = msg.Result, + ToolFailureCode = msg.FailureCode }, UsageOutput msg => new SessionOutputDto @@ -307,7 +309,8 @@ public static SessionOutput FromDto(SessionOutputDto dto) ArgumentsJson = dto.ArgumentsJson, BatchId = dto.ToolBatchId ?? string.Empty, BatchSize = dto.ToolBatchSize ?? 1, - Rationale = dto.ToolRationale + Rationale = dto.ToolRationale, + FailureCode = dto.ToolFailureCode }, SessionOutputTypes.ToolActivity => new ToolActivityOutput { @@ -325,7 +328,8 @@ public static SessionOutput FromDto(SessionOutputDto dto) TimestampMs = dto.TimestampMs, CallId = new Netclaw.Tools.ToolCallId(dto.CallId ?? string.Empty), ToolName = new Netclaw.Tools.ToolName(dto.ToolName ?? "unknown"), - Result = dto.Result ?? string.Empty + Result = dto.Result ?? string.Empty, + FailureCode = dto.ToolFailureCode }, SessionOutputTypes.Usage => new UsageOutput { diff --git a/src/Netclaw.Actors/Sessions/ActiveToolBatchTracker.cs b/src/Netclaw.Actors/Sessions/ActiveToolBatchTracker.cs index 6d4f0c64a..1354a0f27 100644 --- a/src/Netclaw.Actors/Sessions/ActiveToolBatchTracker.cs +++ b/src/Netclaw.Actors/Sessions/ActiveToolBatchTracker.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using Microsoft.Extensions.AI; using Netclaw.Actors.Protocol; +using Netclaw.Actors.Sessions.Pipelines; namespace Netclaw.Actors.Sessions; @@ -12,9 +13,12 @@ internal sealed class ActiveToolBatchTracker { private readonly HashSet _expectedCallIds = new(StringComparer.Ordinal); private readonly HashSet _completedCallIds = new(StringComparer.Ordinal); + private readonly HashSet _invalidRationaleCallIds = new(StringComparer.Ordinal); public int CompletedCount => _completedCallIds.Count; + public int InvalidRationaleCount => _invalidRationaleCallIds.Count; + public bool HasAllResults => _expectedCallIds.Count > 0 && _completedCallIds.Count >= _expectedCallIds.Count; @@ -32,11 +36,9 @@ public void Start( _expectedCallIds.Add(call.CallId.Value); ClearCompletedCallIds(); + _invalidRationaleCallIds.Clear(); foreach (var result in existingResults) - { - if (result.ToolCallId is { } id) - _completedCallIds.Add(id.Value); - } + RecordCompleted(result); ExecutionTaskCompleted = false; } @@ -48,11 +50,19 @@ public void Start(IEnumerable toolCalls) _expectedCallIds.Add(call.CallId); ClearCompletedCallIds(); + _invalidRationaleCallIds.Clear(); ExecutionTaskCompleted = false; } - public void RecordCompleted(string callId) - => _completedCallIds.Add(callId); + public void RecordCompleted(SerializableChatMessage result) + { + if (result.ToolCallId is not { } callId) + return; + + _completedCallIds.Add(callId.Value); + if (ToolCallMetaExtractor.IsRequiredRationaleRejection(result.Content)) + _invalidRationaleCallIds.Add(callId.Value); + } public void MarkExecutionTaskCompleted() => ExecutionTaskCompleted = true; @@ -61,6 +71,7 @@ public void Clear() { ClearExpectedCallIds(); ClearCompletedCallIds(); + _invalidRationaleCallIds.Clear(); ExecutionTaskCompleted = false; } diff --git a/src/Netclaw.Actors/Sessions/Handlers/TurnStateTracker.cs b/src/Netclaw.Actors/Sessions/Handlers/TurnStateTracker.cs index f2adbc85b..05e7c32d3 100644 --- a/src/Netclaw.Actors/Sessions/Handlers/TurnStateTracker.cs +++ b/src/Netclaw.Actors/Sessions/Handlers/TurnStateTracker.cs @@ -14,6 +14,7 @@ internal sealed class TurnStateTracker { private const int MaxPreToolEmptyRetries = 5; private const int MaxPostToolEmptyRetries = 8; + private const int MaxConsecutiveInvalidRationaleIterations = 3; private const int DuplicateToolThreshold = 3; private const double BudgetNudgeRatio = 0.75; @@ -52,6 +53,7 @@ internal sealed class TurnStateTracker private int _postToolEmptyResponseCount; private int _preToolEmptyResponseCount; private bool _duplicateNudgeSent; + private int _consecutiveInvalidRationaleIterations; /// /// Reset all per-turn state. Called at the start of each user turn. @@ -66,6 +68,7 @@ public void ResetForNewTurn() ForceNoToolsActive = false; _toolCallCounts.Clear(); _duplicateNudgeSent = false; + _consecutiveInvalidRationaleIterations = 0; } /// @@ -78,6 +81,7 @@ public void ResetToolCounters() ToolIterationCount = 0; _toolCallCounts.Clear(); _duplicateNudgeSent = false; + _consecutiveInvalidRationaleIterations = 0; } /// @@ -144,6 +148,26 @@ public ToolBudgetStatus RecordToolCompletion(int resultCount, int maxToolIterati return ToolBudgetStatus.Ok.Instance; } + public InvalidRationaleAction EvaluateInvalidRationaleResults( + int invalidRationaleCount, + int resultCount) + { + if (resultCount <= 0 || invalidRationaleCount != resultCount) + { + _consecutiveInvalidRationaleIterations = 0; + return InvalidRationaleAction.Continue.Instance; + } + + _consecutiveInvalidRationaleIterations++; + if (_consecutiveInvalidRationaleIterations < MaxConsecutiveInvalidRationaleIterations) + return InvalidRationaleAction.Continue.Instance; + + return new InvalidRationaleAction.StopTools( + "The provider omitted the required tool rationale three times. " + + "Do not request more tools in this turn. " + + "Answer with the evidence that you already have, and state any limits."); + } + // ── Duplicate detection decisions ── /// @@ -252,6 +276,16 @@ internal sealed record Exhausted(string NudgeText) : ToolBudgetStatus; /// Result of . internal sealed record DuplicateToolNudge(string ToolName, int Count, string NudgeText); +internal abstract record InvalidRationaleAction +{ + internal sealed record Continue : InvalidRationaleAction + { + public static readonly Continue Instance = new(); + } + + internal sealed record StopTools(string NudgeText) : InvalidRationaleAction; +} + /// Result of . internal abstract record EmptyResponseAction { diff --git a/src/Netclaw.Actors/Sessions/LlmMessages.cs b/src/Netclaw.Actors/Sessions/LlmMessages.cs index 62ea2174b..50c50a68d 100644 --- a/src/Netclaw.Actors/Sessions/LlmMessages.cs +++ b/src/Netclaw.Actors/Sessions/LlmMessages.cs @@ -80,6 +80,7 @@ internal sealed record ToolExecutionCompleted : INoSerializationVerificationNeed public List AcceptedSubAgentFindings { get; init; } = []; public List StartedBackgroundJobs { get; init; } = []; public List ScratchCorrectionChanges { get; init; } = []; + public Dictionary ToolFailureCodes { get; init; } = new(StringComparer.Ordinal); } internal sealed record ToolExecutionSingleCompleted(ToolCallResult Result) : INoSerializationVerificationNeeded; diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index 918137bec..0dd149610 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -939,7 +939,8 @@ private void HandleToolExecutionCompleted(ToolExecutionCompleted msg) SessionId = _sessionId, CallId = toolCallId, ToolName = new ToolName(result.Name ?? "unknown"), - Result = result.Content ?? string.Empty + Result = result.Content ?? string.Empty, + FailureCode = msg.ToolFailureCodes.GetValueOrDefault(toolCallId.Value) }, OutputFilter.ToolCalls); } @@ -1014,6 +1015,11 @@ private void HandleToolExecutionCompleted(ToolExecutionCompleted msg) AppendBufferedUserMessages(); } + var invalidRationaleCount = msg.ToolResults.Count(result => + Pipelines.ToolCallMetaExtractor.IsRequiredRationaleRejection(result.Content)); + if (StopToolsAfterInvalidRationale(invalidRationaleCount, msg.ToolResults.Count)) + return; + switch (budgetStatus) { case ToolBudgetStatus.Exhausted exhausted: @@ -1860,6 +1866,7 @@ private void HandleToolCallResponse( // meta extraction), so recorded history matches what actually runs — a near-miss // meta key is stripped + captured in MetaJson, not left raw with an empty meta. var toolMetadata = new Dictionary(StringComparer.Ordinal); + var toolFailureCodes = new Dictionary(StringComparer.Ordinal); var assistantMsg = ChatMessageConverter.FromAiMessage( lastMessage, interpretToolCall: tc => @@ -1868,10 +1875,12 @@ private void HandleToolCallResponse( IDictionary? cleanedArguments; if (_toolExecutor is { } toolExec) { - var prepared = toolExec.PrepareToolCall(tc); - meta = prepared.Meta; + var interpretation = toolExec.InterpretToolCall(tc); + if (interpretation.Rejection is { } rejection) + toolFailureCodes[tc.CallId] = rejection.DenyReason; + meta = interpretation.Meta; toolMetadata[tc.CallId] = meta; - return (meta, prepared.Cleaned.Arguments); + return (meta, interpretation.Cleaned.Arguments); } (meta, cleanedArguments) = ChatMessageConverter.ExtractMeta(tc.Arguments); @@ -1894,7 +1903,7 @@ private void HandleToolCallResponse( }, evt => { ApplyToolBatchStarted(evt); - EmitAndDispatchToolBatch(lastMessage, toolCalls, toolMetadata, usage); + EmitAndDispatchToolBatch(lastMessage, toolCalls, toolMetadata, toolFailureCodes, usage); }); } @@ -1939,6 +1948,7 @@ private void EmitAndDispatchToolBatch( AiChatMessage lastMessage, List toolCalls, IReadOnlyDictionary toolMetadata, + IReadOnlyDictionary toolFailureCodes, UsageDetails? usage) { @@ -1981,7 +1991,8 @@ private void EmitAndDispatchToolBatch( ArgumentsJson = argsJson, BatchId = batchId, BatchSize = Math.Max(1, toolCalls.Count), - Rationale = toolMetadata[tc.CallId]?.Rationale + Rationale = toolMetadata[tc.CallId]?.Rationale, + FailureCode = toolFailureCodes.GetValueOrDefault(tc.CallId) }, OutputFilter.ToolCalls); // Duplicate tool call detection: hash tool name + args @@ -3731,7 +3742,7 @@ private void ApplyToolCallRecorded(ToolCallRecorded evt) var alreadyRecorded = false; if (evt.ToolResult.ToolCallId is { } toolCallId) { - _activeToolBatch.RecordCompleted(toolCallId.Value); + _activeToolBatch.RecordCompleted(evt.ToolResult); if (ParkedToolBatchHistory.HasToolResult(_state.History, toolCallId.Value)) alreadyRecorded = true; @@ -5053,7 +5064,8 @@ private void ProcessToolCallResult(Pipelines.ToolCallResult result) SessionId = _sessionId, CallId = toolCallId, ToolName = new ToolName(toolMessage.Name ?? "unknown"), - Result = toolMessage.Content ?? string.Empty + Result = toolMessage.Content ?? string.Empty, + FailureCode = result.FailureCode }, OutputFilter.ToolCalls); var updatedContext = WorkingContextUpdater.UpdateFromToolResults( @@ -5140,6 +5152,9 @@ private void CompleteToolBatch(int resultCount) AppendBufferedUserMessages(); } + if (StopToolsAfterInvalidRationale(_activeToolBatch.InvalidRationaleCount, resultCount)) + return; + switch (budgetStatus) { case ToolBudgetStatus.Exhausted exhausted: @@ -5172,6 +5187,25 @@ private void CompleteToolBatch(int resultCount) FireLlmCall(); } + private bool StopToolsAfterInvalidRationale(int invalidRationaleCount, int resultCount) + { + if (_turnState.EvaluateInvalidRationaleResults(invalidRationaleCount, resultCount) + is not InvalidRationaleAction.StopTools stop) + { + return false; + } + + TurnLog().Warning( + "turn_invalid_rationale_limit_reached consecutiveIterations=3 resultCount={ResultCount}", + resultCount); + _state = _state.AddSystemNudge(stop.NudgeText); + _pendingToolInteractions.Clear(); + _resolvedToolApprovals.Clear(); + ClearActiveToolBatchTracking(); + FireLlmCall(forceNoTools: true); + return true; + } + private void PersistAdoptedContextIfNeeded(MessageSource? source) { if (source?.HasAdoptedContext != true) diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index 3be83855c..32ffb8a71 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -38,7 +38,8 @@ internal sealed record ToolCallResult( IReadOnlyList CompletedSubAgentRuns, IReadOnlyList AcceptedSubAgentFindings, Jobs.ActiveJobInfo? StartedBackgroundJob = null, - SessionScratchCorrectionChange? ScratchCorrectionChange = null); + SessionScratchCorrectionChange? ScratchCorrectionChange = null, + string? FailureCode = null); internal abstract record SessionScratchCorrectionChange { @@ -395,7 +396,16 @@ public async Task ExecuteAsync(SessionToolBatch batch) CompletedSubAgentRuns = [.. results.SelectMany(r => r.CompletedSubAgentRuns)], AcceptedSubAgentFindings = [.. results.SelectMany(r => r.AcceptedSubAgentFindings)], StartedBackgroundJobs = [.. results.Where(r => r.StartedBackgroundJob is not null).Select(r => r.StartedBackgroundJob!)], - ScratchCorrectionChanges = [.. results.Where(r => r.ScratchCorrectionChange is not null).Select(r => r.ScratchCorrectionChange!)] + ScratchCorrectionChanges = [.. results.Where(r => r.ScratchCorrectionChange is not null).Select(r => r.ScratchCorrectionChange!)], + ToolFailureCodes = results + .Where(result => result.FailureCode is not null) + .ToDictionary( + result => result.Message.ToolCallId is { } callId + ? callId.Value + : throw new InvalidOperationException("A failed tool result requires a call identity."), + result => result.FailureCode + ?? throw new InvalidOperationException("A failed tool result requires a failure code."), + StringComparer.Ordinal) }); } catch (TimeoutException ex) @@ -443,7 +453,7 @@ private async Task ExecuteSingleToolAsync( Content = rejection.Message, ToolCallId = new ToolCallId(tc.CallId), Name = tc.Name - }, [], [], [], []); + }, [], [], [], [], FailureCode: rejection.DenyReason); } var meta = interpretation.Meta; diff --git a/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs b/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs index 2471bc65f..f8131ce5b 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs @@ -15,10 +15,13 @@ namespace Netclaw.Actors.Sessions.Pipelines; /// internal static class ToolCallMetaExtractor { - private const string RequiredRationaleError = + internal const string RequiredRationaleError = "Error: Required meta argument '_rationale' must be a non-empty string. " + "Supply one sentence that states the tool call intent. The tool was NOT executed."; + internal static bool IsRequiredRationaleRejection(string? message) => + string.Equals(message, RequiredRationaleError, StringComparison.Ordinal); + /// /// Maps a key to its canonical meta field (schema-aware for the executor, /// exact for persistence). Defaults to exact. See diff --git a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs index f2e3ce9f3..60dad4b3a 100644 --- a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs +++ b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs @@ -79,6 +79,9 @@ public sealed record ToolCallOutput : SessionOutput /// The model-supplied intent for this call. public string? Rationale { get; init; } + /// The stable preflight failure code, or null for a valid request. + public string? FailureCode { get; init; } + /// /// Tool arguments as a JSON string. Kept opaque at the protocol level — /// tool executors parse based on their schema. @@ -97,6 +100,9 @@ public sealed record ToolResultOutput : SessionOutput public required ToolName ToolName { get; init; } public required string Result { get; init; } + + /// The stable preflight failure code, or null after execution. + public string? FailureCode { get; init; } } /// diff --git a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs index 3544cc21e..bb71b271f 100644 --- a/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs @@ -67,6 +67,30 @@ public void Tool_rationale_is_the_work_title_and_arguments_do_not_supply_a_fallb Assert.Contains("No rationale supplied", missing.SemanticText, StringComparison.Ordinal); } + [Fact] + public void Invalid_rationale_result_marks_the_tool_request_as_rejected() + { + var state = Apply(ChatPresentationState.Empty, ToolCall("call-a", "search", 1) with + { + FailureCode = "invalid_rationale" + }); + Assert.Equal("rejected", state.Tools["call-a"].Phase); + + state = Apply(state, ToolResult("call-a", "The tool was not executed.", 2) with + { + FailureCode = "invalid_rationale" + }); + + var tool = state.Tools["call-a"]; + Assert.Equal("rejected", tool.Phase); + Assert.Equal("Rejected tool request · rationale missing", + ChatPresentationReducer.ToolWorkTitle(tool)); + + state = Apply(state, CompletedTurn(3)); + Assert.Contains("1 rejected request", Assert.Single(state.Transcript).Summary, + StringComparison.Ordinal); + } + [Fact] public void Parallel_tool_batch_stays_in_one_live_passage() { @@ -375,6 +399,20 @@ public void Every_session_output_subtype_has_a_defined_reduction() SubAgent("run-a", SubAgentPhase.Started, 3), new BufferFlush { SessionId = SessionId }, new ProcessingStateOutput(true) { SessionId = SessionId }, + new UserMessageQueuedOutput + { + SessionId = SessionId, + MessageId = "tui:message-1", + TurnId = new TurnId("turn-a"), + QueueDepth = 1 + }, + new UserMessagesPulledOutput + { + SessionId = SessionId, + BatchId = "batch-a", + TurnId = new TurnId("turn-a"), + Messages = [new PulledUserMessage("tui:message-1", "Use the dev branch")] + }, new CompactionOutput { SessionId = SessionId, diff --git a/src/Netclaw.Cli/Tui/ChatPresentation.cs b/src/Netclaw.Cli/Tui/ChatPresentation.cs index 37542b207..d2a20f981 100644 --- a/src/Netclaw.Cli/Tui/ChatPresentation.cs +++ b/src/Netclaw.Cli/Tui/ChatPresentation.cs @@ -51,7 +51,8 @@ internal sealed record ToolActivityPresentation( int BatchSize, int PassageIndex, string? Result, - long? CompletedAtMs); + long? CompletedAtMs, + string? FailureCode); internal sealed record ReplyPassagePresentation( int Index, @@ -493,7 +494,7 @@ private static ChatPresentationState StartTool( output.ToolName.Value, output.Rationale, output.ArgumentsJson, - "queued", + output.FailureCode is null ? "queued" : "rejected", null, output.TimestampMs, state.CurrentTurnId, @@ -501,7 +502,8 @@ private static ChatPresentationState StartTool( output.BatchSize, passage.Index, null, - null); + null, + output.FailureCode); passage = passage with { ToolCallIds = passage.ToolCallIds.Add(tool.CallId) }; return state with { @@ -528,6 +530,7 @@ private static ChatPresentationState UpdateTool(ChatPresentationState state, Too 1, state.ReplyPassages.Count == 0 ? 0 : state.ReplyPassages[^1].Index, null, + null, null); return state with { @@ -556,7 +559,7 @@ private static ChatPresentationState CompleteTool( output.ToolName.Value, null, null, - "completed", + output.FailureCode is null ? "completed" : "rejected", null, output.TimestampMs, state.CurrentTurnId, @@ -564,7 +567,8 @@ private static ChatPresentationState CompleteTool( 1, passage.Index, output.Result, - output.TimestampMs); + output.TimestampMs, + output.FailureCode); passage = passage with { ToolCallIds = passage.ToolCallIds.Add(key) }; passages = passages.SetItem(passages.Count - 1, passage); } @@ -572,9 +576,10 @@ private static ChatPresentationState CompleteTool( { active = active with { - Phase = "completed", + Phase = output.FailureCode is null ? "completed" : "rejected", Result = output.Result, - CompletedAtMs = output.TimestampMs + CompletedAtMs = output.TimestampMs, + FailureCode = output.FailureCode }; } @@ -774,14 +779,20 @@ private static ChatPresentationBlock BuildSettledReply( var prose = string.Join("\n\n", state.ReplyPassages .Select(passage => passage.Text.Trim()) .Where(text => text.Length > 0)); - var completedToolCount = state.Tools.Count - incompleteTools.Count; - var toolReceipt = state.Tools.Count switch + var rejectedToolCount = state.Tools.Count(tool => tool.Value.FailureCode is not null); + var requestedToolCount = state.Tools.Count - rejectedToolCount; + var completedToolCount = requestedToolCount - incompleteTools.Count; + var toolReceipt = requestedToolCount switch { 0 => string.Empty, 1 when incompleteTools.Count == 0 => "1 tool", - _ when incompleteTools.Count == 0 => $"{state.Tools.Count} tools", - _ => $"{completedToolCount}/{state.Tools.Count} tools completed" + _ when incompleteTools.Count == 0 => $"{requestedToolCount} tools", + _ => $"{completedToolCount}/{requestedToolCount} tools completed" }; + var rejectedToolReceipt = CountLabel( + rejectedToolCount, + "rejected request", + "rejected requests"); var completedAgentCount = state.SubAgents.Count(run => run.Value.CompletedAtMs is not null); var agentReceipt = completedAgentCount switch { @@ -797,7 +808,7 @@ private static ChatPresentationBlock BuildSettledReply( }; var pulledMessageCount = state.AgentPulls.Sum(pull => pull.Messages.Count); var pullReceipt = CountLabel(pulledMessageCount, "follow-up", "follow-ups"); - var receiptParts = new[] { toolReceipt, agentReceipt, approvalReceipt, pullReceipt } + var receiptParts = new[] { toolReceipt, rejectedToolReceipt, agentReceipt, approvalReceipt, pullReceipt } .Where(value => value.Length > 0) .ToArray(); var receipt = receiptParts.Length == 0 @@ -868,7 +879,7 @@ private static string ToolDetail(ToolActivityPresentation tool) var duration = tool.CompletedAtMs is { } completedAt ? $"\nDuration: {Math.Max(0, completedAt - tool.StartedAtMs)} ms" : string.Empty; - return $"{ToolWorkTitle(tool.Rationale)}\nTool: {tool.ToolName}\nCall: {tool.CallId}\nState: {tool.Phase}" + return $"{ToolWorkTitle(tool)}\nTool: {tool.ToolName}\nCall: {tool.CallId}\nState: {tool.Phase}" + (tool.ArgumentsJson is null ? string.Empty : $"\nArguments: {tool.ArgumentsJson}") + (tool.Result is null ? string.Empty : $"\nResult: {tool.Result}") + duration; @@ -1108,6 +1119,12 @@ private static string ToolWorkTitle(string? rationale) => string.IsNullOrWhiteSp ? "No rationale supplied" : rationale.Trim(); + internal static string ToolWorkTitle(ToolActivityPresentation tool) => tool.FailureCode switch + { + "invalid_rationale" => "Rejected tool request · rationale missing", + _ => ToolWorkTitle(tool.Rationale) + }; + private static ChatPresentationState CommitParallelGroup( ChatPresentationState state, string batchId, diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index a8797a3d7..3af11a471 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -797,9 +797,7 @@ private ILayoutNode BuildToolActivity( ? string.Empty : $" {phase}"; var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; - var action = string.IsNullOrWhiteSpace(tool.Rationale) - ? "No rationale supplied" - : tool.Rationale.Trim(); + var action = ChatPresentationReducer.ToolWorkTitle(tool); var rows = Layouts.Vertical() .WithChild(new TextNode(ChatPresentationRenderer.OneLine( $"{state,-8} {action} · {tool.ToolName}{phaseText}{summary}", @@ -1148,7 +1146,7 @@ private static string StatusKeys( private static Color ActivityColor(string phase) => phase.ToLowerInvariant() switch { "queued" => ChatVisualTheme.Muted, - "failed" or "error" or "denied" => ChatVisualTheme.Danger, + "failed" or "error" or "denied" or "rejected" => ChatVisualTheme.Danger, "completed" or "complete" => ChatVisualTheme.Muted, _ => ChatVisualTheme.Primary }; @@ -1158,6 +1156,7 @@ private static string StatusKeys( "queued" => "Queued", "failed" or "error" => "Failed", "denied" => "Denied", + "rejected" => "Rejected", "completed" or "complete" => "Done", _ => "Live" }; From 603fc1e466377198cd051ae711cf96499d95b778 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 09:30:51 -0500 Subject: [PATCH 37/39] feat(chat): add tail follow controls --- .../Tui/InlineChatPageTests.cs | 86 +++++++++++ src/Netclaw.Cli/Tui/InlineChatPage.cs | 134 +++++++++++++++++- 2 files changed, 215 insertions(+), 5 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index 0876f759f..12bb79d2b 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -481,6 +481,92 @@ await harness.WaitUntilAsync(() => await harness.StopAsync(runTask); } + [Fact] + public async Task MouseWheelUp_PausesTailFollowUntilEnd() + { + await using var harness = CreateHarness(height: 20); + var runTask = harness.StartAsync(); + + harness.ViewModel.Emit(new TextDeltaOutput(string.Join( + '\n', + Enumerable.Range(1, 30).Select(index => $"stream line {index}"))) + { + SessionId = SessionId, + TimestampMs = 1 + }); + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("stream line 30") + && harness.Page.AssistantScrollOffset > 0); + + harness.Events.Enqueue(new MouseScrollEvent(+1) { X = 3, Y = 1 }); + await harness.WaitUntilAsync(() => harness.Page.AssistantCanScrollDown); + var pausedOffset = harness.Page.AssistantScrollOffset; + + harness.ViewModel.Emit(new TextDeltaOutput("\nstream line 31") + { + SessionId = SessionId, + TimestampMs = 2 + }); + harness.ViewModel.Emit(new TextDeltaOutput("\nstream line 32") + { + SessionId = SessionId, + TimestampMs = 3 + }); + await harness.WaitUntilAsync(() => + harness.Page.UnseenAssistantEventCount == 1 + && harness.Terminal.Contains("1 new event")); + + Assert.Equal(pausedOffset, harness.Page.AssistantScrollOffset); + harness.Input.EnqueueKey(ConsoleKey.End); + await harness.WaitUntilAsync(() => + !harness.Page.AssistantCanScrollDown + && harness.Page.UnseenAssistantEventCount == 0 + && harness.Terminal.Contains("stream line 32")); + + await harness.StopAsync(runTask); + } + + [Fact] + public async Task PageUp_GroupsUpdatesByWorkItem() + { + await using var harness = CreateHarness(height: 20); + var runTask = harness.StartAsync(); + + harness.ViewModel.Emit(new TextDeltaOutput(string.Join( + '\n', + Enumerable.Range(1, 30).Select(index => $"stream line {index}"))) + { + SessionId = SessionId, + TimestampMs = 1 + }); + await harness.WaitUntilAsync(() => harness.Page.AssistantScrollOffset > 0); + + harness.Input.EnqueueKey(ConsoleKey.PageUp); + await harness.WaitUntilAsync(() => harness.Page.AssistantCanScrollDown); + harness.ViewModel.Emit(ToolCall("call-tail", "shell_execute", 2)); + harness.ViewModel.Emit(new ToolActivityOutput + { + SessionId = SessionId, + TimestampMs = 3, + CallId = new ToolCallId("call-tail"), + ToolName = new ToolName("shell_execute"), + TurnId = new Netclaw.Actors.Protocol.TurnId("turn-1"), + Phase = "running", + Summary = "Inspect the repository." + }); + + await harness.WaitUntilAsync(() => + harness.Page.UnseenAssistantEventCount == 1 + && harness.Terminal.Contains("1 new event")); + + for (var index = 0; index < 5; index++) + harness.Input.EnqueueKey(ConsoleKey.PageDown); + await harness.WaitUntilAsync(() => + !harness.Page.AssistantCanScrollDown + && harness.Page.UnseenAssistantEventCount == 0); + await harness.StopAsync(runTask); + } + [Fact] public async Task StableTranscript_UsesPrimaryScrollbackWithoutAnOuterBorder() { diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 3af11a471..88a0b511f 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -52,6 +52,7 @@ public sealed class InlineChatPage : ReactivePage private Task _commitTail = Task.CompletedTask; private readonly List _deferredInspectorCommits = []; private readonly List _queuedPromptDisplays = []; + private readonly HashSet _unseenAssistantEvents = new(StringComparer.Ordinal); private long? _lastEscapeTimestamp; private int _inspectorIndex; private bool _inspectorOpen; @@ -124,6 +125,9 @@ protected override void OnBound() ViewModel.Input.OfType() .Subscribe(_ => _liveRegion.Invalidate()) .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(HandleAssistantMouseScroll) + .DisposeWith(Subscriptions); ViewModel.Input.OfType() .Subscribe(capabilities => { @@ -139,6 +143,12 @@ protected override void OnBound() internal bool ApprovalDetailCanScrollDown => _approvalDetail?.CanScrollDown == true; + internal int AssistantScrollOffset => _assistantStream?.ScrollOffset ?? 0; + + internal bool AssistantCanScrollDown => _assistantStream?.CanScrollDown == true; + + internal int UnseenAssistantEventCount => _unseenAssistantEvents.Count; + public override bool HandlePageInput(ConsoleKeyInfo keyInfo) { if (keyInfo.Key == ConsoleKey.Q @@ -172,6 +182,32 @@ public override bool HandlePageInput(ConsoleKeyInfo keyInfo) } } + if (_assistantStream is not null && _state.PendingApproval is null) + { + if (keyInfo.Key == ConsoleKey.PageUp && _assistantStream.CanScrollUp) + { + _assistantStream.PageUp(); + _liveRegion.Invalidate(); + return true; + } + + if (keyInfo.Key == ConsoleKey.PageDown && _assistantStream.CanScrollDown) + { + _assistantStream.PageDown(); + ResumeAssistantTailIfAtBottom(); + _liveRegion.Invalidate(); + return true; + } + + if (keyInfo.Key == ConsoleKey.End && IsAssistantTailPaused()) + { + _assistantStream.ScrollToBottom(); + _unseenAssistantEvents.Clear(); + _liveRegion.Invalidate(); + return true; + } + } + if (_state.PendingApproval is not null && keyInfo.Key == ConsoleKey.O && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control)) @@ -212,6 +248,23 @@ private void SubmitPrompt(string text) _ = ViewModel.SubmitAsync(text, messageId); } + private void HandleAssistantMouseScroll(MouseScrollEvent mouseScroll) + { + if (_assistantStream is null || _inspectorOpen || _state.PendingApproval is not null) + return; + + var scrollable = (IScrollable)_assistantStream; + if (mouseScroll.Delta > 0) + scrollable.ScrollUp(3); + else if (mouseScroll.Delta < 0) + scrollable.ScrollDown(3); + else + return; + + ResumeAssistantTailIfAtBottom(); + _liveRegion.Invalidate(); + } + private void ApplyMessageLifecycle(SessionOutput output) { switch (output) @@ -237,10 +290,59 @@ private void ApplyMessageLifecycle(SessionOutput output) private void ApplyOutput(SessionOutput output) { + TrackUnseenAssistantEvent(output); ApplyMessageLifecycle(output); ApplyReduction(ChatPresentationReducer.Reduce(_state, output)); } + private void TrackUnseenAssistantEvent(SessionOutput output) + { + if (output is TurnCompleted) + { + _unseenAssistantEvents.Clear(); + return; + } + + if (!IsAssistantTailPaused()) + { + _unseenAssistantEvents.Clear(); + return; + } + + if (AssistantEventKey(output) is { } key) + _unseenAssistantEvents.Add(key); + } + + private string? AssistantEventKey(SessionOutput output) => output switch + { + TextDeltaOutput or TextOutput => $"text:{CurrentReplyPassageIndex()}", + ThinkingDeltaOutput or ThinkingOutput => "thought", + ToolCallOutput tool => $"tool:{tool.CallId.Value}", + ToolActivityOutput activity => $"tool:{activity.CallId.Value}", + ToolResultOutput result => $"tool:{result.CallId.Value}", + SubAgentOutput agent => $"agent:{agent.RunId?.Value ?? agent.AgentName.Value}", + UserMessagesPulledOutput pulled => $"pull:{pulled.BatchId}", + _ => null + }; + + private int CurrentReplyPassageIndex() + { + if (_state.ReplyPassages.Count == 0) + return 0; + + var passage = _state.ReplyPassages[^1]; + return passage.IsFinal ? passage.Index + 1 : passage.Index; + } + + private bool IsAssistantTailPaused() => + _assistantStream is { CanScrollDown: true, IsNearBottom: false }; + + private void ResumeAssistantTailIfAtBottom() + { + if (_assistantStream?.CanScrollDown == false) + _unseenAssistantEvents.Clear(); + } + private void ApplyReduction(ChatReduction reduction) { var hadComposer = ShowsComposer(_state); @@ -732,7 +834,7 @@ private ILayoutNode BuildLiveReplyBlock() var maximumHeight = Math.Max(5, Math.Min(18, _terminal.Height / 2)); _assistantStream ??= new ScrollableContainerNode() - .WithAutoScroll(AutoScrollPolicy.AlwaysTail) + .WithAutoScroll(AutoScrollPolicy.TailWhenAtBottom) .WithScrollbar(false); _assistantStream.WithContent(reply); @@ -955,12 +1057,14 @@ private ILayoutNode BuildStatusLine() }; if (_state.PendingApproval is not null) displayStatus = "Decision needed"; + var tailPaused = IsAssistantTailPaused(); var keys = StatusKeys( _terminal.Width, _state.PendingApproval is not null, ViewModel.IsApprovalDetailVisible.Value, ShowsComposer(_state), - _modifiedEnterKeySupport); + _modifiedEnterKeySupport, + tailPaused); var statusNode = new TextNode( string.Equals(status, "Generating...", StringComparison.Ordinal) ? displayStatus.PadRight(12) @@ -971,7 +1075,17 @@ private ILayoutNode BuildStatusLine() else statusNode.WidthAuto(); var statusLine = Layouts.Horizontal() - .WithChild(statusNode) + .WithChild(statusNode); + if (tailPaused) + { + var count = _unseenAssistantEvents.Count; + var eventLabel = $"{count} new {(count == 1 ? "event" : "events")}"; + statusLine.WithChild(new TextNode($" {eventLabel.PadRight(13)}") + .WithForeground(ChatVisualTheme.Primary) + .Width(15)); + } + + statusLine .WithChild(new TextNode($" {keys}") .WithForeground(ChatVisualTheme.Muted) .NoWrap() @@ -1105,7 +1219,8 @@ private static string StatusKeys( bool hasApproval, bool approvalDetailVisible, bool hasComposer, - TerminalCapabilityAvailability modifiedEnterKeySupport) + TerminalCapabilityAvailability modifiedEnterKeySupport, + bool tailPaused) { if (hasApproval) return width >= 88 @@ -1114,7 +1229,16 @@ private static string StatusKeys( : "Up/Down select Enter confirm Ctrl+O details Esc deny Ctrl+Q quit" : "Up/Down select Enter confirm Esc deny"; if (!hasComposer) - return width >= 70 ? "Ctrl+O inspect Ctrl+Q quit" : "Ctrl+Q quit"; + return width >= 70 + ? tailPaused + ? "End follow Ctrl+O inspect Ctrl+Q quit" + : "Ctrl+O inspect Ctrl+Q quit" + : "Ctrl+Q quit"; + + if (tailPaused) + return width >= 88 + ? "PageDown/End follow Enter send Esc x2 clear Ctrl+Q quit" + : "End follow Enter send"; if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unavailable) { From 5dc58772b699a09eefa985b4252171b0dda14b40 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 10:07:36 -0500 Subject: [PATCH 38/39] fix(chat): preserve implicit tail follow --- .../Tui/InlineChatPageTests.cs | 30 +++++++++++++ src/Netclaw.Cli/Tui/InlineChatPage.cs | 43 ++++++++++++++++--- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs index 12bb79d2b..fab1972bf 100644 --- a/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -481,6 +481,36 @@ await harness.WaitUntilAsync(() => await harness.StopAsync(runTask); } + [Fact] + public async Task LargeStreamUpdate_StaysAtTheTailWithoutAnUnseenBadge() + { + await using var harness = CreateHarness(height: 20); + var runTask = harness.StartAsync(); + + harness.ViewModel.Emit(new TextDeltaOutput(string.Join( + '\n', + Enumerable.Range(1, 12).Select(index => $"stream line {index}"))) + { + SessionId = SessionId, + TimestampMs = 1 + }); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("stream line 12")); + + harness.ViewModel.Emit(new TextDeltaOutput("\n" + string.Join( + '\n', + Enumerable.Range(13, 30).Select(index => $"stream line {index}"))) + { + SessionId = SessionId, + TimestampMs = 2 + }); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("stream line 42")); + + Assert.False(harness.Page.AssistantCanScrollDown); + Assert.Equal(0, harness.Page.UnseenAssistantEventCount); + Assert.DoesNotContain("new events", harness.Terminal.ToString(), StringComparison.Ordinal); + await harness.StopAsync(runTask); + } + [Fact] public async Task MouseWheelUp_PausesTailFollowUntilEnd() { diff --git a/src/Netclaw.Cli/Tui/InlineChatPage.cs b/src/Netclaw.Cli/Tui/InlineChatPage.cs index 88a0b511f..155740546 100644 --- a/src/Netclaw.Cli/Tui/InlineChatPage.cs +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -48,6 +48,7 @@ public sealed class InlineChatPage : ReactivePage private string? _inspectorCopyStatus; private int _inspectorRenderWidth; private int _thinkingFrame; + private bool _assistantTailPaused; private ChatPresentationState _state = ChatPresentationState.Empty; private Task _commitTail = Task.CompletedTask; private readonly List _deferredInspectorCommits = []; @@ -187,6 +188,7 @@ public override bool HandlePageInput(ConsoleKeyInfo keyInfo) if (keyInfo.Key == ConsoleKey.PageUp && _assistantStream.CanScrollUp) { _assistantStream.PageUp(); + _assistantTailPaused = true; _liveRegion.Invalidate(); return true; } @@ -202,6 +204,7 @@ public override bool HandlePageInput(ConsoleKeyInfo keyInfo) if (keyInfo.Key == ConsoleKey.End && IsAssistantTailPaused()) { _assistantStream.ScrollToBottom(); + _assistantTailPaused = false; _unseenAssistantEvents.Clear(); _liveRegion.Invalidate(); return true; @@ -255,7 +258,12 @@ private void HandleAssistantMouseScroll(MouseScrollEvent mouseScroll) var scrollable = (IScrollable)_assistantStream; if (mouseScroll.Delta > 0) + { + var offset = _assistantStream.ScrollOffset; scrollable.ScrollUp(3); + if (_assistantStream.ScrollOffset != offset) + _assistantTailPaused = true; + } else if (mouseScroll.Delta < 0) scrollable.ScrollDown(3); else @@ -299,6 +307,7 @@ private void TrackUnseenAssistantEvent(SessionOutput output) { if (output is TurnCompleted) { + _assistantTailPaused = false; _unseenAssistantEvents.Clear(); return; } @@ -334,13 +343,29 @@ private int CurrentReplyPassageIndex() return passage.IsFinal ? passage.Index + 1 : passage.Index; } - private bool IsAssistantTailPaused() => - _assistantStream is { CanScrollDown: true, IsNearBottom: false }; + private bool IsAssistantTailPaused() => _assistantTailPaused; private void ResumeAssistantTailIfAtBottom() { if (_assistantStream?.CanScrollDown == false) + { + _assistantTailPaused = false; _unseenAssistantEvents.Clear(); + } + } + + private void SynchronizeAssistantTailState() + { + if (_assistantStream is null) + return; + + if (_assistantStream is { CanScrollDown: true, IsNearBottom: false }) + { + _assistantTailPaused = true; + return; + } + + ResumeAssistantTailIfAtBottom(); } private void ApplyReduction(ChatReduction reduction) @@ -833,9 +858,17 @@ private ILayoutNode BuildLiveReplyBlock() reply.WithChild(BuildAgentPull(pull, lineWidth)); var maximumHeight = Math.Max(5, Math.Min(18, _terminal.Height / 2)); - _assistantStream ??= new ScrollableContainerNode() - .WithAutoScroll(AutoScrollPolicy.TailWhenAtBottom) - .WithScrollbar(false); + if (_assistantStream is null) + { + _assistantStream = new ScrollableContainerNode() + .WithScrollbar(false); + _assistantStream.Invalidated + .Subscribe(_ => SynchronizeAssistantTailState()) + .DisposeWith(Subscriptions); + } + _assistantStream.AutoScroll = _assistantTailPaused + ? AutoScrollPolicy.None + : AutoScrollPolicy.AlwaysTail; _assistantStream.WithContent(reply); return new PanelNode() From 00a011a48239d1d958701b2f47f6b044165c1fa2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 13 Aug 2026 10:23:04 -0500 Subject: [PATCH 39/39] test(actors): honor required tool rationales --- .../Sessions/ToolBatchHistoryWedgeTests.cs | 23 ++++++++++++++----- .../SubAgents/SubAgentActorTests.cs | 15 +++++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Sessions/ToolBatchHistoryWedgeTests.cs b/src/Netclaw.Actors.Tests/Sessions/ToolBatchHistoryWedgeTests.cs index 388e6100d..ea35287bf 100644 --- a/src/Netclaw.Actors.Tests/Sessions/ToolBatchHistoryWedgeTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/ToolBatchHistoryWedgeTests.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using Akka.Actor; using Akka.Hosting; +using System.Collections.Concurrent; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Netclaw.Actors.Hosting; @@ -82,15 +83,22 @@ public async Task Partial_failure_of_parallel_tool_batch_leaves_history_well_for var ct = TestContext.Current.CancellationToken; // Turn 1: two parallel tool calls. call-A executes normally; call-B - // throws in InterpretToolCall, which escapes to ToolExecutionFailed -> - // FailCurrentTurn. call-A is recorded before the failure (Task.WhenAll - // invariant), so the batch fails with A answered and B unanswered. + // throws during the execution pipeline preflight. The pipeline reports + // ToolExecutionFailed after call-A reports its streamed result. _fakeChatClient.ToolCallsOnFirstCall = [ new FunctionCallContent("call-A", "web_search", - new Dictionary { ["query"] = "a" }), + new Dictionary + { + ["query"] = "a", + ["_rationale"] = "Search for the first test result." + }), new FunctionCallContent("call-B", "web_search", - new Dictionary { ["query"] = "b" }), + new Dictionary + { + ["query"] = "b", + ["_rationale"] = "Search for the second test result." + }), ]; _executor.FailInterpretForCallIds.Add("call-B"); @@ -190,11 +198,14 @@ await subscriber.FishForMessageAsync( /// internal sealed class PartialFailureToolExecutor : IToolExecutor { + private readonly ConcurrentDictionary _interpretCounts = new(StringComparer.Ordinal); + public HashSet FailInterpretForCallIds { get; } = new(StringComparer.Ordinal); public ToolCallInterpretation InterpretToolCall(FunctionCallContent toolCall) { - if (FailInterpretForCallIds.Contains(toolCall.CallId)) + var invocation = _interpretCounts.AddOrUpdate(toolCall.CallId, 1, static (_, count) => count + 1); + if (FailInterpretForCallIds.Contains(toolCall.CallId) && invocation > 1) throw new InvalidOperationException( $"simulated interpret failure for {toolCall.CallId}"); diff --git a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs index 7df590df5..b601c97ae 100644 --- a/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs +++ b/src/Netclaw.Actors.Tests/SubAgents/SubAgentActorTests.cs @@ -777,7 +777,11 @@ public async Task Subagent_project_declaration_updates_child_prompt_before_uncha new FunctionCallContent( declarationCallId, SetWorkingDirectoryTool.ToolName, - new Dictionary { ["Path"] = worktree }), + new Dictionary + { + ["Path"] = worktree, + ["_rationale"] = "Declare the project directory before the next inspection." + }), ProjectScopeCall(retryCallId, worktree) ]); var approvalBridge = supportsApproval @@ -833,7 +837,11 @@ public async Task Subagent_rejects_control_characters_from_project_scope_result( new FunctionCallContent( "call-control-project", SetWorkingDirectoryTool.ToolName, - new Dictionary { ["Path"] = controlledDirectory }) + new Dictionary + { + ["Path"] = controlledDirectory, + ["_rationale"] = "Verify that the project scope rejects control characters." + }) ]); var promptProvider = new ProjectPromptProvider(controlledDirectory, projectGuidance); var actor = Sys.ActorOf(SubAgentActor.CreatePropsWithProjectInstructionProvider( @@ -1414,7 +1422,8 @@ private static FunctionCallContent ProjectScopeCall(string callId, string workin => new(callId, ShellTool.ToolName, new Dictionary { ["Command"] = "grep -rn 'Metric' tests src; cat tests/project.csproj", - ["WorkingDirectory"] = workingDirectory + ["WorkingDirectory"] = workingDirectory, + ["_rationale"] = "Inspect the project metric sources." }); private static string? GetLastToolResult(FakeChatClient fakeClient, string callId)