diff --git a/Directory.Packages.props b/Directory.Packages.props index 4c4a3611c..f429fe76f 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/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..763d7f7b7 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,26 @@ 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 +- 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 +- 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..abc5ac124 100644 --- a/docs/spec/SPEC-004-cli-contract.md +++ b/docs/spec/SPEC-004-cli-contract.md @@ -94,6 +94,31 @@ 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 model call does not disable or hide the composer. Later prompts enter the +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. + ## Output and Exit Codes - default output: human readable text @@ -110,6 +135,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..8686fb27a 100644 --- a/docs/spec/SPEC-010-testing-and-smoke-strategy.md +++ b/docs/spec/SPEC-010-testing-and-smoke-strategy.md @@ -29,6 +29,36 @@ 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 visual review. +Unit tests alone cannot validate the visual grammar. + +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 + +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, +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 +75,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/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-001-command-wireframes.md b/docs/ui/TUI-001-command-wireframes.md index 7f52668da..c12cf3c74 100644 --- a/docs/ui/TUI-001-command-wireframes.md +++ b/docs/ui/TUI-001-command-wireframes.md @@ -115,84 +115,170 @@ 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 | 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 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 | + +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 x2 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 + +QUEUED 2 messages + 1 Also inspect the failed test history. + 2 Then propose the smallest deterministic fix. + +MESSAGE + Ask Netclaw… +``` + +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 + +``` +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 + | | + | +--approval--> Decision Gate --decision--> Live Deck + | +--inspect--> Inspector --close--> queued output commit + +--Enter while live--> Queue Shelf --turn end--> one FIFO follow-up call ``` -### 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/docs/ui/TUI-002-chat-visual-grammar.md b/docs/ui/TUI-002-chat-visual-grammar.md new file mode 100644 index 000000000..4361f04d1 --- /dev/null +++ b/docs/ui/TUI-002-chat-visual-grammar.md @@ -0,0 +1,373 @@ +# TUI-002: Chat Visual Grammar + +Source PRDs: `PRD-004`, `PRD-009` + +Revised: 2026-08-12 + +## Design Intent + +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 | 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 | +| 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 live region also avoids these glyphs where practical. +Cell background can define a surface because terminal selection does not copy color. + +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 + +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 | +| 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 | A useful completed result | +| Warning | A decision or degraded result | +| Danger | A failure or denial | +| Muted | Time, metadata, receipts, and key 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 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. +- 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 + +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 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: + +```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. + +## 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. +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. +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. + +## 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 +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: + +| 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 +You 13:35 + Find the configured context window. + +Netclaw 13:35 LIVE + I will inspect the deployment settings and the active session. + + ⠹ Search deployment settings + shell_execute · grep context and model values + + Parallel work · 2 calls + ✓ Read session manifest · 0.2s + ⠋ Inspect model configuration · file_list + +Queued 1 + Then tell me which setting wins. + +NETCLAW Casual Greeting Exchange deepseek-v4-flash-dspark 18% connected + +MESSAGE + Ask Netclaw... + +Thinking.. Enter send Shift+Enter newline Esc x2 clear Ctrl+O inspect +``` + +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. + +## ASCII Mockup: Settled Turn + +```text +You 13:35 + Find the configured context window. + +Netclaw 13:35 + I inspected the deployment settings and the active session. + + ✓ Inspected configuration · 3 tools · 1.7s + ! One protected path blocked direct access + + 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... + +Ready Enter send Shift+Enter newline Esc x2 clear Ctrl+O inspect +``` + +The settled Turn prints as one immutable block. +Individual tool cards do not enter the Transcript. + +## ASCII Mockup: Decision Sheet + +```text +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 + 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. + +## 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 +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 x2 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 +--> one queued batch + | +--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. +The approval queue exposes only its head as a Decision Sheet. + +## Content Rules + +- 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` + +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..504309095 --- /dev/null +++ b/docs/ui/chat-reply-stack-v2/index.html @@ -0,0 +1,1043 @@ + + + + + + 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 +
+ +
+
+ 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.
+
+ +
+ Session Strip + NETCLAW + Casual Greeting Exchange + deepseek-v4-flash-dspark + 18% + connected +
+ +
+ Composer +
MESSAGE
+
Ask Netclaw...
+
+ +
+ Pulse Line + Thinking. + Enter send   Shift+Enter newline   Esc x2 clear   Ctrl+O inspect   Ctrl+Q quit +
+
+ + + + + + +
+
+ + +
+ + + + diff --git a/docs/ui/mockups/chat-quiet-active.png b/docs/ui/mockups/chat-quiet-active.png new file mode 100644 index 000000000..5cfafb797 Binary files /dev/null and b/docs/ui/mockups/chat-quiet-active.png differ diff --git a/docs/ui/mockups/chat-quiet-active.svg b/docs/ui/mockups/chat-quiet-active.svg new file mode 100644 index 000000000..c73a9d072 --- /dev/null +++ b/docs/ui/mockups/chat-quiet-active.svg @@ -0,0 +1,74 @@ + + + + + + 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 000000000..a92f4441f Binary files /dev/null and b/docs/ui/mockups/chat-quiet-approval.png differ 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 000000000..46a4eef9b Binary files /dev/null and b/docs/ui/mockups/chat-quiet-inspector.png differ 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 000000000..45b3d28fd Binary files /dev/null and b/docs/ui/mockups/chat-quiet-normal.png differ 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 + diff --git a/evals/run-evals.sh b/evals/run-evals.sh index c352a5171..3eac536c1 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 @@ -144,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 } @@ -765,7 +763,6 @@ check_daemon_alive() { fi } - run_prompt() { local prompt="$1" local output_format="${2:-text}" @@ -774,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 @@ -1099,6 +1094,16 @@ 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_contains 'one.*approval\|approval.*one' \ + && stdout_no_skill_file_read_called +} + assert_skill_citation_search() { # Model should actually search when asked to search. stdout_contains '\[tool:call\] web_search' @@ -1229,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 } } @@ -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, 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" \ "Look up the current version of Akka.NET" @@ -2249,6 +2262,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 diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 9b76ea793..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 @@ -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..8a9120ab1 --- /dev/null +++ b/feeds/skills/.system/files/netclaw-operations/references/chat.md @@ -0,0 +1,45 @@ +# 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 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 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. + +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. + +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/.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..680550f3b --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/design.md @@ -0,0 +1,429 @@ +## 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. +- 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. +- Prove behavior with deterministic tests and disposable visual checkpoints. + +**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. +- 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. + +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. + +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 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. +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. + +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. + +### 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. +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 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. + +## 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 disposable visual 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 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..c3e9c4eae --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/proposal.md @@ -0,0 +1,105 @@ +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. +- 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. +- 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. + +### 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. +- 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. +- `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. + +## 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. +- Development review gains temporary chat videos and selected frame images. +- `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..0665c08b1 --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-chat-tui/spec.md @@ -0,0 +1,284 @@ +## 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 retains the Composer + +- **WHEN** a submitted turn remains active +- **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 + +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: 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, +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..0e1be72f0 --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/specs/netclaw-testing/spec.md @@ -0,0 +1,97 @@ +## 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. 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 + +- **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` + +#### 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. +These tapes SHALL not enter CI or the permanent smoke suite. + +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: Visual checkpoint review + +- **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 + +- **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/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 new file mode 100644 index 000000000..67d87bedf --- /dev/null +++ b/openspec/changes/redesign-netclaw-chat-tui/tasks.md @@ -0,0 +1,143 @@ +## 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. +- [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. + +## 4. Termina Input, Scroll, and Copy Primitives + +- [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`. +- [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. + +## 5. Netclaw Session Output Contract + +- [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. +- [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 + +- [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 + +- [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. +- [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. + +## 8. Composer, Approval, Inspector, and Copy Behavior + +- [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. +- [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 + +- [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. +- [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 + +- [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 + +- [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. +- [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. +- [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. +- [ ] 11.10 Sync the approved delta specifications and archive the completed change. diff --git a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs index 7618331ff..995fd1456 100644 --- a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs @@ -132,6 +132,243 @@ 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() + { + 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\"}", + Rationale = "Verify the source tree", + Result = "Passed", + BatchId = "batch-1", + BatchSize = 2 + }, + new SessionTranscriptEntry + { + 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, + 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_004, + ErrorMessage = "Failed", + ErrorDetail = "detail", + ErrorCorrelationId = "correlation", + ErrorCategory = "provider_failure" + } + ] + }; + + var result = RoundTrip(original); + + 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 = "{}", + Rationale = "Find the relevant source", + 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); + 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, + SessionId = "test/wire", + CallId = "old-call", + ToolName = "search" + })); + Assert.Null(oldToolResult.Rationale); + + 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 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() + { + 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 +1024,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/ApprovalRehydrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ApprovalRehydrationTests.cs index 68923a476..61adaabd5 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( @@ -1337,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( @@ -1403,6 +1435,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 +1591,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.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/LlmSessionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs index a16cdab6d..2f92de84d 100644 --- a/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/LlmSessionIntegrationTests.cs @@ -783,7 +783,183 @@ 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); + + 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] + 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 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] @@ -1751,6 +1927,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/Pipelines/MetaValidationAndNoticeTests.cs b/src/Netclaw.Actors.Tests/Sessions/Pipelines/MetaValidationAndNoticeTests.cs index 8bc9f190c..3ded3ac74 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,47 @@ 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)); + Assert.Equal("invalid_rationale", completed.ToolFailureCodes["call-invalid"]); + Assert.False(completed.ToolFailureCodes.ContainsKey("call-valid")); + } + [Fact] public async Task Non_integral_json_timeout_rejects_without_uncaught_throw() { 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..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() { @@ -74,6 +127,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 +468,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..0741303f9 100644 --- a/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs +++ b/src/Netclaw.Actors.Tests/Sessions/SessionSubscriberManagerTests.cs @@ -86,4 +86,66 @@ 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); + } + + [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.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..791ca0887 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Sessions/SessionTranscriptEntryFactoryTests.cs @@ -0,0 +1,139 @@ +// ----------------------------------------------------------------------- +// +// 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"), + Rationale = "Verify the source tree", + 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("Verify the source tree", tool.Rationale); + 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..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 @@ -236,25 +248,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); @@ -295,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 @@ -306,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 @@ -342,10 +375,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 +403,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); @@ -389,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 @@ -400,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 @@ -429,8 +474,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); @@ -707,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 @@ -778,27 +828,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..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"); @@ -116,10 +124,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 +133,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 +177,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); } } @@ -180,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/Sessions/ToolExecutionIntegrationTests.cs b/src/Netclaw.Actors.Tests/Sessions/ToolExecutionIntegrationTests.cs index 87bc8fa28..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; @@ -68,7 +70,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 +100,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); @@ -347,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.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..b601c97ae 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" }) ] }; @@ -762,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 @@ -818,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( @@ -937,11 +960,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 +1001,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 +1045,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 +1099,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 +1151,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 +1199,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 +1231,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" }) ] }; @@ -1391,14 +1414,16 @@ 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) => 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) @@ -1484,7 +1509,7 @@ public async Task Max_iterations_forces_text_response() { ToolCallsOnFirstCall = [ - new FunctionCallContent("call-loop", "looper") + CreateToolCall("call-loop", "looper") ], AlwaysReturnToolCalls = true }; @@ -1621,7 +1646,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 +1721,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 +1894,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 +1925,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/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index f42cf6994..1ecfb136e 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"; @@ -28,9 +30,12 @@ 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"; + public const string ApprovalOutcome = "approval_outcome"; public const string Unknown = "unknown"; } @@ -40,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 @@ -66,6 +76,13 @@ 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; } + public string? ToolBatchId { get; init; } + public int? ToolBatchSize { get; init; } + public string? ToolRationale { get; init; } // Usage public long? InputTokens { get; init; } @@ -94,9 +111,17 @@ 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; } + public bool? ToolResultsCleared { get; init; } + public bool? Summarized { get; init; } public long? PreCompactionInputTokens { get; init; } public int? KeepCountUsed { get; init; } @@ -104,23 +129,33 @@ 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; } + + // Approval Outcome + public string? ApprovalSelectedKey { get; init; } + public string? ApprovalParentCallId { 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..871f94ef6 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; @@ -57,7 +59,23 @@ 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, + ToolRationale = msg.Rationale, + ToolFailureCode = msg.FailureCode + }, + + 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 @@ -67,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 @@ -132,6 +151,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 @@ -162,6 +185,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, @@ -169,6 +214,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 +228,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 +242,28 @@ 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 + }, + + 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 @@ -244,7 +306,21 @@ 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, + Rationale = dto.ToolRationale, + FailureCode = dto.ToolFailureCode + }, + 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 { @@ -252,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 { @@ -313,12 +390,33 @@ 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, 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 +427,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 +439,31 @@ 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 + }, + 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 { @@ -355,7 +471,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) @@ -367,11 +483,31 @@ 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?.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 +515,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..ef925ea21 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. @@ -43,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). @@ -109,4 +118,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..e3b058454 --- /dev/null +++ b/src/Netclaw.Actors/Protocol/SessionTranscriptEntry.cs @@ -0,0 +1,119 @@ +// ----------------------------------------------------------------------- +// +// 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 Approval = "approval"; + 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? Rationale { get; init; } + + public string? BatchId { get; init; } + + public int? BatchSize { 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 string? ApprovalSelectedKey { 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..aab13487c 100644 --- a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs +++ b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs @@ -129,6 +129,111 @@ 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; + 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; + } + + 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, + BatchId = proto.HasBatchId ? proto.BatchId : null, + BatchSize = proto.HasBatchSize ? proto.BatchSize : null, + ApprovalSelectedKey = proto.HasApprovalSelectedKey ? proto.ApprovalSelectedKey : null, + Rationale = proto.HasRationale ? proto.Rationale : null + }; + // ── SendUserMessage ── internal static Proto.SendUserMessageProto ToProto(SendUserMessage cmd) @@ -166,6 +271,8 @@ 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)); + proto.UserMessages.AddRange(evt.UserMessages.Select(ToProto)); return proto; } @@ -176,7 +283,9 @@ 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(), + UserMessages = proto.UserMessages.Select(FromProto).ToArray() }; // ── SessionTitleSet ── @@ -224,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 }; @@ -489,6 +604,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 +619,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 +670,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 +849,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..b35e4c0a8 100644 --- a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto +++ b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto @@ -77,6 +77,53 @@ 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; + optional string batch_id = 41; + optional int32 batch_size = 42; + optional string approval_selected_key = 43; + optional string rationale = 44; +} + // ── Commands ── message SendUserMessageProto { @@ -94,6 +141,8 @@ 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; + repeated SerializableChatMessageProto user_messages = 8; } message SessionTitleSetProto { @@ -117,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 { @@ -268,6 +318,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/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 d68c9115b..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; @@ -121,6 +122,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 +138,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..0dd149610 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; @@ -75,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(); @@ -206,6 +208,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 +304,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); @@ -501,6 +508,7 @@ private void Processing() _deliveryRetry.Clear(); _log.Info("Buffering user message (LLM call in progress)"); _buffer.Add(cmd); + EmitUserMessageQueued(cmd); TryReplyAck(); }); @@ -730,6 +738,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 +869,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 +908,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, @@ -924,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); } @@ -996,14 +1012,14 @@ 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(); } + var invalidRationaleCount = msg.ToolResults.Count(result => + Pipelines.ToolCallMetaExtractor.IsRequiredRationaleRejection(result.Content)); + if (StopToolsAfterInvalidRationale(invalidRationaleCount, msg.ToolResults.Count)) + return; + switch (budgetStatus) { case ToolBudgetStatus.Exhausted exhausted: @@ -1155,6 +1171,7 @@ private void Compacting() _log.Info("Buffering user message (compaction in progress)"); _buffer.Add(cmd); + EmitUserMessageQueued(cmd); TryReplyAck(); }); @@ -1287,9 +1304,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 +1314,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); @@ -1371,6 +1397,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; } @@ -1402,12 +1436,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) @@ -1836,15 +1865,28 @@ 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 toolFailureCodes = 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 interpretation = toolExec.InterpretToolCall(tc); + if (interpretation.Rejection is { } rejection) + toolFailureCodes[tc.CallId] = rejection.DenyReason; + meta = interpretation.Meta; + toolMetadata[tc.CallId] = meta; + return (meta, interpretation.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, @@ -1855,12 +1897,13 @@ private void HandleToolCallResponse( { SessionId = _sessionId, UserMessage = userMsg, + UserMessages = SnapshotCurrentTurnUserMessages(userMsg), AssistantMessage = assistantMsg, StartedAtMs = NowMs() }, evt => { ApplyToolBatchStarted(evt); - EmitAndDispatchToolBatch(lastMessage, toolCalls, usage); + EmitAndDispatchToolBatch(lastMessage, toolCalls, toolMetadata, toolFailureCodes, usage); }); } @@ -1904,6 +1947,8 @@ private static void CanonicalizeToolCallNames( private void EmitAndDispatchToolBatch( AiChatMessage lastMessage, List toolCalls, + IReadOnlyDictionary toolMetadata, + IReadOnlyDictionary toolFailureCodes, UsageDetails? usage) { @@ -1929,6 +1974,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) { @@ -1940,7 +1988,11 @@ 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), + Rationale = toolMetadata[tc.CallId]?.Rationale, + FailureCode = toolFailureCodes.GetValueOrDefault(tc.CallId) }, OutputFilter.ToolCalls); // Duplicate tool call detection: hash tool name + args @@ -2005,6 +2057,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 +2108,7 @@ await self.Ask( ToolCalls = toolCalls, DefaultTimeout = new ToolExecutionTimeout(toolExecutionTimeout), ReplyTo = self, + EmitToolActivityOutput = emitToolActivityOutput, EmitSubAgentOutput = emitSubAgentOutput, ApprovalRequests = new ToolApprovalRequests( _approvalChannel, @@ -2087,6 +2145,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) @@ -2102,10 +2161,20 @@ private void HandleTextResponse( Role = Protocol.ChatRole.User, Content = string.Empty }, + UserMessages = SnapshotCurrentTurnUserMessages(userMsg), 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 +2193,13 @@ 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(); + _currentTurnUserMessages.Clear(); EmitResponseOutputs(lastMessage, usage, includeText: true, includeThinking: true); MaybeSnapshot(); @@ -2158,6 +2233,58 @@ private void HandleTextResponse( }); } + private IReadOnlyList BuildTurnTranscriptEntries( + SerializableChatMessage userMessage, + SerializableChatMessage assistantReply, + UsageDetails? usage, + long recordedAtMs) + { + var turnId = _activeTurnId?.Value; + var transcriptStart = _currentTurnUserMessages.Count > 0 + ? _currentTurnUserMessages[0] + : userMessage; + var extracted = SessionTranscriptExtractor.ExtractTurn( + _state.History, + transcriptStart, + 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 IReadOnlyList SnapshotCurrentTurnUserMessages( + SerializableChatMessage? fallback) + { + if (_currentTurnUserMessages.Count > 0) + return _currentTurnUserMessages.ToArray(); + + return fallback is null + ? Array.Empty() + : [fallback]; + } + private void DrainBufferedMessagesOrBecomeReady() { if (_restartDrainRequested) @@ -2171,13 +2298,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 @@ -2188,6 +2309,54 @@ private void DrainBufferedMessagesOrBecomeReady() TransitionTo(SessionPhase.Ready); } + 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; + _state = _state.AddUserMessage(buffered.Content, refs); + _currentTurnUserMessages.Add(_state.History[^1]); + } + + _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) { if (_deliveryRetry.EligibleTurnNumber != msg.TurnNumber) @@ -2317,6 +2486,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()), @@ -2349,6 +2520,7 @@ private void ContinueIncomingUserMessage(SendUserMessage cmd) _observerActor?.Tell(cmd); _turnState.ResetForNewTurn(); + _currentTurnUserMessages.Clear(); _discoveredToolCache.PrepareForNewTurn( _config.Tuning.DiscoveredToolRetentionTurns, _config.Tuning.DiscoveredToolMaxCount, @@ -2358,6 +2530,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; @@ -2460,6 +2633,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 +2667,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 @@ -3141,6 +3320,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(); @@ -3243,6 +3423,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(); @@ -3269,8 +3450,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 +3477,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 +3514,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 +3528,20 @@ 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(), + UserMessages = SnapshotCurrentTurnUserMessages(userMsg), + 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 +3560,13 @@ 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(); + _currentTurnUserMessages.Clear(); EmitOutput(new TextOutput(msg.Result.Output) { @@ -3481,15 +3692,20 @@ 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); + _currentTurnUserMessages.Clear(); return; } - _state = _state.Apply(evt); + _state = _state.Apply(evt) + .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages)); + _currentTurnUserMessages.Clear(); } private void ApplyToolBatchStarted(ToolBatchStarted evt) @@ -3500,8 +3716,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) }; @@ -3519,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; @@ -3716,6 +3939,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) @@ -3733,6 +3958,9 @@ private void EmitResponseOutputs( SessionId = _sessionId, CallId = new ToolCallId(toolCall.CallId), 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 @@ -3774,10 +4002,25 @@ 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); + 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 +4035,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 +4047,8 @@ private void EmitUsageOutput(UsageDetails usage) ContextWindowTokens = contextWindow, UsagePercent = usagePercent, PromptMs = promptMs, - PredictedPerSecond = predictedPerSec, - }, OutputFilter.Usage); + PredictedPerSecond = predictedPerSec + }; } /// @@ -4125,6 +4369,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; @@ -4132,6 +4377,7 @@ private async Task HandleProcessingApprovalResponseAsync(ToolInteractionResponse PersistApprovalResolved(msg, decision, () => { + EmitApprovalOutcome(pending, msg); approvalWait.Complete(decision); TryReplyAck(); }); @@ -4228,6 +4474,7 @@ private async Task HandleToolInteractionResponseWhenIdle(ToolInteractionResponse PersistApprovalResolved(msg, decision, () => { + EmitApprovalOutcome(pending, msg); var outcome = TryRedriveToolBatchAfterApproval(callId); if (outcome == ApprovalRedriveOutcome.Failed) { @@ -4497,42 +4744,130 @@ 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, + UserMessages = SnapshotCurrentTurnUserMessages(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); + + Persist(turnEvent, evt => + { + var processed = _state.ProcessedReminderIds; + if (evt.SourceReminderId is { } reminderId && !string.IsNullOrEmpty(reminderId.Value)) + processed = processed.Add(reminderId); - DrainBufferedMessagesOrBecomeReady(); + _state = (_state with { ProcessedReminderIds = processed }) + .AppendTranscript(evt) + .KeepRecentTranscriptTurns(Math.Max(1, _config.Tuning.KeepRecentMessages)) + .CompleteTurnBackgroundJobBookkeeping(evt.SourceBackgroundJobId); + _settledTurnEntries.Clear(); + _transcriptToolCalls.Clear(); + _currentTurnUserMessages.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 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, @@ -4662,6 +4997,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 +5036,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, @@ -4725,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( @@ -4809,14 +5149,12 @@ 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(); } + if (StopToolsAfterInvalidRationale(_activeToolBatch.InvalidRationaleCount, resultCount)) + return; + switch (budgetStatus) { case ToolBudgetStatus.Exhausted exhausted: @@ -4849,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) @@ -4905,8 +5262,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..32ffb8a71 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; @@ -37,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 { @@ -257,6 +259,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 +312,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); @@ -392,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) @@ -425,6 +438,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 @@ -438,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; @@ -455,8 +470,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 +510,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 +533,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 +554,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 +697,7 @@ private async Task ExecuteSingleToolAsync( } resultText = await ExecuteToolAttemptAsync( - _executor, tc, context, timeout, _timeProvider, batch.CancellationToken); + _executor, originalToolCall, context, timeout, _timeProvider, EmitToolActivity, batch.CancellationToken); sw.Stop(); } @@ -788,7 +837,7 @@ ToolAgentCorrection.SessionScratchSuggested scratchCorrection } resultText = await ExecuteToolAttemptAsync( - _executor, tc, context, timeout, _timeProvider, batch.CancellationToken); + _executor, originalToolCall, context, timeout, _timeProvider, EmitToolActivity, batch.CancellationToken); sw.Stop(); } @@ -899,6 +948,7 @@ private static async Task ExecuteToolAttemptAsync( ToolExecutionContext context, TimeSpan timeout, TimeProvider timeProvider, + Action emitActivity, CancellationToken cancellationToken) { var grantedOneTimeToolName = context.Approval.OneTimeApprovedToolName; @@ -919,7 +969,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 +979,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 +1010,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/Pipelines/ToolCallMetaExtractor.cs b/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs index d9fa4d64c..f8131ce5b 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/ToolCallMetaExtractor.cs @@ -15,6 +15,13 @@ namespace Netclaw.Actors.Sessions.Pipelines; /// internal static class ToolCallMetaExtractor { + 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 @@ -91,4 +98,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/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/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 0d1fc0dbe..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; } @@ -49,6 +56,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; @@ -66,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/SessionProtocol.Outputs.cs b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs index 8fa0e4612..60dad4b3a 100644 --- a/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs +++ b/src/Netclaw.Actors/Sessions/SessionProtocol.Outputs.cs @@ -70,6 +70,18 @@ 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; + + /// 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. @@ -88,6 +100,26 @@ 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; } + } + + /// + /// 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; } } /// @@ -253,6 +285,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; } @@ -308,6 +352,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 . @@ -443,6 +518,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/SessionState.cs b/src/Netclaw.Actors/Sessions/SessionState.cs index d45a27eaa..430503dd2 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,14 +106,61 @@ 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 + 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); } + 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( + userMessages.Append(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 +482,7 @@ public SessionSnapshot ToSnapshot() return new SessionSnapshot { History = new List(History), + RecentTranscript = [.. RecentTranscript], TurnCount = TurnCount, Title = Title, WorkingContext = WorkingContext.IsEmpty ? null : WorkingContext, @@ -489,9 +540,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..bfeafcada --- /dev/null +++ b/src/Netclaw.Actors/Sessions/SessionTranscriptEntryFactory.cs @@ -0,0 +1,107 @@ +// ----------------------------------------------------------------------- +// +// 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, + Rationale = call?.Rationale, + 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, + 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..f11759d5a --- /dev/null +++ b/src/Netclaw.Actors/Sessions/SessionTranscriptExtractor.cs @@ -0,0 +1,120 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; +using Netclaw.Tools; + +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, + Rationale = ToolCallMeta.Parse(call?.MetaJson)?.Rationale, + 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/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/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.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. 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/Cli/DaemonClientSessionTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs index 43bd3aa70..ddcad967f 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientSessionTests.cs @@ -9,9 +9,13 @@ 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 Netclaw.Tools; using R3; using Xunit; using static Netclaw.Actors.Sessions.SessionProtocol; @@ -54,6 +58,84 @@ 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 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() { @@ -84,6 +166,248 @@ 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); + } + + [Fact] + public async Task ChatViewModel_keeps_prompts_until_the_agent_pulls_each_identity() + { + 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; + viewModel.StatusMessage.Value = "Generating..."; + + await Task.WhenAll( + 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, "SendMessageWithId", StringComparison.Ordinal)) + .Select(invocation => ( + Id: Assert.IsType(invocation.Args[1]), + Text: Assert.IsType(invocation.Args[2]))) + .ToList(); + 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); + + transport.PushOutput(SessionOutputDtoMapper.ToDto(new TurnCompleted + { + SessionId = new SessionId("fake/session"), + TimestampMs = 1, + TurnNumber = new TurnNumber(1), + Outcome = TurnOutcome.Completed + })); + + Assert.Equal(3, viewModel.QueuedTurnMessageCount.Value); + Assert.True(viewModel.IsGenerating.Value); + Assert.Equal(3, transport.Invocations.Count(invocation => + 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] + 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); + } + + [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, + 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"), + 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) @@ -110,11 +434,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 +454,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/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.Tests/Tui/ChatPresentationReducerTests.cs b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs new file mode 100644 index 000000000..bb71b271f --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/ChatPresentationReducerTests.cs @@ -0,0 +1,510 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +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; + +namespace Netclaw.Cli.Tests.Tui; + +public sealed class ChatPresentationReducerTests +{ + private static readonly SessionId SessionId = new("test/chat"); + + [Fact] + public void Parallel_tool_results_keep_stable_rows_until_the_turn_settles() + { + 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.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.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] + 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)); + 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 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() + { + 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 passage = Assert.Single(state.ReplyPassages); + Assert.Equal(["call-a", "call-b"], passage.ToolCallIds); + Assert.Empty(state.Transcript); + } + + [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.True(state.SubAgents.ContainsKey("run-a")); + Assert.Equal("completed", state.SubAgents["run-a"].Phase); + Assert.True(state.SubAgents.ContainsKey("run-b")); + Assert.Empty(state.Transcript); + } + + [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, 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 = 4, + CallId = new ToolCallId(firstCallId), + ToolName = new ToolName("shell_execute"), + ParentCallId = "parent-a", + SelectedKey = new ApprovalOptionKey(ApprovalOptionKeys.Deny) + }); + + 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); + Assert.Contains("denied", decision.Summary, StringComparison.Ordinal); + } + + [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 + }); + + Assert.Empty(state.Transcript); + Assert.Equal("short reason", state.ThoughtText); + } + + [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", + Rationale = "Check service health", + 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)); + Assert.Contains(state.Transcript, block => + block.Kind == ChatBlockKind.Tool && block.Summary.Contains("Check service health", 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() + { + 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.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] + 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 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, + 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; + + 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 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, + 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, + 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..fab1972bf --- /dev/null +++ b/src/Netclaw.Cli.Tests/Tui/InlineChatPageTests.cs @@ -0,0 +1,1398 @@ +// ----------------------------------------------------------------------- +// +// 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); + } + + [Theory] + [InlineData(40)] + [InlineData(60)] + [InlineData(80)] + [InlineData(120)] + public async Task ModifiedEnterUnavailable_OmitsTheUnavailableShortcut(int width) + { + await using var harness = CreateHarness(width: width); + var runTask = harness.StartAsync(); + + harness.Events.Enqueue(new TerminalInputCapabilitiesChanged( + new TerminalInputCapabilities( + TerminalCapabilityAvailability.Unavailable, + TerminalInputCapabilitySource.LegacyTerminal))); + + await harness.WaitUntilAsync(() => harness.Terminal.Contains("Enter send")); + Assert.DoesNotContain("Shift+Enter", harness.Terminal.ToString(), StringComparison.Ordinal); + 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 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() + { + 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("Approval required")); + + 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("MESSAGE")); + + 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("Approval required")); + + 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("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); + + harness.Input.EnqueueKey(ConsoleKey.Escape); + + Assert.Equal(ApprovalOptionKeys.Deny, + await harness.ViewModel.ReadApprovalAsync(harness.Cancellation.Token)); + 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() + { + 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("Approval required")); + + harness.Input.EnqueueKey(ConsoleKey.O, control: true); + var screen = string.Empty; + await harness.WaitUntilAsync(() => + { + screen = harness.Terminal.ToString(); + 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) + && screen.Contains("third-party context", StringComparison.Ordinal); + }); + + 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); + } + + [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("Approval required")); + + 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_ShowsEveryQueuedPromptInOrder() + { + await using var harness = CreateHarness(); + var runTask = harness.StartAsync(); + 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("Thinking") + && harness.Terminal.Contains("MESSAGE") + && harness.Focus.CurrentFocus is not null); + + 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); + } + + 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 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) + < 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); + } + + [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 + }); + + await harness.WaitUntilAsync(() => + harness.Terminal.Contains("stream line 30") + && harness.Terminal.Contains("MESSAGE")); + 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() + { + 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() + { + 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.Equal(string.Empty, harness.Terminal.GetLine(2)); + AssertHasNoDecorativeTrim(harness.Terminal.ToString()); + 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() + { + var outputs = new SessionOutput[] + { + new SessionJoined + { + SessionId = SessionId, + TimestampMs = 0, + TurnCount = 0 + }, + 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("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); + } + + [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 requests permission to run 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 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() + { + 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() + { + 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("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("INSPECTOR")); + harness.Input.EnqueueKey(ConsoleKey.End); + await harness.WaitUntilAsync(() => harness.Terminal.Contains("complete hidden line")); + + 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); + AssertHasNoDecorativeTrim(harness.Terminal.ToString()); + await harness.StopAsync(runTask); + } + + [Fact] + public async Task Inspector_KeepsItsHeaderAndFooterInTheSameFrame() + { + 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); + var screen = string.Empty; + await harness.WaitUntilAsync(() => + { + screen = harness.Terminal.ToString(); + return screen.Contains("INSPECTOR", StringComparison.Ordinal) + && screen.Contains("Up/Down event", 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); + } + + [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("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); + } + + [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 + }); + 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); + + 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("Completed work")); + 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("MESSAGE")); + + var screen = harness.Terminal.ToString(); + 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); + } + + [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("connected")); + + Assert.Contains("NETCLAW", harness.Terminal.ToString(), StringComparison.Ordinal); + Assert.Contains("connected", harness.Terminal.ToString(), 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), + Rationale = $"Inspect {callId}", + 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, + 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 Dictionary _messageIds = new(StringComparer.Ordinal); + 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 (_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); + } + + public override Task SubmitAsync(string text) + { + _submissions.Writer.TryWrite(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); + 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/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/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/Program.cs b/src/Netclaw.Cli/Program.cs index 65ed9dd91..a78e71fd8 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)) + { + 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 devices = await devApi.ListPairedDevicesAsync(); - if (devices.Count == 0) + 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,59 @@ static async Task RunAsync(string[] args) return; } - using var app = webBuilder.Build(); - await RunTerminaHostAsync(app); + using (var app = webBuilder.Build()) + { + if (mode == "chat") + await RunChatHostAsync(app); + else + 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 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) +{ + termina.ConfigureRuntime(TerminalRuntimeProfiles.ConfigureFullScreenSelection); +} + +static void ConfigureInlineChat(TerminaBuilder termina) +{ + termina.ConfigureRuntime(TerminalRuntimeProfiles.ConfigureInlineChat); } static void WriteCrashLog(Exception ex) @@ -1386,6 +1426,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 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"); + 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/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; + } + } +} 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..d2a20f981 --- /dev/null +++ b/src/Netclaw.Cli/Tui/ChatPresentation.cs @@ -0,0 +1,1160 @@ +// ----------------------------------------------------------------------- +// +// 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, + Parallel, + 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? Rationale, + string? ArgumentsJson, + string Phase, + string? Summary, + long StartedAtMs, + string? TurnId, + string BatchId, + int BatchSize, + int PassageIndex, + string? Result, + long? CompletedAtMs, + string? FailureCode); + +internal sealed record ReplyPassagePresentation( + int Index, + long StartedAtMs, + string Text, + 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, + string AgentName, + string Phase, + string? Summary, + long StartedAtMs, + string? ActiveToolName, + long? CompletedAtMs, + string? Outcome, + string? Detail, + bool IsFailure); + +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 ImmutableHashSet CommittedToolBatches { get; init; } = + ImmutableHashSet.Empty.WithComparer(StringComparer.Ordinal); + + public ImmutableQueue PendingApprovals { get; init; } = + ImmutableQueue.Empty; + + public ImmutableList CompletedApprovals { get; init; } = []; + + public ImmutableList ReplyPassages { get; init; } = []; + + public ImmutableList AgentPulls { get; init; } = []; + + public string ThoughtText { get; init; } = string.Empty; + + public int TurnNumber { get; init; } = 1; + + 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 => + 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 +{ + 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 => AppendAssistantDelta(state, textDelta), + TextOutput text => FinalizeAssistantPassage(state, text), + ThinkingDeltaOutput thoughtDelta => state with + { + ThoughtText = state.ThoughtText + thoughtDelta.Delta + }, + ThinkingOutput thought => FinalizeThought(state, thought), + ToolCallOutput toolCall => StartTool(state, toolCall), + ToolActivityOutput activity => UpdateTool(state, activity), + 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), + 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), + BufferFlush => FinalizeAssistantPassage(state, null), + _ => 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 TextOutput or ThinkingDeltaOutput or ThinkingOutput + or ToolCallOutput or ToolActivityOutput or ToolResultOutput + or ProcessingStateOutput or UserMessageQueuedOutput or UserMessagesPulledOutput) + { + 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; + } + + state = state with { SessionTitle = joined.Title }; + + 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, + 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 }) + { + 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 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, + 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 AppendAssistantDelta( + ChatPresentationState state, + TextDeltaOutput output) + { + 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 FinalizeAssistantPassage( + ChatPresentationState state, + TextOutput? output) + { + 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; + + return state with + { + ReplyPassages = passages.SetItem(passages.Count - 1, passage with + { + Text = finalText, + IsFinal = true + }) + }; + } + + private static ChatPresentationState FinalizeThought( + ChatPresentationState state, + ThinkingOutput output) + { + var text = string.IsNullOrEmpty(output.Text) ? state.ThoughtText : output.Text; + 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) + { + var passages = EnsureToolPassage(state, output.TimestampMs); + var passage = passages[^1]; + + var tool = new ToolActivityPresentation( + output.CallId.Value, + output.ToolName.Value, + output.Rationale, + output.ArgumentsJson, + output.FailureCode is null ? "queued" : "rejected", + null, + output.TimestampMs, + state.CurrentTurnId, + output.BatchId, + output.BatchSize, + passage.Index, + null, + null, + output.FailureCode); + 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) + { + var key = output.CallId.Value; + var existing = state.Tools.TryGetValue(key, out var tool) + ? tool + : new ToolActivityPresentation( + key, + output.ToolName.Value, + null, + null, + output.Phase, + output.Summary, + output.TimestampMs, + output.TurnId.Value, + string.Empty, + 1, + state.ReplyPassages.Count == 0 ? 0 : state.ReplyPassages[^1].Index, + null, + null, + null); + 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) + { + var key = output.CallId.Value; + var passages = state.ReplyPassages; + if (!state.Tools.TryGetValue(key, out var active)) + { + passages = EnsureToolPassage(state, output.TimestampMs); + var passage = passages[^1]; + active = new ToolActivityPresentation( + key, + output.ToolName.Value, + null, + null, + output.FailureCode is null ? "completed" : "rejected", + null, + output.TimestampMs, + state.CurrentTurnId, + string.Empty, + 1, + passage.Index, + output.Result, + output.TimestampMs, + output.FailureCode); + passage = passage with { ToolCallIds = passage.ToolCallIds.Add(key) }; + passages = passages.SetItem(passages.Count - 1, passage); + } + else + { + active = active with + { + Phase = output.FailureCode is null ? "completed" : "rejected", + Result = output.Result, + CompletedAtMs = output.TimestampMs, + FailureCode = output.FailureCode + }; + } + + return state with + { + ReplyPassages = passages, + Tools = state.Tools.SetItem(key, active) + }; + } + + private static ChatPresentationState ReduceSubAgent( + ChatPresentationState state, + SubAgentOutput output) + { + 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, + 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; + return state with + { + SubAgents = state.SubAgents.SetItem(key, current with + { + Phase = output.ActivityPhase ?? output.Phase.ToString().ToLowerInvariant(), + Summary = output.ActivitySummary, + ActiveToolName = activeToolName + }) + }; + } + + 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 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) + { + 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, + 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()); + 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 = state with + { + PendingApprovals = remaining, + CompletedApprovals = state.CompletedApprovals.Add(block) + }; + effects.Add(new ChatPresentationEffect.SetStatus( + remaining.IsEmpty ? "Generating..." : "Approval required")); + effects.Add(new ChatPresentationEffect.RefreshLiveRegion()); + return state; + } + + private static ChatPresentationState CompleteTurn( + ChatPresentationState state, + TurnCompleted completed, + List effects) + { + 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, BuildSettledReply(state, completed, incompleteTools), effects); + } + + 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", + $"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(), + ReplyPassages = [], + ThoughtText = string.Empty, + PendingApprovals = ImmutableQueue.Empty, + CompletedApprovals = [], + AgentPulls = [], + 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 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 => $"{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 + { + 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 pulledMessageCount = state.AgentPulls.Sum(pull => pull.Messages.Count); + var pullReceipt = CountLabel(pulledMessageCount, "follow-up", "follow-ups"); + var receiptParts = new[] { toolReceipt, rejectedToolReceipt, agentReceipt, approvalReceipt, pullReceipt } + .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))}"); + } + 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) + .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.Count > 0 + ? state.CompletedApprovals.Min(approval => approval.TimestampMs) + : state.AgentPulls.Min(pull => pull.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)}\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, + 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.Approval => ChatBlockKind.Approval, + 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 => $"{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}", + 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.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"}" + + (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}"), + 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.Parallel => "PARALLEL", + ChatBlockKind.SubAgent => "AGENT", + ChatBlockKind.Approval => "APPROVAL", + ChatBlockKind.File => "FILE", + ChatBlockKind.Error => "ERROR", + ChatBlockKind.Usage => "USAGE", + ChatBlockKind.Compaction => "CONTEXT", + _ => "DIAGNOSTIC" + }; + + private static string ToolWorkTitle(string? rationale) => string.IsNullOrWhiteSpace(rationale) + ? "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, + 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 a32a641a1..399aa8a02 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; @@ -36,8 +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 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 @@ -47,6 +53,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 @@ -55,7 +62,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 = []; @@ -65,6 +72,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 @@ -129,20 +137,49 @@ 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; + 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 = false; + IsGenerating.Value = GetQueuedTurnMessageCount() > 0; break; } @@ -156,11 +193,14 @@ protected virtual Task InitializeSessionAsync() or DaemonConnectionState.Reconnecting or DaemonConnectionState.TransportClosed) { - _sessionReady = false; - IsGenerating.Value = false; + SetSessionReady(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 + && !IsSessionReady()) { _ = EnsureSessionAndFlushAsync(); } @@ -180,7 +220,17 @@ or DaemonConnectionState.Reconnecting /// /// Submit user text to the session pipeline. /// - public 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; @@ -191,32 +241,35 @@ public async Task SubmitAsync(string text) return; } - if (!_sessionReady || !_daemonClient.IsConnected) + var isActiveTurnPrompt = IsGenerating.Value; + if (isActiveTurnPrompt) + AddQueuedTurnMessage(messageId); + + var pendingMessage = new PendingUserMessage(text, messageId); + if (TryEnqueuePendingMessageWhenUnavailable(pendingMessage, 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; } - IsGenerating.Value = true; - StatusMessage.Value = "Generating..."; + if (!isActiveTurnPrompt) + { + IsGenerating.Value = true; + StatusMessage.Value = "Generating..."; + } try { - await _daemonClient.EnsureSessionAsync(DaemonClient.TuiChannelType); - - await _daemonClient.SendAsync(text); + await SendUserMessageAsync(pendingMessage); } catch (Exception ex) { - IsGenerating.Value = false; - _sessionReady = false; IsInputEnabled.Value = true; - _pendingMessages.Enqueue(text); + SetNotReadyAndEnqueuePendingMessage(pendingMessage); StatusMessage.Value = $"Send failed ({ex.Message}). Reconnecting..."; RequestRedraw(); _ = ConnectUntilReadyAsync(); @@ -228,9 +281,58 @@ public virtual void RequestAppShutdown() Shutdown(); } + private void AddQueuedTurnMessage(string? messageId) + { + 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 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() + { + lock (_queuedTurnMessagesGate) + return _legacyQueuedTurnMessageCount + _queuedTurnMessageIds.Count; + } + private async Task SubmitInteractionResponseAsync(string text) { - if (!_sessionReady || !_daemonClient.IsConnected) + if (!IsSessionReady() || !_daemonClient.IsConnected) { StatusMessage.Value = "Approval required. Reconnecting..."; RequestRedraw(); @@ -249,37 +351,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; @@ -297,10 +391,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; @@ -389,13 +497,21 @@ public void ToggleApprovalDetail() internal void SeedPendingInteractionForTesting(ToolInteractionRequest interaction) { _outputSubject.OnNext(interaction); - _pendingInteractions.Enqueue(interaction); + EnqueuePendingInteraction(interaction); RefreshApprovalOptions(); IsGenerating.Value = false; StatusMessage.Value = "Approval required"; 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 @@ -469,6 +585,7 @@ public override void Dispose() SessionIdDisplay.Dispose(); UsageDisplay.Dispose(); UiVersion.Dispose(); + QueuedTurnMessageCount.Dispose(); IsApprovalDetailVisible.Dispose(); base.Dispose(); } @@ -483,7 +600,7 @@ private async Task ConnectUntilReadyAsync() TimeSpan.FromSeconds(10) }; - while (!_sessionReady) + while (!IsSessionReady()) { try { @@ -504,45 +621,135 @@ 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) + { + PendingUserMessage? pending; + lock (_pendingMessagesGate) + { + pending = _pendingMessages.Count == 0 + ? null + : _pendingMessages.Peek(); + } + + if (pending is not null) + { + await SendUserMessageAsync(pending); + lock (_pendingMessagesGate) + { + if (_pendingMessages.Count == 0 + || _pendingMessages.Peek() != pending) + { + 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(PendingUserMessage message, out int pendingCount) + { + lock (_pendingMessagesGate) + { + if (_sessionReady && _daemonClient.IsConnected) + { + pendingCount = _pendingMessages.Count; + return false; + } + + _pendingMessages.Enqueue(message); + pendingCount = _pendingMessages.Count; + return true; + } + } + + private void SetNotReadyAndEnqueuePendingMessage(PendingUserMessage message) + { + lock (_pendingMessagesGate) + { + _sessionReady = false; + _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) @@ -551,7 +758,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(); @@ -564,29 +771,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) { - _sessionReady = false; + _submittedInteractionCallId = null; + _isSubmittingInteraction = false; + SetSessionReady(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 new file mode 100644 index 000000000..155740546 --- /dev/null +++ b/src/Netclaw.Cli/Tui/InlineChatPage.cs @@ -0,0 +1,1707 @@ +// ----------------------------------------------------------------------- +// +// 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; +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 const int MaximumReadableWidth = 120; + + 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 ScrollableContainerNode? _assistantStream; + private CopyableTextNode? _inspectorCopyNode; + private ScrollableContainerNode? _inspectorDetail; + private string? _approvalCallId; + private string? _approvalDetailCallId; + private string? _inspectorBlockKey; + 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 = []; + private readonly List _queuedPromptDisplays = []; + private readonly HashSet _unseenAssistantEvents = new(StringComparer.Ordinal); + private long? _lastEscapeTimestamp; + private int _inspectorIndex; + private bool _inspectorOpen; + private TerminalCapabilityAvailability _modifiedEnterKeySupport = + TerminalCapabilityAvailability.Unknown; + + 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...") + .WithForeground(ChatVisualTheme.Text) + .WithBackground(ChatVisualTheme.SurfaceStrong) + .WithMaxHeight(8) + .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)) + .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(_ => + { + if (ShowsComposer(_state)) + Focus.SetFocus(_promptInput); + _liveRegion.Invalidate(); + }) + .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(_ => _liveRegion.Invalidate()) + .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(HandleAssistantMouseScroll) + .DisposeWith(Subscriptions); + ViewModel.Input.OfType() + .Subscribe(capabilities => + { + _modifiedEnterKeySupport = capabilities.Capabilities.ModifiedEnterKeySupport; + _liveRegion.Invalidate(); + }) + .DisposeWith(Subscriptions); + } + + public override ILayoutNode BuildLayout() => _liveRegion; + + internal int ApprovalDetailScrollOffset => _approvalDetail?.ScrollOffset ?? 0; + + 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 + && 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 (_assistantStream is not null && _state.PendingApproval is null) + { + if (keyInfo.Key == ConsoleKey.PageUp && _assistantStream.CanScrollUp) + { + _assistantStream.PageUp(); + _assistantTailPaused = true; + _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(); + _assistantTailPaused = false; + _unseenAssistantEvents.Clear(); + _liveRegion.Invalidate(); + 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; + var messageId = ChatViewModel.CreateUserMessageId(); + if (ViewModel.IsGenerating.Value) + { + _queuedPromptDisplays.Add(new QueuedPromptDisplay(messageId, text, false)); + _liveRegion.Invalidate(); + _ = ViewModel.SubmitAsync(text, messageId); + return; + } + + ApplyReduction(ChatPresentationReducer.RecordUserPrompt( + _state, + text, + _timeProvider.GetUtcNow().ToUnixTimeMilliseconds())); + _ = 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) + { + var offset = _assistantStream.ScrollOffset; + scrollable.ScrollUp(3); + if (_assistantStream.ScrollOffset != offset) + _assistantTailPaused = true; + } + else if (mouseScroll.Delta < 0) + scrollable.ScrollDown(3); + else + return; + + ResumeAssistantTailIfAtBottom(); + _liveRegion.Invalidate(); + } + + private void ApplyMessageLifecycle(SessionOutput output) + { + switch (output) + { + 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; + } + } + } + + private void ApplyOutput(SessionOutput output) + { + TrackUnseenAssistantEvent(output); + ApplyMessageLifecycle(output); + ApplyReduction(ChatPresentationReducer.Reduce(_state, output)); + } + + private void TrackUnseenAssistantEvent(SessionOutput output) + { + if (output is TurnCompleted) + { + _assistantTailPaused = false; + _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() => _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) + { + 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) + { + switch (effect) + { + case ChatPresentationEffect.Commit commit: + if (!ShowsInPrimaryTranscript(commit.Block)) + break; + 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); + var approvalHeadChanged = !string.Equals( + priorApprovalCallId, + _state.PendingApproval?.CallId.Value, + StringComparison.Ordinal); + if (hadApproval != hasApproval || hadComposer != hasComposer || approvalHeadChanged) + { + 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(); + if (_state.Transcript.Count > 0) + content.WithChild(Layouts.Empty().Height(1)); + 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) + 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)); + else if (ShowsComposer(_state)) + content.WithChild(BuildComposer()); + + content.WithChild(BuildStatusLine()); + return WithViewportMargin(content); + } + + private ILayoutNode BuildInspector() + { + var block = _state.Transcript[_inspectorIndex]; + 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) + { + _inspectorBlockKey = block.Key; + _inspectorRenderWidth = detailWidth; + _inspectorDetail ??= new ScrollableContainerNode() + .WithAutoScroll(AutoScrollPolicy.None) + .WithScrollbar(false); + 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); + _inspectorDetail.WithContent(_inspectorCopyNode); + _inspectorDetail.ScrollToTop(); + } + + 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) + ? 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.Max(6, _terminal.Height - 3); + 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 + ? "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); + var inspector = Layouts.Vertical() + .WithChild(inspectorHeader) + .WithChild(inspectorBody) + .WithChild(new TextNode(help).WithForeground(ChatVisualTheme.Muted)) + .Width(ReadableWidth()); + return WithViewportMargin(inspector); + } + + 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)); + } + + 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) + { + var prefix = $"{label}\n"; + return text.StartsWith(prefix, StringComparison.Ordinal) + ? text[prefix.Length..] + : 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) + { + 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 connectionPart = _state.HasJoined ? "connected" : "connecting"; + var sessionPart = !_state.HasJoined + ? "connecting" + : _state.SessionTitle ?? "new session"; + var modelPart = _terminal.Width >= 100 ? $" {ViewModel.ModelId}" : string.Empty; + var contextPart = _terminal.Width >= 110 && _state.ContextUsagePercent is { } usage + ? $" {Math.Round(usage * 100, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture)}%" + : string.Empty; + var daemonPart = _terminal.Width >= 76 + ? $" {connectionPart}" + : string.Empty; + 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 BuildQueueShelf() + { + if (_queuedPromptDisplays.Count == 0) + return Layouts.Empty(); + + var count = _queuedPromptDisplays.Count; + var label = count == 1 ? "1 message" : $"{count} messages"; + var content = Layouts.Vertical() + .WithChild(new TextNode($"QUEUED {label}") + .WithForeground(ChatVisualTheme.Muted) + .Bold()); + var index = 1; + foreach (var prompt in _queuedPromptDisplays) + { + var preview = ChatPresentationRenderer.OneLine( + 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++; + } + + return new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.Surface) + .WithPadding(1) + .WithContent(content) + .Width(ReadableWidth()) + .Height(count + 3); + } + + 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(); + + 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)) + { + reply.WithChild(new TextNode(ChatPresentationRenderer.OneLine( + $"Reasoning {_state.ThoughtText}", + lineWidth)) + .WithForeground(ChatVisualTheme.Muted)); + } + + var agents = _state.SubAgents.Values + .OrderBy(value => value.StartedAtMs) + .ThenBy(value => value.RunId, StringComparer.Ordinal) + .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)) + { + reply.WithChild(new TextNode(ChatPresentationRenderer.MarkdownToPlainText(passage.Text)) + .WithForeground(ChatVisualTheme.Text)); + } + + 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)) + { + 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 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 + .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))) + 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)); + 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() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.Surface) + .WithPadding(1) + .WithContent(_assistantStream.HeightAuto( + min: 1, + max: Math.Max(1, maximumHeight - 2))) + .Width(ReadableWidth()) + .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, + ISet renderedAgents, + int lineWidth) + { + var childRuns = agents.Where(value => value.ParentCallId == tool.CallId).ToList(); + 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 = isCurrentApproval + ? "Decision" + : waitsForApproval + ? "Waiting" + : ActivityState(tool.Phase); + if (string.Equals(state, "Live", StringComparison.Ordinal)) + state = $"Live{new string('.', _thinkingFrame + 1)}"; + var phaseText = !isCurrentApproval + && !waitsForApproval + && string.Equals(phase, tool.Phase, StringComparison.OrdinalIgnoreCase) + ? string.Empty + : $" {phase}"; + var summary = string.IsNullOrWhiteSpace(tool.Summary) ? string.Empty : $" {tool.Summary}"; + var action = ChatPresentationReducer.ToolWorkTitle(tool); + var rows = Layouts.Vertical() + .WithChild(new TextNode(ChatPresentationRenderer.OneLine( + $"{state,-8} {action} · {tool.ToolName}{phaseText}{summary}", + lineWidth)) + .WithForeground(isCurrentApproval + ? ChatVisualTheme.Warning + : waitsForApproval + ? ChatVisualTheme.Muted + : ActivityColor(tool.Phase))); + foreach (var run in childRuns) + { + rows.WithChild(BuildAgentActivity(run, lineWidth)); + renderedAgents.Add(run.RunId); + } + + return rows; + } + + private ILayoutNode BuildComposer() + { + var content = Layouts.Vertical() + .WithChild(new TextNode("MESSAGE").WithForeground(ChatVisualTheme.Primary).Bold()) + .WithChild(_promptInput); + var composer = new PanelNode() + .WithBorder(BorderStyle.None) + .WithBackground(ChatVisualTheme.SurfaceStrong) + .WithPadding(1) + .WithContent(content) + .Width(ReadableWidth()) + .HeightAuto(min: 4, max: Math.Max(4, Math.Min(11, _terminal.Height / 3))); + return Layouts.Horizontal() + .WithChild(composer) + .WithChild(Layouts.Empty().Fill()); + } + + private ILayoutNode BuildDecisionGate(ToolInteractionRequest approval) + { + 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 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{queuePosition} {ChatPresentationRenderer.ApprovalPath(_state, approval)}") + .WithForeground(ChatVisualTheme.Warning) + .Bold()) + .Height(1); + var gate = Layouts.Vertical().WithChild(header); + if (ViewModel.IsApprovalDetailVisible.Value) + { + gate.WithChild(EnsureApprovalDetail(approval) + .Height(detailHeight)); + } + else + { + gate.WithChild(new TextNode(ChatPresentationRenderer.OneLine( + approval.DisplayText, + Math.Max(20, width - 4))) + .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(ChatVisualTheme.Muted)) + .WithChild(EnsureApprovalList()); + + var panel = new PanelNode() + .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))); + return Layouts.Horizontal() + .WithChild(panel) + .WithChild(Layouts.Empty().Fill()); + } + + 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(ApprovalOptionLabel).ToList()) + .WithMode(SelectionMode.Single) + .WithHighlightColors(Color.Black, Color.Yellow); + _approvalList.SelectionConfirmed + .Subscribe(selected => + { + if (selected.Count > 0) + { + var option = approval.Options.FirstOrDefault(candidate => + string.Equals(ApprovalOptionLabel(candidate), selected[0], StringComparison.Ordinal)); + if (option is not null) + _ = ViewModel.SubmitInteractionOptionAsync(approval.CallId, option.Label); + } + }) + .DisposeWith(_approvalSubscriptions); + return _approvalList; + } + + private void ClearApprovalList() + { + _approvalSubscriptions.Clear(); + _approvalList = null; + _approvalCallId = null; + _approvalDetail = null; + _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) + return _approvalDetail; + + _approvalDetailCallId = approval.CallId.Value; + _approvalDetail = new ScrollableContainerNode() + .WithAutoScroll(AutoScrollPolicy.None) + .WithScrollbar(false) + .WithContent(new TextNode(BuildApprovalDetail(approval)).WithForeground(ChatVisualTheme.Text)); + return _approvalDetail; + } + + private ILayoutNode BuildStatusLine() + { + var status = ViewModel.StatusMessage.Value; + var displayStatus = status switch + { + "Generating..." => $"Thinking{new string('.', _thinkingFrame + 1)}", + "Connecting..." when _terminal.Width < 48 => "Connect", + _ => status + }; + 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, + tailPaused); + 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(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() + .Fill()) + .Width(ReadableWidth()) + .Height(1); + return Layouts.Horizontal() + .WithChild(statusLine) + .WithChild(Layouts.Empty().Fill()); + } + + 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 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}{state,-8} Agent {run.AgentName} {phase}{summary}", + lineWidth)) + .WithForeground(isCurrentApproval + ? ChatVisualTheme.Warning + : waitsForApproval + ? ChatVisualTheme.Muted + : ActivityColor(run.Phase)) + }; + if (run.ActiveToolName is not null) + { + var toolPrefix = run.ParentCallId is null ? " " : " "; + rows.Add(new TextNode(ChatPresentationRenderer.OneLine( + $"{toolPrefix}{ActivityState(run.Phase),-8} Tool {run.ActiveToolName} {run.Phase}", + lineWidth)) + .WithForeground(ActivityColor(run.Phase))); + } + + 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 + { + $"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) + 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 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 approvalDetailVisible, + bool hasComposer, + TerminalCapabilityAvailability modifiedEnterKeySupport, + bool tailPaused) + { + if (hasApproval) + return width >= 88 + ? 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 + ? 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) + { + if (width >= 110) + return "Enter send Esc x2 clear Ctrl+O inspect Ctrl+Q quit"; + + return width >= 66 + ? "Enter send Esc x2 clear" + : "Enter send"; + } + + if (modifiedEnterKeySupport == TerminalCapabilityAvailability.Unknown) + { + if (width >= 110) + return "Enter send Esc x2 clear Ctrl+O inspect Ctrl+Q quit"; + + return width >= 66 + ? "Enter send Esc x2 clear" + : "Enter send"; + } + + if (width >= 110) + return "Enter send Shift+Enter newline Esc x2 clear Ctrl+O inspect Ctrl+Q quit"; + return width >= 66 + ? "Enter send Shift+Enter line Esc x2 clear Ctrl+O inspect" + : "Enter send Shift+Enter line"; + } + + private static Color ActivityColor(string phase) => phase.ToLowerInvariant() switch + { + "queued" => ChatVisualTheme.Muted, + "failed" or "error" or "denied" or "rejected" => ChatVisualTheme.Danger, + "completed" or "complete" => ChatVisualTheme.Muted, + _ => ChatVisualTheme.Primary + }; + + private static string ActivityState(string phase) => phase.ToLowerInvariant() switch + { + "queued" => "Queued", + "failed" or "error" => "Failed", + "denied" => "Denied", + "rejected" => "Rejected", + "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--) + { + 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 { } approval) + { + _lastEscapeTimestamp = null; + _ = ViewModel.DenyPendingInteractionAsync(approval.CallId); + 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" => 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) => + state.PendingApproval is null; + + private sealed record QueuedPromptDisplay(string MessageId, string Text, bool IsAccepted); +} + +internal static partial 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.Kind == ChatBlockKind.Assistant + ? 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(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()); + } + + var stableBlock = Layouts.Vertical() + .WithChild(heading) + .WithChild(content) + .WithChild(Layouts.Empty().Height(1)); + 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; + + 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 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) + { + 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)..]}"; + + 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 "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 + ? "A sub-agent" + : requester.AgentName; + } + + private static Color LabelColor(ChatPresentationBlock block) => block.Kind switch + { + 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 => 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 = 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(@"(? +// 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.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() 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; }