mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -24,6 +24,14 @@ public struct HermesSlashCommand: Identifiable, Sendable, Equatable {
|
|||||||
/// "agent working" indicator on; the guidance applies after the
|
/// "agent working" indicator on; the guidance applies after the
|
||||||
/// next tool call. Added in v2.5 alongside Hermes v2026.4.23.
|
/// next tool call. Added in v2.5 alongside Hermes v2026.4.23.
|
||||||
case acpNonInterruptive
|
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 }
|
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
|
/// Capability snapshot the chat surface uses to filter
|
||||||
/// `availableCommands`. Set by the chat controller (Mac
|
/// `availableCommands`. Set by the chat controller (Mac
|
||||||
/// `ChatViewModel`, iOS `ChatController`) at session-start time and
|
/// `ChatViewModel`, iOS `ChatController`) at session-start time and
|
||||||
@@ -396,7 +472,20 @@ public final class RichChatViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let nonInterruptive = supported.filter { !occupied.contains($0.name) }
|
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.
|
/// Publish a fresh capabilities snapshot from the controller.
|
||||||
@@ -615,7 +704,22 @@ public final class RichChatViewModel {
|
|||||||
acpErrorDetails = nil
|
acpErrorDetails = nil
|
||||||
acpCachedReadTokens = 0
|
acpCachedReadTokens = 0
|
||||||
acpCompressionCount = 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 = []
|
projectScopedCommands = []
|
||||||
currentTurnStart = nil
|
currentTurnStart = nil
|
||||||
turnDurations = [:]
|
turnDurations = [:]
|
||||||
|
|||||||
Reference in New Issue
Block a user