Merge pull request #174 from l0ng-ai/feat/grok-hooks

feat(agents): wire the rich status channel into Grok Build
This commit is contained in:
l0ng-ai
2026-07-26 10:09:21 +08:00
committed by GitHub
6 changed files with 287 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
<!-- Grok brand mark, from lobehub/lobe-icons (MIT, Copyright (c) 2023 LobeHub).
Normalized to tty7's 24x24 icon format; geometry unchanged. -->
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 874 B

+1 -1
View File
@@ -31,7 +31,7 @@ Codex, Gemini CLI, Aider, Amp, OpenCode, and ~10 more) and adds around them —
it never wraps or replaces the agent.
- **Brand avatars** — the tab chip / sidebar row shows which agent runs where; custom wrappers map in via `agent_commands` in `config.json`
- **Status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; run *Agent: Install Claude Code Hooks* from the palette to wire Claude Code up
- **Status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; Settings → Agents installs the hooks that feed it (Claude Code, Codex, Copilot CLI, OpenCode, Pi, Grok Build)
- **Notifications** — "needs your permission…" the moment an agent blocks on you, and "finished after Ns" per turn, honoring your notification policy
- **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N M`), refreshed on `cd` and when a command finishes
- **Session resume** — panes lost to a reboot re-launch their agent conversation on restore, carrying the original launch flags (`claude --dangerously-skip-permissions --resume …`) (`restore_agent_sessions`, on by default)
+1 -1
View File
@@ -30,7 +30,7 @@ tty7 能识别 pane 里跑着的第三方 coding agentClaude Code、Codex、G
Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包裹或替代 agent 本身。
- **品牌头像** —— 标签 chip / 侧栏行显示每个 pane 跑的是哪个 agent;自定义包装命令可通过 `config.json``agent_commands` 映射
- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在命令面板运行 *Agent: Install Claude Code Hooks* 一键接通 Claude Code
- **状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在 设置 → Agents 一键装好对应 hooksClaude Code、Codex、Copilot CLI、OpenCode、Pi、Grok Build
- **通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略
- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N M`),`cd` 或命令跑完时自动刷新
- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话,并带上原始启动 flags(`claude --dangerously-skip-permissions --resume …``restore_agent_sessions`,默认开启)
+197 -19
View File
@@ -4,10 +4,10 @@
//! The rich agent-status channel ([`crate::core::cli_agent`]) needs the agent
//! itself to say what it's doing. Each supported agent exposes that
//! differently — Claude Code and Codex take a declarative hooks map, Copilot
//! auto-loads JSON hook files from a directory, OpenCode loads JS plugins, Pi
//! loads TS extensions — but every integration bottoms out in the same tiny
//! emitter: `tty7 agent-hook <agent> <event>` reads the hook's JSON payload
//! from stdin and writes one sentinel OSC 777 sequence to the controlling
//! and Grok auto-load JSON hook files from a directory, OpenCode loads JS
//! plugins, Pi loads TS extensions — but every integration bottoms out in the
//! same tiny emitter: `tty7 agent-hook <agent> <event>` reads the hook's JSON
//! payload from stdin and writes one sentinel OSC 777 sequence to the controlling
//! terminal, where tty7's daemon-side sniffer picks it up and folds it into
//! the pane's session state.
//!
@@ -24,6 +24,12 @@ use crate::core::cli_agent::AGENT_EVENT_SENTINEL;
/// write escape sequences into terminals that aren't tty7.
pub const TTY7_ENV_MARKER: &str = "TTY7";
/// Env var Grok Build's hook runner injects into every hook process it spawns.
/// Its presence identifies *who ran us*, which matters because grok also scans
/// `~/.claude/settings.json` for hooks (its Claude-compat layer) — see
/// [`run_agent_hook`].
const GROK_HOOK_ENV: &str = "GROK_HOOK_EVENT";
/// Cap on how much hook stdin we'll read: real payloads are a few hundred
/// bytes of JSON; anything huge is not for us.
const MAX_STDIN: u64 = 64 * 1024;
@@ -46,6 +52,7 @@ pub fn run_agent_hook(agent: &str, event: &str) {
if std::env::var_os(TTY7_ENV_MARKER).is_none() {
return;
}
let agent = effective_agent(agent, std::env::var_os(GROK_HOOK_ENV).is_some());
// Hook payload: the agent writes JSON ({"session_id": …, "message": …, …})
// and closes stdin. Absent/malformed input still emits the bare event —
// the state machine works without ids or messages. A tty stdin means the
@@ -82,18 +89,35 @@ fn detach_console() {
#[cfg(unix)]
fn detach_console() {}
/// The agent slug an invocation really speaks for. Normally the one the
/// installed hook passed, but Grok Build reads `~/.claude/settings.json` as
/// well as its own hooks directory (a deliberate Claude-compat layer), so a
/// tty7 Claude Code integration also fires inside grok panes. Grok's hook
/// runner stamps every hook process with [`GROK_HOOK_ENV`], so those events are
/// relabeled to the agent that actually ran them — otherwise a grok pane
/// reports "Claude Code", and a user with both integrations installed emits
/// each turn under two identities instead of one deduplicated stream.
fn effective_agent(agent: &str, ran_by_grok: bool) -> &str {
if ran_by_grok { "grok" } else { agent }
}
/// The sentinel event one hook invocation maps onto, or `None` to stay silent.
/// Most hooks pass their event through; the exception is Copilot's single
/// `notification` hook, which fires for *every* notification type — only
/// permission/elicitation prompts are the amber "needs you" moment, so those
/// are escalated to `permission-request` and everything else is dropped
/// rather than parroted as a block.
/// Most hooks pass their event through; the exceptions are the single catch-all
/// `notification` hook Copilot and Grok expose, which fires for *every*
/// notification type. Only the types that always mean a real block escalate to
/// `permission-request`; everything else is dropped rather than parroted as one.
///
/// The two agents draw that line differently. Copilot's `permission_prompt`
/// only fires when it is actually asking, so it counts; grok dispatches the
/// same type *before* its permission system decides — on essentially every tool
/// call, auto-approved ones included — so only `elicitation_dialog` (grok
/// asking the user a question) survives there. Grok's completions and errors
/// (`task_complete`, `agent_error`) are never blocks for either.
fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> {
if agent == "copilot" && event == "notification" {
if stdin_json.contains("permission_prompt") || stdin_json.contains("elicitation_dialog") {
return Some("permission-request");
}
return None;
if matches!(agent, "copilot" | "grok") && event == "notification" {
let blocks = stdin_json.contains("elicitation_dialog")
|| (agent == "copilot" && stdin_json.contains("permission_prompt"));
return blocks.then_some("permission-request");
}
Some(event)
}
@@ -110,9 +134,19 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec<u8> {
"agent": agent,
"event": event,
});
for key in ["session_id", "message", "cwd"] {
// The sentinel body is always snake_case, but the payloads are not: Claude
// Code & friends send `session_id`, while Grok Build's envelope is
// camelCase throughout (`sessionId`). Read both spellings of each field so
// one vendor's convention doesn't cost us the session id that `--resume`
// needs.
for (key, alias) in [
("session_id", "sessionId"),
("message", "message"),
("cwd", "cwd"),
] {
if let Some(v) = payload
.get(key)
.or_else(|| payload.get(alias))
.and_then(|v| v.as_str())
.filter(|v| !v.is_empty())
{
@@ -319,15 +353,19 @@ pub enum HookAgent {
OpenCode,
/// A tty7-owned TS extension in `~/.pi/agent/extensions/tty7/`.
Pi,
/// A tty7-owned hook file in `~/.grok/hooks/` (Grok Build loads every JSON
/// file there, and global hooks need no folder-trust grant).
Grok,
}
impl HookAgent {
pub const ALL: [HookAgent; 5] = [
pub const ALL: [HookAgent; 6] = [
HookAgent::Claude,
HookAgent::Codex,
HookAgent::Copilot,
HookAgent::OpenCode,
HookAgent::Pi,
HookAgent::Grok,
];
/// The `agent` slug in hook commands and sentinel events — matches
@@ -339,6 +377,7 @@ impl HookAgent {
HookAgent::Copilot => "copilot",
HookAgent::OpenCode => "opencode",
HookAgent::Pi => "pi",
HookAgent::Grok => "grok",
}
}
@@ -350,6 +389,7 @@ impl HookAgent {
HookAgent::Copilot => "Copilot CLI",
HookAgent::OpenCode => "OpenCode",
HookAgent::Pi => "Pi",
HookAgent::Grok => "Grok Build",
}
}
@@ -386,6 +426,12 @@ impl HookAgent {
.join("tty7")
.join("index.ts"),
),
HookAgent::Grok => Some(
home_dir()?
.join(".grok")
.join("hooks")
.join(OWNED_FILE_STEM_JSON),
),
}
}
@@ -421,7 +467,7 @@ pub fn hooks_state(agent: HookAgent) -> HooksState {
match agent {
HookAgent::Claude => hook_map_state(&path, agent, CLAUDE_HOOK_EVENTS),
HookAgent::Codex => hook_map_state(&path, agent, CODEX_HOOK_EVENTS),
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi => {
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi | HookAgent::Grok => {
let Some(expected) = owned_file_content(agent) else {
return HooksState::NotInstalled;
};
@@ -457,7 +503,7 @@ pub fn install_hooks(agent: HookAgent) -> anyhow::Result<String> {
),
})
}
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi => {
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi | HookAgent::Grok => {
let content = owned_file_content(agent)
.ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?;
owned_file_install(&path, &content, &agent.marker())?;
@@ -475,7 +521,7 @@ pub fn uninstall_hooks(agent: HookAgent) -> anyhow::Result<String> {
.ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?;
match agent {
HookAgent::Claude | HookAgent::Codex => hook_map_uninstall(&path, agent),
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi => {
HookAgent::Copilot | HookAgent::OpenCode | HookAgent::Pi | HookAgent::Grok => {
owned_file_uninstall(&path, &agent.marker())
}
}
@@ -603,6 +649,39 @@ const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[
("Stop", "stop"),
];
/// Seconds grok gives one tty7 hook before killing it. Set explicitly for two
/// reasons: it keeps `Stop` off grok's 600-second gate default (that budget is
/// for hooks that run test suites; ours writes a few bytes and returns), and it
/// leaves headroom for the first hook of a session, which pays the cold-start
/// cost of paging in the tty7 binary. A killed hook only loses that one event —
/// the session id arrives again on the next `UserPromptSubmit`.
const GROK_HOOK_TIMEOUT_SECS: u32 = 10;
/// Grok Build's hook events — Claude Code's vocabulary, because grok mirrors it
/// deliberately (it even reads `~/.claude/settings.json`) — plus the matcher
/// regex each subscription is narrowed by (grok tests it against the event's
/// own discriminator: the notification type on `Notification`, the tool name on
/// tool events, …). Written as an owned file rather than merged into a shared
/// one; see [`grok_hooks_json`].
///
/// `Notification` is the one narrowed subscription. Grok dispatches its
/// `permission_prompt` notification *before* the permission system decides, so
/// it fires on essentially every tool call, auto-approved ones included —
/// escalating that to the amber "needs you" state would flash the pane (and
/// fire a desktop notification) on every tool a turn runs. `elicitation_dialog`
/// — grok's ask-the-user question — is the type that always means a real block,
/// so it is the only one subscribed. The emitter re-checks this (see
/// [`effective_event`]), which is what covers the same events arriving through
/// grok's Claude-compat scan, where the matcher isn't ours to set.
const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[
("SessionStart", "session-start", None),
("UserPromptSubmit", "prompt-submit", None),
("Notification", "notification", Some("elicitation_dialog")),
("PostToolUse", "tool-complete", None),
("Stop", "stop", None),
("SessionEnd", "session-end", None),
];
fn hook_map_state(path: &Path, agent: HookAgent, events: &[(&str, &str)]) -> HooksState {
let Ok(text) = std::fs::read_to_string(path) else {
return HooksState::NotInstalled;
@@ -789,6 +868,7 @@ fn owned_file_content(agent: HookAgent) -> Option<String> {
HookAgent::Copilot => copilot_hooks_json(),
HookAgent::OpenCode => opencode_plugin_js(),
HookAgent::Pi => pi_extension_ts(),
HookAgent::Grok => grok_hooks_json(),
HookAgent::Claude | HookAgent::Codex => None,
}
}
@@ -875,6 +955,31 @@ fn copilot_hooks_json() -> Option<String> {
serde_json::to_string_pretty(&root).ok()
}
/// Grok Build hook file (`~/.grok/hooks/tty7.json`). Grok loads every JSON file
/// in that directory and global hooks are always trusted (project hooks need a
/// folder-trust grant; ours don't), so tty7 owns its own file and never touches
/// the user's. Both the schema and the event names are Claude Code's — grok
/// mirrors them deliberately — so this is the same wiring as
/// [`CLAUDE_HOOK_EVENTS`] in owned-file form; see [`GROK_HOOK_EVENTS`] for the
/// table and why `Notification` carries a matcher.
fn grok_hooks_json() -> Option<String> {
let mut hooks = serde_json::Map::new();
for (event, sentinel, matcher) in GROK_HOOK_EVENTS {
let mut group = serde_json::json!({
"hooks": [{
"type": "command",
"command": hook_command(HookAgent::Grok, sentinel)?,
"timeout": GROK_HOOK_TIMEOUT_SECS,
}]
});
if let Some(matcher) = matcher {
group["matcher"] = serde_json::Value::String((*matcher).to_string());
}
hooks.insert((*event).to_string(), serde_json::json!([group]));
}
serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok()
}
/// OpenCode plugin (`~/.config/opencode/plugins/tty7.js`). OpenCode has no
/// declarative hooks — its extensibility surface is JS plugins auto-loaded
/// from that directory — so the plugin bridges its events onto the same
@@ -985,6 +1090,31 @@ mod tests {
let ev = parse_agent_event(&seq[2..seq.len() - 1]).expect("bare event still parses");
assert_eq!(ev.kind, AgentEventKind::Stop);
assert_eq!(ev.session_id, None);
// Grok's envelope is camelCase throughout; the same fields must land in
// the snake_case sentinel body, or restore loses the id `--resume` needs.
let seq = build_hook_sequence(
"grok",
"session-start",
r#"{"hookEventName":"session_start","sessionId":"g-42","cwd":"/w"}"#,
);
let ev = parse_agent_event(&seq[2..seq.len() - 1]).expect("daemon parses the grok event");
assert_eq!(ev.agent, Some(CLIAgent::Grok));
assert_eq!(ev.session_id.as_deref(), Some("g-42"));
assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w")));
}
/// Grok reads `~/.claude/settings.json` too, so a tty7 Claude Code
/// integration fires inside grok panes. Those invocations must speak as
/// grok — otherwise the pane reports the wrong agent, and having both
/// integrations installed emits every turn twice under two identities.
#[test]
fn grok_run_hooks_are_relabeled_to_grok() {
assert_eq!(effective_agent("claude", true), "grok");
assert_eq!(effective_agent("grok", true), "grok");
// Outside grok's hook runner nothing is rewritten.
assert_eq!(effective_agent("claude", false), "claude");
assert_eq!(effective_agent("grok", false), "grok");
}
/// Every event name any installer writes must be one the daemon's parser
@@ -997,6 +1127,7 @@ mod tests {
.iter()
.chain(CODEX_HOOK_EVENTS)
.map(|(_, e)| *e)
.chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e))
.collect();
// Owned-file integrations embed their events in generated source.
events.extend([
@@ -1037,12 +1168,37 @@ mod tests {
effective_event("copilot", "notification", r#"{"type":"turn_summary"}"#),
None
);
// Grok's catch-all Notification hook is filtered harder: only its
// ask-the-user question is reliably a block. `permission_prompt` fires
// ahead of the permission decision — verified against grok 0.2.112,
// where an auto-approved `list_dir` emitted one — so escalating it
// would flash amber on every tool call.
assert_eq!(
effective_event(
"grok",
"notification",
r#"{"notificationType":"elicitation_dialog","message":"User question requested"}"#
),
Some("permission-request")
);
for noisy in ["permission_prompt", "task_complete", "agent_error"] {
assert_eq!(
effective_event(
"grok",
"notification",
&format!(r#"{{"notificationType":"{noisy}"}}"#)
),
None,
"grok {noisy} is not a block"
);
}
// Other agents and events pass through untouched.
assert_eq!(
effective_event("claude", "notification", "{}"),
Some("notification")
);
assert_eq!(effective_event("copilot", "stop", "{}"), Some("stop"));
assert_eq!(effective_event("grok", "stop", "{}"), Some("stop"));
}
/// The controlling-tty fallback (`ancestor_tty_device`) is what makes the
@@ -1124,6 +1280,28 @@ mod tests {
assert!(pi.contains("agent-hook pi"));
assert!(pi.contains(&exe));
assert!(pi.contains(r#"process.env["TTY7"]"#));
let grok = grok_hooks_json().expect("grok content builds");
let parsed: serde_json::Value = serde_json::from_str(&grok).expect("valid JSON");
for (event, sentinel, matcher) in GROK_HOOK_EVENTS {
let group = &parsed["hooks"][*event][0];
let cmd = group["hooks"][0]["command"]
.as_str()
.unwrap_or_else(|| panic!("grok {event} carries a command"));
assert!(
cmd.ends_with(&format!("agent-hook grok {sentinel}")),
"grok {event} runs the emitter with {sentinel}, got {cmd}"
);
// A narrowed subscription must carry its matcher — without it grok
// fires the hook for every notification type, which is the amber
// flash this integration exists to avoid.
assert_eq!(
group.get("matcher").and_then(|m| m.as_str()),
*matcher,
"grok {event} matcher"
);
}
assert!(grok.contains(&exe));
}
/// Owned-file lifecycle against a scratch path: install → Installed,
+63 -5
View File
@@ -211,6 +211,9 @@ impl CLIAgent {
// Copilot CLI: `copilot --resume <sessionId>` (`-r` shorthand) —
// the one hooks-covered agent that was missing from this table.
CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")),
// Grok Build: `grok --resume <id-or-title>`; a UUID-shaped value
// always takes the id path, which is what its hooks report.
CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")),
_ => None,
}
}
@@ -268,6 +271,27 @@ impl CLIAgent {
// `--last` targets "the most recent session" and would contradict
// the explicit id we inject.
CLIAgent::Codex => &["--last"],
// Beyond the session-targeting flags (`--load` is grok's hidden
// alias for `--resume`; `--session-id` names a *new* session and
// `--fork-session` would branch off the one we mean to continue),
// the worktree pair goes too: `--worktree` with no value mints a
// fresh git worktree on every relaunch, and `--worktree-ref`
// requires `--worktree`, so leaving it behind would make grok
// reject the resume outright.
CLIAgent::Grok => &[
"--resume",
"-r",
"--load",
"--continue",
"-c",
"--session-id",
"-s",
"--fork-session",
"--worktree",
"-w",
"--worktree-ref",
"--ref",
],
_ => &[],
};
let mut i = 0;
@@ -314,8 +338,11 @@ impl CLIAgent {
/// Brand accent (0xRRGGBB) for the tab chip's agent dot. Chosen for legibility
/// on both light and dark themes rather than exact brand black/white. A pure
/// black or white dot vanishes against one theme, so monochrome vendors get
/// a recognizable mid-tone hue instead; Codex keeps its black field.
/// *white* field vanishes against a light theme, so vendors whose mark is a
/// grey or gradient monochrome (Cursor) get a recognizable mid-tone hue
/// instead. A black field is a different case: it stays darker than even the
/// darkest theme background and the white mark on it carries the badge, so
/// vendors who actually brand in black (Codex, Grok) keep it.
pub fn accent_rgb(self) -> u32 {
match self {
CLIAgent::Claude => 0xD97757, // Claude terracotta
@@ -333,7 +360,7 @@ impl CLIAgent {
CLIAgent::Hermes => 0x8B5CF6, // violet
CLIAgent::Vibe => 0xFF7000, // Mistral orange
CLIAgent::Antigravity => 0x2563EB, // Google blue (darker than Gemini's)
CLIAgent::Grok => 0x64748B, // xAI is monochrome → slate
CLIAgent::Grok => 0x000000, // xAI brands in black
CLIAgent::Qwen => 0x7C3AED, // Qwen purple
}
}
@@ -355,6 +382,7 @@ impl CLIAgent {
CLIAgent::Cursor => "icons/agents/cursor.svg",
CLIAgent::Goose => "icons/agents/goose.svg",
CLIAgent::Droid => "icons/agents/droid.svg",
CLIAgent::Grok => "icons/agents/grok.svg",
// No brand mark bundled → generic robot glyph.
CLIAgent::Aider
| CLIAgent::Pi
@@ -362,7 +390,6 @@ impl CLIAgent {
| CLIAgent::Hermes
| CLIAgent::Vibe
| CLIAgent::Antigravity
| CLIAgent::Grok
| CLIAgent::Qwen => "icons/bot.svg",
}
}
@@ -916,9 +943,12 @@ mod tests {
}
}
/// The two vendors who actually brand in black keep the black field rather
/// than the mid-tone substitute monochrome marks otherwise get.
#[test]
fn codex_avatar_uses_its_black_brand_field() {
fn black_branded_avatars_keep_their_brand_field() {
assert_eq!(CLIAgent::Codex.accent_rgb(), 0x000000);
assert_eq!(CLIAgent::Grok.accent_rgb(), 0x000000);
}
#[test]
@@ -1371,6 +1401,34 @@ mod tests {
CLIAgent::Copilot.resume_command("s-9", None).as_deref(),
Some("copilot --resume s-9")
);
// Grok: mode flags survive, and every way of naming another session is
// stripped so the injected id is the only target left.
assert_eq!(
CLIAgent::Grok
.resume_command("g-2", Some(&argv(&["grok", "--model", "grok-code"])))
.as_deref(),
Some("grok --model grok-code --resume g-2")
);
assert_eq!(
CLIAgent::Grok
.resume_command(
"g-2",
Some(&argv(&["grok", "--resume", "g-1", "--fork-session"]))
)
.as_deref(),
Some("grok --resume g-2")
);
// `--worktree` would mint a fresh git worktree on every restore, and
// `--worktree-ref` can't survive without it.
assert_eq!(
CLIAgent::Grok
.resume_command(
"g-3",
Some(&argv(&["grok", "-w", "--worktree-ref", "main", "--yolo"]))
)
.as_deref(),
Some("grok --yolo --resume g-3")
);
}
#[test]
+20
View File
@@ -151,6 +151,11 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/agents/cursor.svg" => include_bytes!("../../assets/icons/agents/cursor.svg"),
"icons/agents/goose.svg" => include_bytes!("../../assets/icons/agents/goose.svg"),
"icons/agents/droid.svg" => include_bytes!("../../assets/icons/agents/droid.svg"),
// The one mark not taken from the vendor directly: xAI publishes its
// symbol only as a ~2:1 landscape lockup that turns to mush as a 16px
// silhouette, so this is lobehub/lobe-icons' square transcription (MIT),
// drawn for exactly this avatar use. Its notice rides in the SVG.
"icons/agents/grok.svg" => include_bytes!("../../assets/icons/agents/grok.svg"),
_ => return None,
};
Some(bytes)
@@ -181,6 +186,21 @@ mod tests {
}
}
/// Every agent avatar must resolve to real bytes. Adding a brand mark means
/// touching two files — the SVG and the arm above — and forgetting the
/// second one costs the agent its avatar with nothing to show for it.
#[test]
fn every_agent_icon_resolves() {
for agent in crate::core::cli_agent::CLIAgent::ALL {
let path = agent.icon_path();
assert!(
Assets.load(path).unwrap().is_some(),
"{} points at {path}, which nothing serves",
agent.display_name()
);
}
}
/// A `stock/` path for a glyph tty7 never overrode still has to resolve —
/// the prefix is a bypass, not a separate asset set.
#[test]