Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ session lifecycle message queue
| Local agent registry | `✅ done` | custom agents can be registered | keep local config registration simple | ACP Registry should complement this, not replace it yet |
| Session lifecycle | `✅ done` | navigate previous sessions | keep behavior stable while forking work continues | `load`, `list`, `resume`, lazy indexing, and `undo` are already working |
| Session continuity | `✅ done` | users can continue prior work | keep resume listing, speed, and metadata stable | context/usage metadata and `session_info_update` are finished |
| Session forking | `🔵 now` | sessions can branch and form trees | finish `fork` and tree-oriented flows | keep native ACP behavior ahead of local stand-ins |
| Session forking | `🔵 now` | sessions can branch and form trees | fork-at-message and tree-oriented flows (needs upstream ACP PR #629) | branch-at-head shipped via native `session/fork`, capability-gated (claude-code adapter supports it; codex/gemini do not advertise fork yet) |
| Image attachments | `🔵 now` | users can send visual context | capability-aware image routing and transcript fidelity | images reach ACP, but the path needs polish |
| Queued messages | `🔵 now` | users can keep typing during work | finish backend handling for queued turns | TUI support exists, but ACP backend behavior needs tightening |
| Session configuration | `✅ done` | agents can expose useful controls | custom options like thinking/effort level and plan/build modes | keep this driven by agent-provided config, not hardcoding |
Expand All @@ -45,7 +45,7 @@ This table keeps the roadmap grounded in the current ACP spec and draft surface.
| Session lifecycle | `session/load` | stable baseline | done |
| Session lifecycle | `session/list` | stable | done |
| Session lifecycle | `session/resume` | unstable landed | done |
| Session lifecycle | `session/fork` | unstable landed | todo |
| Session lifecycle | `session/fork` | unstable landed | branch-at-head done; fork-at-message blocked on upstream PR #629 |
| Session lifecycle | `session/close` | unstable landed | handled |
| Session lifecycle | `session/delete` | draft only | not planned near term |
| Session lifecycle | undo, rewind, checkpoints | not first-class ACP today | local only |
Expand Down
10 changes: 5 additions & 5 deletions nori-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion nori-rs/acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ license = { workspace = true }
workspace = true

[dependencies]
agent-client-protocol = { workspace = true }
agent-client-protocol = { workspace = true, features = ["unstable_session_fork"] }
agent-client-protocol-schema = { workspace = true, features = ["unstable"] }
anyhow = { workspace = true }
axum = { workspace = true, default-features = false, features = [
Expand Down
38 changes: 27 additions & 11 deletions nori-rs/acp/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,7 @@ This means `to_acp_mcp_servers()` has side effects (reads from keyring/file syst

The server uses a normal `http://127.0.0.1:<port>/mcp` URL because current Codex and Claude ACP adapters forward ACP MCP server entries into their underlying clients as ordinary HTTP MCP config. Avoiding `acp:` keeps startup compatible with those adapters while still keeping the tool implementation in process.

ACP session setup paths build the MCP server list in two phases: first convert configured MCP servers with `to_acp_mcp_servers()`, then let backend-owned features append local MCP servers when their own eligibility checks pass. The `nori-client` server requires HTTP MCP support. This setup applies to resumed, fresh, fallback, and compaction-created sessions. New backend construction commits the server before `session/new` or `session/load` so agents that eagerly initialize MCP during session setup can reach it. Compaction-created replacement sessions re-advertise the backend's existing `nori-client` server because the tools are backend-owned rather than ACP-session-owned; if the replacement `session/new` fails, the backend restores the previous initialized flag and keeps the existing endpoint alive. After compact replacement succeeds, the backend re-emits `SessionCapabilitiesChanged`, deriving `nori_client.advertised` from the actual MCP server list passed to that replacement session. Hook-only ACP sessions pass an empty list because hooks do not need user-configured or backend-owned MCP servers.
ACP session setup paths build the MCP server list in two phases: first convert configured MCP servers with `to_acp_mcp_servers()`, then let backend-owned features append local MCP servers when their own eligibility checks pass. The `nori-client` server requires HTTP MCP support. This setup applies to resumed, fresh, fallback, and replacement sessions (client-side compaction via `session/new` and `/fork` branching via `session/fork`, both through `replace_session()` in `backend/session_replace.rs`). New backend construction commits the server before `session/new` or `session/load` so agents that eagerly initialize MCP during session setup can reach it. Replacement sessions re-advertise the backend's existing `nori-client` server because the tools are backend-owned rather than ACP-session-owned; if the replacement request fails, the backend restores the previous initialized flag and keeps the existing endpoint alive. After replacement succeeds, the backend re-emits `SessionCapabilitiesChanged`, deriving `nori_client.advertised` from the actual MCP server list passed to that replacement session. Hook-only ACP sessions pass an empty list because hooks do not need user-configured or backend-owned MCP servers.

The local `nori-client` MCP server is intentionally additive. User-configured MCP servers are still forwarded normally, and ineligible agents simply do not receive the loopback endpoint. `SessionCapabilitiesChanged` exposes both endpoint advertisement and initialization so clients do not need to infer either state from raw HTTP MCP support. Continuation chaining depends on the local MCP server actually being initialized for the current advertised endpoint, not just on HTTP MCP support or endpoint advertisement. Durable follow-up work for this surface lives in `@/docs/followups/nori-client-mcp.md`.

Expand Down Expand Up @@ -861,7 +861,7 @@ Configuration:

- `AcpBackendConfig.cli_version`: CLI version included in session metadata
- `AcpBackendConfig.default_model`: Default model to apply at session start (from config.toml [default_models])
- `AcpBackendConfig.initial_context`: Optional string injected into `pending_compact_summary` at spawn time. Used by the TUI's `/fork` command to pass a plain-text conversation summary into a new ACP session, giving the agent prior context without a protocol-level session fork. When `None` (the default), `pending_compact_summary` starts empty as before. The same `pending_compact_summary` mechanism is shared by `/compact` and `/resume`.
- `AcpBackendConfig.initial_context`: Optional string injected into `pending_compact_summary` at spawn time. Used by the TUI's `/fork` rewind-to-a-previous-message path to pass a plain-text conversation summary into a new ACP session, giving the agent prior context without a protocol-level session fork (branching at the current point uses native `session/fork` instead -- see Session Branching below). When `None` (the default), `pending_compact_summary` starts empty as before. The same `pending_compact_summary` mechanism is shared by `/compact`'s client-side fallback and `/resume`.
- `AcpBackendConfig.session_context`: Optional string injected into `pending_hook_context` at spawn time (`spawn_and_relay.rs` / `session.rs`) only for ACP connections that do not advertise HTTP MCP support. Unlike `initial_context`, session context is prepended to the first user prompt **without** `SUMMARY_PREFIX` framing -- it appears as raw text before the user's message. If session start hooks also produce `::context::` lines, those are appended to the session context (both accumulate in the same `pending_hook_context` mutex). The context is consumed on the first prompt and not repeated. The TUI populates this with an embedded `<context>` blurb (`@/nori-rs/tui/session_context.md`) that tells non-MCP agents they are running inside Nori CLI over ACP, points to the source repo, and names MCP-backed affordances as unavailable.

**Re-exported Types:**
Expand Down Expand Up @@ -1018,15 +1018,31 @@ The simpler `translator.rs` helper functions are unrelated to ACP session transl

**Conversation Compaction:**

Unlike core's direct history manipulation, ACP uses a **prompt-based approach**:
`/compact` has two paths. The decision point is `start_prompt()` in `backend/session_reducer.rs`, at the moment the queued compact prompt starts:

1. `/compact` sends summarization prompt to agent
2. Agent's summary response is streamed to the TUI as deltas and captured in `pending_compact_summary`
3. A new ACP session is created (the old session's context is discarded)
4. The `ContextCompactedEvent` is emitted with the summary text cloned from `pending_compact_summary`, enabling the TUI to render a visual session boundary
5. Summary is prepended to the next user message (via `SUMMARY_PREFIX` framing)
**Native path (preferred):** When the agent advertises a slash command named exactly `compact` (via `available_commands_update`, persisted on the runtime's session state -- both the claude-agent-acp and codex-acp adapters advertise one), the reducer rewrites the queued prompt to `QueuedPromptKind::NativeCompact` with text `/compact` and forwards it as an ordinary `session/prompt`. The agent compacts **inside the same session**: no replacement session, no summary capture, no summary injection into the next prompt. On completion, `backend/session_runtime_driver.rs` emits `ClientEvent::ContextCompacted { summary: None }` only when the turn stopped with `EndTurn` -- a cancelled native compaction did not compact, so nothing is recorded. This intent distinction did not exist on the old path, where any streamed summary was captured regardless.

The `ContextCompactedEvent.summary` field is the coupling point between the ACP backend and the TUI's session boundary rendering. The TUI uses it to flush the streamed summary, show a "Context compacted" info message, insert a new session header, and reprint the summary as the first assistant message of the new session (see `@/nori-rs/tui/docs.md`).
**Client-side fallback:** For agents without a native `compact` command, the prompt-based approach remains:

1. `/compact` sends `codex_core::compact::SUMMARIZATION_PROMPT` to the agent
2. The agent's summary response is streamed to the TUI as deltas and captured in `pending_compact_summary`
3. A replacement ACP session is created via `replace_session(SessionReplacement::NewSession)` in `backend/session_replace.rs` (the old session's context is discarded)
4. `ContextCompactedEvent` is emitted with the summary text cloned from `pending_compact_summary`, enabling the TUI to render a visual session boundary
5. The summary is prepended to the next user message (via `SUMMARY_PREFIX` framing)

The `ContextCompactedEvent.summary` field is the coupling point between the ACP backend and the TUI's session boundary rendering. When the summary is present (fallback path), the TUI flushes the streamed summary, shows a "Context compacted" info message, inserts a new session header, and reprints the summary as the first assistant message of the new session. When the summary is `None` (native path), only the info message is shown because the session did not change (see `@/nori-rs/tui/docs.md`).

**Session Branching (`session/fork`):**

`Op::BranchSession` (from `@/nori-rs/protocol/src/protocol/mod.rs`, fired by the first entry of the TUI's `/fork` picker) forks the ACP session at its current state and swaps the runtime to the fork; the original session is preserved on the agent side and remains resumable. The flow lives in `backend/session_replace.rs`:

- The op is routed through `SessionRuntimeInput::BranchSession` on the session-runtime channel so branching serializes with turn starts and completions.
- `handle_branch_session()` rejects with a user-facing error when the agent does not advertise the `fork` session capability (`AcpConnection::supports_fork()`) or when a turn is in progress (`SessionDriver::is_idle()`).
- On success it emits `ClientEvent::SessionBranched { new_session_id }`. `AcpConnection::fork_session()` applies the fork response's `config_options` before returning, so the live config snapshot is already fresh when clients react to the event.

`replace_session(SessionReplacement::NewSession | Fork { from })` in `backend/session_replace.rs` is the shared session-replacement machinery for client-side compaction and branching: it re-assembles the MCP server list, re-registers the backend-owned `nori-client` goal MCP server when needed, swaps the live session id, and re-broadcasts `SessionCapabilitiesChanged`. It was extracted from the compact arm of `backend/session_runtime_driver.rs` when branching landed. On failure, the previous session stays active and goal-MCP state is restored.

ACP `session/fork` is at-head only; picking an earlier message in the `/fork` picker still uses the local summary-based backtrack fork (see `@/nori-rs/tui/docs.md`). Fork-at-message is tracked upstream as ACP PR #629. There is deliberately a single `/fork` command with no `/branch` or `/rewind` aliases.

**Session Resume** (`backend/mod.rs`, `connection.rs`):

Expand Down Expand Up @@ -1153,7 +1169,7 @@ Error categorization operates on the `Debug`-formatted (`{e:?}`) anyhow error to

**Retryable classification and the failure disposition:** `AcpErrorCategory::is_retryable()` returns `true` only for `ApiServerError` and `QuotaExceeded` -- transient server errors and rate/quota limits that clear on their own -- and `false` for the persistent categories a retry cannot fix. The retryable bool no longer rides on `EventMsg::Error`; `ErrorEvent` in `@/nori-rs/protocol/src/protocol/mod.rs` now carries only `message` and is display-only. Instead the disposition is threaded onto the prompt **completion** as a `nori_protocol::TurnFailure` (`Retryable`/`Fatal`) so a single ordered event decides both how the failure is surfaced and whether an unattended loop should keep going (the TUI consumes this in `@/nori-rs/tui/docs.md`, Loop Mode section).

The path: `send_prompt_error` in `backend/session_runtime_driver.rs` emits the `EventMsg::Error` for display and *returns* the retryable bool (computed as `category.is_retryable()` for user prompts; `Compact` and `GoalContinuation` failures are always non-retryable). The prompt-task error handler maps that bool to `Some(TurnFailure::Retryable | Fatal)` and sends it as `InboundEvent::PromptFailed { failure }` to the reducer (`backend/session_reducer.rs`). `reduce_prompt_failed` threads `failure` straight onto the `PromptCompleted` it pushes, while clean completions (`reduce_prompt_response`) carry `failure: None`. The two non-prompt-task producers of `PromptFailed` use fixed dispositions: the cancel watchdog's force-cancel/timeout sends `failure: None` (a clean forced cancel, not a failure), and unexpected agent process exit (`ConnectionEvent::ChildExited`) in `backend/spawn_and_relay.rs` sends `Some(TurnFailure::Fatal)`. The generic `send_error` op path in `backend/submit_and_ops.rs` only emits a display `EventMsg::Error` and is not part of the prompt-completion path. The bare `529` and underscore `rate_limit` matches above still route real overloaded/rate-limit strings into the retryable buckets; `send_prompt_error` logs the category, the resolved retryable bool, and the raw error for observability.
The path: `send_prompt_error` in `backend/session_runtime_driver.rs` emits the `EventMsg::Error` for display and *returns* the retryable bool (computed as `category.is_retryable()` for user prompts; compaction failures of either kind and `GoalContinuation` failures are always non-retryable). The prompt-task error handler maps that bool to `Some(TurnFailure::Retryable | Fatal)` and sends it as `InboundEvent::PromptFailed { failure }` to the reducer (`backend/session_reducer.rs`). `reduce_prompt_failed` threads `failure` straight onto the `PromptCompleted` it pushes, while clean completions (`reduce_prompt_response`) carry `failure: None`. The two non-prompt-task producers of `PromptFailed` use fixed dispositions: the cancel watchdog's force-cancel/timeout sends `failure: None` (a clean forced cancel, not a failure), and unexpected agent process exit (`ConnectionEvent::ChildExited`) in `backend/spawn_and_relay.rs` sends `Some(TurnFailure::Fatal)`. The generic `send_error` op path in `backend/submit_and_ops.rs` only emits a display `EventMsg::Error` and is not part of the prompt-completion path. The bare `529` and underscore `rate_limit` matches above still route real overloaded/rate-limit strings into the retryable buckets; `send_prompt_error` logs the category, the resolved retryable bool, and the raw error for observability.

### Things to Know

Expand All @@ -1163,7 +1179,7 @@ Large modules use a directory layout (`foo/mod.rs` + submodules) instead of a si

- Agent subprocess communication uses stdin/stdout with JSON-RPC 2.0 framing
- The minimum supported ACP protocol version is V1 (`MINIMUM_SUPPORTED_VERSION`); initialize is sent with `ProtocolVersion::LATEST`
- `nori-acp` no longer has an `unstable` feature. Model selection rides the `SessionConfigOptionCategory::Model` variant, which the schema exposes only behind its own `unstable` feature; `nori-acp` turns that on unconditionally (`agent-client-protocol-schema = { features = ["unstable"] }` in `Cargo.toml`). The removed `unstable` feature previously gated only the deleted `session/set_model`/`SessionModelState` model-selection API
- `nori-acp` no longer has an `unstable` feature. Model selection rides the `SessionConfigOptionCategory::Model` variant, which the schema exposes only behind its own `unstable` feature; `nori-acp` turns that on unconditionally (`agent-client-protocol-schema = { features = ["unstable"] }` in `Cargo.toml`). The removed `unstable` feature previously gated only the deleted `session/set_model`/`SessionModelState` model-selection API. Session branching additionally enables the SDK's `unstable_session_fork` feature on the `agent-client-protocol` dependency, since `session/fork` is an unstable ACP extension
- Approval requests are translated to use appropriate UI (exec approval for shell commands, patch approval for file edits)
- Config loading uses Nori-specific paths (`~/.nori/cli/config.toml`) when the `nori-config` feature is enabled in the TUI
- Transcript discovery is synchronous and intended for use in background threads (e.g., the TUI's `SystemInfo` collection thread)
Expand Down
Loading
Loading