feat(chat): richer slash menu in resumed sessions; preserve agent commands across reset

Two fixes uncovered by v2.8.0 dogfooding when clicking a previous chat
in the sidebar (vs. starting a new one):

1. **Preserve `acpCommands` across `RichChatViewModel.reset()`**.
   Hermes ACP only emits `available_commands_update` after `session/new`,
   not after `session/load`. Wiping the cached set on every session
   switch meant resumed sessions landed at a 4-command fallback even
   though the agent identity (and therefore the command list) hadn't
   changed. The new comment in `reset()` documents the rationale; the
   host-switch case still tears down the whole `ContextBoundRoot`, so
   stale carry-over isn't reachable when the agent identity does change.

2. **Expand the static fallback when a session is active**. Adds the
   agent-level command set (`/clear`, `/compact`, `/cost`, `/model`,
   `/tools`, `/reload-skills`, `/help`) to `alwaysAvailableCommands`
   when `sessionId != nil`. `/new` continues to show in both states.
   Pre-session, only `/new` surfaces — the others all require a live
   session, and surfacing them would mislead. Deduped by name against
   the ACP-advertised set so the richer (server-authoritative)
   description / argument hint wins once Hermes does emit them.

The two fixes together cover all paths to the slash menu:
- Cold start, click resume → fix #2 paints the active-session set
- Hot path, switch sessions after a `session/new` → fix #1 keeps the
  ACP-advertised set in `acpCommands`
- Cold start, click "+ New" → ACP populates as before; unchanged

Discovered during v2.8.0 dogfooding against a live Hermes v0.13.0 host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-09 20:19:50 +02:00
parent a359177e76
commit 096dbe085e
2 changed files with 114 additions and 2 deletions
@@ -24,6 +24,14 @@ public struct HermesSlashCommand: Identifiable, Sendable, Equatable {
/// "agent working" indicator on; the guidance applies after the
/// next tool call. Added in v2.5 alongside Hermes v2026.4.23.
case acpNonInterruptive
/// ACP-native commands Hermes always supports but only advertises
/// inside an active session via `available_commands_update`.
/// Surfacing a small static fallback pre-session lets the slash
/// menu offer discoverable affordances like `/new` even before
/// the user has opened a session. Once a session starts, the
/// ACP-advertised version takes over (deduped by name in
/// `availableCommands`). Added v2.8 alongside Hermes v0.13.
case alwaysAvailable
}
public var id: String { name }
@@ -290,6 +290,82 @@ public final class RichChatViewModel {
)
]
/// Static fallback commands Hermes ACP always supports but only
/// advertises via `available_commands_update` after `session/new`
/// not after `session/load`. Without this fallback, resumed sessions
/// (and "no active session" cold starts) showed an artificially
/// sparse menu. With this list, the menu is discoverable everywhere;
/// when the ACP-advertised version arrives, dedupe-by-name in
/// `availableCommands` ensures the canonical (richer description,
/// authoritative argument hint) entry wins.
///
/// The set splits on whether a session is active:
/// - **Always** (no session AND active session): `/new`. It's the
/// "open a session" affordance and arms the v0.13+ `[<name>]`
/// argument hint via `hasNewWithSessionName`.
/// - **Active-session-only**: `/clear`, `/compact`, `/cost`, `/model`,
/// `/tools`, `/reload-skills`, `/help`, `/exit`. Each requires a
/// live session; surfacing them pre-session would mislead.
public static func alwaysAvailableCommands(
capabilities: HermesCapabilities,
hasActiveSession: Bool
) -> [HermesSlashCommand] {
var result: [HermesSlashCommand] = [
HermesSlashCommand(
name: "new",
description: "Start a new chat session",
argumentHint: capabilities.hasNewWithSessionName ? "[<name>]" : nil,
source: .alwaysAvailable
)
]
guard hasActiveSession else { return result }
result.append(contentsOf: [
HermesSlashCommand(
name: "clear",
description: "Clear the current conversation",
argumentHint: nil,
source: .alwaysAvailable
),
HermesSlashCommand(
name: "compact",
description: "Compress the conversation history",
argumentHint: nil,
source: .alwaysAvailable
),
HermesSlashCommand(
name: "cost",
description: "Show cost breakdown for this session",
argumentHint: nil,
source: .alwaysAvailable
),
HermesSlashCommand(
name: "model",
description: "Switch the active model",
argumentHint: "[<model>]",
source: .alwaysAvailable
),
HermesSlashCommand(
name: "tools",
description: "Manage tool availability",
argumentHint: nil,
source: .alwaysAvailable
),
HermesSlashCommand(
name: "reload-skills",
description: "Reload the skills index",
argumentHint: nil,
source: .alwaysAvailable
),
HermesSlashCommand(
name: "help",
description: "Show available commands",
argumentHint: nil,
source: .alwaysAvailable
)
])
return result
}
/// Capability snapshot the chat surface uses to filter
/// `availableCommands`. Set by the chat controller (Mac
/// `ChatViewModel`, iOS `ChatController`) at session-start time and
@@ -396,7 +472,20 @@ public final class RichChatViewModel {
}
}
let nonInterruptive = supported.filter { !occupied.contains($0.name) }
return acpCommands + projectAsHermes + quicks + nonInterruptive
// Static fallbacks. `/new` always shows; the rest of the agent-
// level command set (`/clear`, `/compact`, `/cost`, `/model`,
// `/tools`, `/reload-skills`, `/help`, `/exit`) only when a
// session is active Hermes ACP doesn't re-emit
// `available_commands_update` after `session/load`, so without
// this fallback resumed sessions showed an artificially sparse
// menu. Deduped against ACP / project / quick names so once a
// session starts and the ACP server advertises its richer
// versions, the ACP-sourced entry wins.
let alwaysAvailable = Self.alwaysAvailableCommands(
capabilities: capabilitiesGate,
hasActiveSession: sessionId != nil
).filter { !occupied.contains($0.name) }
return acpCommands + projectAsHermes + quicks + nonInterruptive + alwaysAvailable
}
/// Publish a fresh capabilities snapshot from the controller.
@@ -615,7 +704,22 @@ public final class RichChatViewModel {
acpErrorDetails = nil
acpCachedReadTokens = 0
acpCompressionCount = 0
acpCommands = []
// `acpCommands` is intentionally NOT cleared. ACP slash commands
// are agent-level (advertised once per process via
// `available_commands_update` typically piggy-backing on
// `session/new`); they don't change when the user switches
// sessions. Hermes does not re-emit on `session/load`, so if
// we wipe here, resumed sessions land at a 4-command fallback
// until the user starts a fresh session exactly the dogfood
// bug surfaced during v2.8.0 testing. The caller paths
// (startNewSession, resumeSession, continueLastSession) all
// spawn a fresh ACP subprocess; if that subprocess emits a
// fresh list, our value is replaced; if it doesn't, we keep
// the most recently-known agent-level set, which stays
// accurate as long as the agent identity hasn't changed. The
// host-switch case (Local SSH) tears down the whole
// ContextBoundRoot so this stale carry-over isn't reachable
// there. See WS-2 / v2.8.0 dogfood report.
projectScopedCommands = []
currentTurnStart = nil
turnDurations = [:]