diff --git a/Native/OptChat/optchat-chief/src/acpmux.rs b/Native/OptChat/optchat-chief/src/acpmux.rs index c3425c2fa810..d8abe4321855 100644 --- a/Native/OptChat/optchat-chief/src/acpmux.rs +++ b/Native/OptChat/optchat-chief/src/acpmux.rs @@ -57,15 +57,33 @@ pub fn chief_tags(home_id: &str, role: &str) -> BTreeMap { ]) } +/// On a turn session: the Chief's participant id (`agent_mux`, `agent_…`). +/// acpmux stamps it as `fromChief` on a message the turn sends another chat +/// (its `prompt_provenance::session_message_from`), and the app draws that +/// Chief's name and picture there, not the turn's session name +/// (manaflow-ai/cmux#27914). +pub const CHIEF_PARTICIPANT_TAG: &str = "cmux.chief.participant"; + /// A turn session's tags: [`chief_tags`] plus the names acpmux puts on its /// approval cards (Leo 2026-10-21, #89(4): "Chief wants to message a12 · -/// Nightly train"): the asker is the Chief, and its children are the -/// sessions whose `mux.parent` is `parent`. -pub fn turn_tags(home_id: &str, parent: &str) -> BTreeMap { +/// Nightly train"): the asker is the Chief by its own name (`title`, else +/// "Chief"), and its children are the sessions whose `mux.parent` is +/// `parent`; and the Chief's `participant` id. +pub fn turn_tags( + home_id: &str, + parent: &str, + participant: &str, + title: Option<&str>, +) -> BTreeMap { use cmux_chief::rules::{CHIEF_DISPLAY_NAME, CHILDREN_TAG, LABEL_TAG}; let mut tags = chief_tags(home_id, "turn"); - tags.insert(LABEL_TAG.to_owned(), CHIEF_DISPLAY_NAME.to_owned()); + let label = title + .map(|t| t.split_whitespace().collect::>().join(" ")) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| CHIEF_DISPLAY_NAME.to_owned()); + tags.insert(LABEL_TAG.to_owned(), label); tags.insert(CHILDREN_TAG.to_owned(), parent.to_owned()); + tags.insert(CHIEF_PARTICIPANT_TAG.to_owned(), participant.to_owned()); tags } diff --git a/Native/OptChat/optchat-chief/src/brain/turns.rs b/Native/OptChat/optchat-chief/src/brain/turns.rs index c611a60eb48b..7d2c0f372f91 100644 --- a/Native/OptChat/optchat-chief/src/brain/turns.rs +++ b/Native/OptChat/optchat-chief/src/brain/turns.rs @@ -684,7 +684,14 @@ impl Brain { model: engine.model.clone(), effort: engine.effort.clone(), preset, - tags: crate::acpmux::turn_tags(&self.settings.chief_id, &self.settings.parent), + // The Chief's own name: `MUX_CHIEF_TITLE`, which the app + // gives each Chief's brain (a rename restarts it). + tags: crate::acpmux::turn_tags( + &self.settings.chief_id, + &self.settings.parent, + &self.settings.membership.participant, + crate::cli::env("MUX_CHIEF_TITLE").as_deref(), + ), // Only the Chief's own turn gets the seal its `chiefs` // tool needs (chief_profile.rs TURN_KEY_ENV). env: [( diff --git a/Native/OptChat/optchat-chief/tests/approval_display_tags.rs b/Native/OptChat/optchat-chief/tests/approval_display_tags.rs index 4057536de3f4..4e3448aafd85 100644 --- a/Native/OptChat/optchat-chief/tests/approval_display_tags.rs +++ b/Native/OptChat/optchat-chief/tests/approval_display_tags.rs @@ -107,4 +107,10 @@ fn a_turn_names_the_chief_and_whose_children_it_messages() { tags.get(CHILDREN_TAG).map(String::as_str), Some(optchat_chief::brain::PARENT) ); + // Which Chief it is: a message it sends another chat shows that Chief + // (manaflow-ai/cmux#27914). + assert_eq!( + tags.get(optchat_chief::acpmux::CHIEF_PARTICIPANT_TAG).map(String::as_str), + Some("agent_mux") + ); } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift index eac4cc8f9f0b..9b80af11258a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift @@ -34,7 +34,7 @@ public nonisolated struct AgentPageOps { "pane.checkpointAvailability", "pane.framePacing", "pane.painted", "pane.renderRate", "pane.saveLog", "pane.saveFile", "pane.showContextUsage", "pane.showDictation", "pane.setScrollToBottomOnSend", "pane.setDisplay", "pane.setAttachmentView", "pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump", "newTab.remember", "newTab.setTemplate", "newTab.editWidgets", "nowPlaying.command", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open", - "project.list", "project.icon", "agent.avatar", "agent.avatar.set", "agent.avatar.remove", "harness.symbol", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "chat.attach.choose", "chat.attach.dropped", "app.action", "chats.open", "chats.openInTerminal", "chats.watch", "chats.page", + "project.list", "project.icon", "agent.avatar", "agent.avatar.set", "agent.avatar.remove", "chief.face", "harness.symbol", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "chat.attach.choose", "chat.attach.dropped", "app.action", "chats.open", "chats.openInTerminal", "chats.watch", "chats.page", "quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.computerUsePreview", "chat.upload", "chat.btw.inline", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.asks.open", "chat.asks.answer", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending", "shell.run", "shell.read", "shell.stop", "harness.update", "harness.restart", "git.diff", "git.status", "git.commit", "git.push", "git.worktree.create", "git.worktree.apply", "git.worktree.remove", "git.githubRepository", "file.search", "git.checkpoint.diff", "turn.undo", "git.stage", "git.unstage", "git.stageHunk", "git.unstageHunk", "git.pullRequest", "project.instructions", "project.addFiles", "composer.capture", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index 26fccd66fb95..e9cea926b349 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -74,6 +74,8 @@ public final class AgentPaneModel { @ObservationIgnored public var onAgentAvatar: ((String) async -> String?)? @ObservationIgnored public var onSetAgentAvatar: ((String) async -> String?)? @ObservationIgnored public var onRemoveAgentAvatar: ((String) -> Void)? + /// `chief.face`: Chief `chief`'s name and avatar as the sidebar shows them (`name`, `src` or `text`). + @ObservationIgnored public var onChiefFace: ((String) async -> [String: String])? /// Returns bounded project paths for the picker, optionally filtered by query. @ObservationIgnored public var onListProjects: ((String?) async -> [String])? /// The folders a chat on another machine can pick: that machine's (its workspace's folders @@ -286,7 +288,7 @@ public final class AgentPaneModel { // Boot traffic, and a request the host refused (it changed nothing), leave it untouched. case .ready, .reconnect, .uiPointerReport, .framePacing, .renderRate, .checkpointAvailability, .painted, .newTabInputReady, .newTabContext, .newTabWarm, .unsupported, .transportOpen, .transportSend, .transportClose, .transportGesture, .transportGestureRelease, .chatsPage, .quickFloatingState, .quickContext, - .tabRowTools, .projectIcon, .agentAvatar, .harnessSymbol, .environment(.list), .cloudMachine(.list), .sandboxState: break + .tabRowTools, .projectIcon, .agentAvatar, .chiefFace, .harnessSymbol, .environment(.list), .cloudMachine(.list), .sandboxState: break case .reply(let reply) where reply.isPassive: break default: if !userTouched { touchedBy = String(String(describing: request).prefix { $0 != "(" }) } @@ -380,6 +382,8 @@ public final class AgentPaneModel { case .removeAgentAvatar(let session): onRemoveAgentAvatar?(session) return AgentPaneReply.success() + case .chiefFace(let chief): + return AgentPaneReply.success(await onChiefFace?(chief) ?? [:]) case .projectIcon where remoteChat: // A chat on another machine: its folders are there, and this Mac's disk is never read (cx-k21v). return AgentPaneReply.success() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift index 119bda87747f..f4238cfdc9bd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift @@ -304,6 +304,12 @@ extension AgentPaneRequest { if let session = params?["sessionId"] as? String, !session.isEmpty, session.utf8.count <= 256 { self = method == "agent.avatar" ? .agentAvatar(session) : method == "agent.avatar.set" ? .setAgentAvatar(session) : .removeAgentAvatar(session) } else { self = .unsupported(method) } + case "chief.face": + // A Chief's participant id (acpmux `fromChief`): `agent_` and a short token. + if let chief = params?["chief"] as? String, chief.hasPrefix("agent_"), (7...64).contains(chief.utf8.count), + chief.utf8.allSatisfy({ $0 == UInt8(ascii: "_") || $0 == UInt8(ascii: "-") || (0x30...0x39).contains($0) || (0x41...0x5A).contains($0) || (0x61...0x7A).contains($0) }) { + self = .chiefFace(chief) + } else { self = .unsupported(method) } case "harness.symbol": if let name = params?["name"] as? String, HarnessSymbolImages.isSymbolName(name) { self = .harnessSymbol(name) } else { self = .unsupported(method) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift index b9ac83883581..f3bc1df40a37 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift @@ -52,6 +52,7 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl case .agentAvatar: "agentAvatar" case .setAgentAvatar: "setAgentAvatar" case .removeAgentAvatar: "removeAgentAvatar" + case .chiefFace: "chiefFace" case .harnessSymbol: "harnessSymbol" case .nowPlaying: "nowPlaying" case .openChat: "openChat" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index 2a8ce62bfe65..5370bdb5dd4b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -116,6 +116,10 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable { case setAgentAvatar(String) /// `agent.avatar.remove {sessionId}`: Remove Avatar: the session's harness mark again. case removeAgentAvatar(String) + /// `chief.face {chief}`: the name and avatar Chief `chief` (a participant id, `agent_mux` or + /// `agent_…`) shows in the sidebar and Home, for a message it sent this chat (acpmux + /// `fromChief`, manaflow-ai/cmux#27914): `name`, and `src` (a small data URL) or `text`. + case chiefFace(String) /// `harness.symbol {name}`: a custom harness's SF Symbol icon as a template PNG `data:` URL /// (``HarnessSymbolImages``); a name this Mac cannot draw answers nothing. case harnessSymbol(String) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift index efb8ab6d76d1..691d85292992 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Wiring.swift @@ -114,6 +114,10 @@ extension AgentTabStore { linkScheme: services.linkScheme, settings: services.settings, openPanel: services.openPanel) tabs.launchImages = services.launchImages tabs.agentAvatars = AgentAvatars(uploads: services.chiefAvatarUploads, openPanel: services.openPanel) + tabs.chiefFace = { [weak services, weak tabs] chief in + guard let services, let avatars = tabs?.agentAvatars else { return [:] } + return await services.home.agentPageFace(of: chief, avatars: avatars) + } let state = services.environment.sidebarSnapshotFile?.url.deletingLastPathComponent() tabs.unsentDrafts = AgentUnsentDrafts(directory: state) tabs.pageChats.environments = state.map { SavedEnvironments(url: $0.appending(path: "environments.json")) } @@ -387,6 +391,7 @@ extension AgentTabStore { self?.agentAvatars?.remove(session) self?.agentAvatarChanged(session) } + model.onChiefFace = { [weak self] chief in await self?.chiefFace?(chief) ?? [:] } } /// Every agent page (tabs and standalone chats) asks for session `session`'s avatar again: a diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift index abaaf6efae9b..06f1233fb1a3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs.swift @@ -148,6 +148,8 @@ final class AgentTabStore { var blankChatHandler: ((String) -> NewTabPageHandler?)? /// Each agent session's own avatar (Set Avatar… in the chat's ⋯ menu). var agentAvatars: AgentAvatars? + /// A Chief's name and avatar as the sidebar shows them, by participant id (`chief.face`). + var chiefFace: (@MainActor (String) async -> [String: String])? /// Opens a redeemed Run attempt's terminal, owned by an enrolled worker daemon, in the tab's pane. var runAttach: (@MainActor (_ key: String, _ request: AgentPaneRunAttach) async throws -> Void)? /// The New Tab page a new workspace's first tab shows, starting in the given folder. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+Conversations.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+Conversations.swift index 523086a2aedc..3bbe096e8c9a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+Conversations.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+Conversations.swift @@ -1,6 +1,7 @@ import CmuxHomeCore import CmuxNextCompat import CmuxNextDaemon +import CmuxNextFloating import CmuxNextChiefPetWiring import CmuxNextHome import CmuxNextHomeEngine @@ -286,6 +287,25 @@ extension HomeService { return Self.chiefFaces(chiefs: chiefs, own: directory.chiefAvatars) { services.characterPicture(of: $0) } } + /// Chief `chief` (a participant id) as an agent chat draws a message it sent there + /// (`chief.face`, manaflow-ai/cmux#27914): the name and avatar Home and the sidebar show for it. + func agentPageFace(of chief: String, avatars: AgentAvatars) async -> [String: String] { + // The one name its tile, header and tab show (a rename being saved included): its own + // conversation's; else what a conversation or its record calls it. + let own = homeStore.rows.first { Self.soleChief(of: $0.summary.participants) == chief } + let name = own.flatMap { chiefName(of: $0.summary.id) } + ?? homeStore.rows.lazy.flatMap(\.summary.participants).first { $0.id.rawValue == chief }?.displayName + ?? directory.chiefs.first { $0.id == chief }?.name + // The sidebar draws the default Chief (`agent_mux`) with no id. + var picture = services.characterPicture(of: chief == DaemonHomeSource.chief.id ? nil : chief), text: String? + switch chiefFaces[chief] { + case .picture(let url): picture = url + case .text(let own): text = own + case nil: break + } + return await avatars.chiefFace(name: name, picture: picture, text: text) + } + /// `own` (each Chief's own avatar), with each other Chief in `chiefs` wearing the `picture` of its character. static func chiefFaces(chiefs: Set, own: [String: HomeChiefAvatar], picture: (String) -> URL?) -> [String: HomeChiefAvatar] { var faces = own diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextFloating/ChiefPet/AgentAvatars.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextFloating/ChiefPet/AgentAvatars.swift index af621ac8d084..ce6dbdd451a9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextFloating/ChiefPet/AgentAvatars.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextFloating/ChiefPet/AgentAvatars.swift @@ -43,6 +43,29 @@ public final class AgentAvatars { return nil } + /// A Chief as an agent chat draws a message it sent there (`chief.face`, manaflow-ai/cmux#27914): + /// `name`, and its own `text` avatar, else its `picture` drawn down as `src`. + public func chiefFace(name: String?, picture: URL?, text: String?) async -> [String: String] { + var face: [String: String] = [:] + if let name = name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { face["name"] = name } + if let text, !text.isEmpty { + face["text"] = text + } else if let picture, let src = await self.picture(picture) { + face["src"] = src + } + return face + } + + /// Picture `file` (a Chief's avatar or character still) drawn down as the agent avatars are, as a + /// data URL; nil when it cannot be read. Each still is drawn once. + func picture(_ file: URL) async -> String? { + if let known = made[file] { return known } + // task-owner: one small read and draw-down of a still; ends with it + let src = await Task.detached(priority: WorkPriority.userVisible.taskPriority) { Self.dataURL(file) }.value + if let src { made[file] = src } + return src + } + /// Set Avatar…: the open panel over `window`, then the upload. The new avatar; nil when the /// person cancelled, or when the picture was refused (a cmux dialog over `window` says why). public func choose(_ session: String, window: NSWindow?) async -> String? { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneAgentAvatarTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneAgentAvatarTests.swift index 231a8fb9b837..d77071702403 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneAgentAvatarTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneAgentAvatarTests.swift @@ -33,6 +33,20 @@ struct AgentPaneAgentAvatarTests { #expect(removed == ["s1"]) } + /// A Chief's message line asks for that Chief's face by its participant id (manaflow-ai/cmux#27914). + @Test func chiefFaceTakesOnlyAChiefIdAndAnswersThroughTheAppsHook() async { + if case .chiefFace("agent_ops") = request("chief.face", ["chief": "agent_ops"]) {} else { Issue.record("chief.face") } + for params: [String: Any] in [[:], ["chief": "ops"], ["chief": "agent_"], ["chief": "agent_a b"], ["chief": "agent_" + String(repeating: "x", count: 64)], ["chief": 7]] { + if case .unsupported = request("chief.face", params) {} else { Issue.record("\(params)") } + } + let model = AgentPaneModel(host: NoHost()) + #expect((await model.respond(to: .chiefFace("agent_ops"))["value"] as? [String: Any])?.isEmpty == true, "no hook: no face") + model.onChiefFace = { $0 == "agent_ops" ? ["name": "Ops Chief", "src": "data:image/png;base64,AAAA"] : [:] } + let face = await model.respond(to: .chiefFace("agent_ops"))["value"] as? [String: Any] + #expect(face?["name"] as? String == "Ops Chief") + #expect(face?["src"] as? String == "data:image/png;base64,AAAA") + } + private actor NoHost: AgentPaneHostProviding { func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } } diff --git a/cmux-tui/crates/acpmux/src/prompt_provenance.rs b/cmux-tui/crates/acpmux/src/prompt_provenance.rs index a1aca98def95..c04ffda685d2 100644 --- a/cmux-tui/crates/acpmux/src/prompt_provenance.rs +++ b/cmux-tui/crates/acpmux/src/prompt_provenance.rs @@ -147,6 +147,53 @@ pub fn session_message(from: &str, from_name: &str, to: &str) -> Value { json!({"kind": "session_message", "from": from, "fromName": from_name, "to": to}) } +/// On a session a Chief host made for one of its turns (optchat-chief `acpmux::turn_tags`): the +/// Chief's participant id (`agent_mux`, `agent_…`), the key the app finds that Chief's name and +/// picture by. +pub const CHIEF_PARTICIPANT_TAG: &str = "cmux.chief.participant"; +/// What a Chief's session is called where it is shown: its `mux.label` (the Chief's own name). +const LABEL_TAG: &str = "mux.label"; +/// On a message from a Chief's turn session: that Chief's participant id, so the receiving chat +/// draws the Chief's name and picture, not the turn's session name (`optchat--`, +/// manaflow-ai/cmux#27914). +pub const FROM_CHIEF: &str = "fromChief"; + +/// `provenance` with its sender's Chief, when the sender is a Chief's turn session of this daemon +/// (`meta_of` reads one by its id): `fromChief` its participant id and `fromName` the Chief's own +/// name. Both kinds a Chief's turn sends with: an `agent_message` (`agents_chat_send` through the +/// cmux daemon's pump, `from` `acp:`) and a `session_message` (`acpmux send` from inside the +/// turn, `from` its id). Any other provenance is left as it is. +pub fn stamp_chief( + mut provenance: Value, + meta_of: impl Fn(&str) -> Option, +) -> Value { + let kind = provenance.get("kind").and_then(Value::as_str); + if !matches!(kind, Some("agent_message" | "session_message")) { + return provenance; + } + let Some(from) = provenance.get("from").and_then(Value::as_str) else { + return provenance; + }; + let Some(meta) = meta_of(from.strip_prefix("acp:").unwrap_or(from)) else { + return provenance; + }; + let live = |key: &str| { + let tag = meta.tags.get(key)?; + if tag.expires_at.is_some_and(|e| e <= crate::store::now_ms()) { + return None; + } + Some(tag.value.trim().to_owned()).filter(|v| !v.is_empty() && !v.chars().any(unshown)) + }; + let Some(chief) = live(CHIEF_PARTICIPANT_TAG).filter(|c| c.len() <= MAX_FIELD_BYTES) else { + return provenance; + }; + provenance[FROM_CHIEF] = json!(chief); + if let Some(label) = live(LABEL_TAG).filter(|l| l.chars().count() <= MAX_NAME_CHARS) { + provenance["fromName"] = json!(label); + } + provenance +} + /// The provenance of a task's result on the prompt that brings it to the chat that gave the task /// (`hub/tasks.rs`): from the lane's session, `from` its id as in [`session_message`]. pub fn task_result(from: &str, from_name: &str, to: &str, task_id: &str, state: &str) -> Value { @@ -308,6 +355,41 @@ mod sender_tests { assert_eq!(peer_sender(&json!({})), None); } + #[test] + fn a_chief_turn_session_signs_with_the_chief_name_and_id() { + let tag = |v: &str| crate::store::Tag { value: v.into(), expires_at: None }; + let mut meta: crate::store::SessionMeta = serde_json::from_value(json!({ + "schema": "acpmux.session.v1", "id": "s-1", "name": "optchat-b902d10e-2991", + "harness": "claude", "family": "claude", "cwd": "/tmp", "status": "idle", + "createdAt": 1, "updatedAt": 2, + })) + .unwrap(); + let found = |meta: &crate::store::SessionMeta| { + let meta = meta.clone(); + move |id: &str| (id == "s-1").then(|| meta.clone()) + }; + let pumped = json!({"kind": "agent_message", "from": "acp:s-1", "to": "acp:s-2", "msgId": "m", "threadId": "t"}); + let sent = session_message("s-1", "optchat-b902d10e-2991", "s-2"); + // A session no Chief made: as it was. + meta.tags.insert(LABEL_TAG.into(), tag("lane")); + assert_eq!(stamp_chief(sent.clone(), found(&meta)), sent); + assert_eq!(stamp_chief(pumped.clone(), found(&meta)), pumped); + // A Chief's turn: the Chief's name and participant id, on both kinds. + meta.tags.insert(LABEL_TAG.into(), tag("Ops Chief")); + meta.tags.insert(CHIEF_PARTICIPANT_TAG.into(), tag("agent_ops")); + for given in [sent, pumped] { + let stamped = stamp_chief(given.clone(), found(&meta)); + assert_eq!(stamped["fromName"], "Ops Chief"); + assert_eq!(stamped[FROM_CHIEF], "agent_ops"); + assert_eq!(stamped["from"], given["from"]); + } + // Another sender, a person's prompt, or a sender this daemon does not have: untouched. + let other = json!({"kind": "agent_message", "from": "acp:s-9", "to": "acp:s-2", "msgId": "m", "threadId": "t"}); + assert_eq!(stamp_chief(other.clone(), found(&meta)), other); + let person = json!({"kind": "person_message", "from": "s-1", "fromName": "Ana"}); + assert_eq!(stamp_chief(person.clone(), found(&meta)), person); + } + #[test] fn a_stamp_replaces_what_the_provenance_had_and_reads_back() { let given = json!({"kind": "agent_message", "from": "fromAgent", "fromHarness": "fake"}); diff --git a/cmux-tui/crates/acpmux/src/server/requests.rs b/cmux-tui/crates/acpmux/src/server/requests.rs index ac011a3a3622..5297d377948c 100644 --- a/cmux-tui/crates/acpmux/src/server/requests.rs +++ b/cmux-tui/crates/acpmux/src/server/requests.rs @@ -1243,6 +1243,12 @@ async fn dispatch_request( }); let provenance = stamp_bound_sender(hub, conn, &s.id, ¶ms, provenance, person_message); + // A Chief's turn names the Chief (manaflow-ai/cmux#27914), by the session's exact id. + let provenance = provenance.map(|p| { + crate::prompt_provenance::stamp_chief(p, |id| { + hub.resolve(id).ok().filter(|from| from.id == id).map(|from| from.meta()) + }) + }); // Another agent's message with no provenance (`hub/digest.rs`): one that names its // sender, or one from a connection that proved it runs in an acpmux session. let agent = declared diff --git a/release-notes/next/highlights/chief-name-in-agent-chats.md b/release-notes/next/highlights/chief-name-in-agent-chats.md new file mode 100644 index 000000000000..65f41ffa71ab --- /dev/null +++ b/release-notes/next/highlights/chief-name-in-agent-chats.md @@ -0,0 +1,4 @@ +title: A Chief's messages to an agent chat show the Chief +category: fixed + +A message a Chief sends into an agent chat shows that Chief's name and avatar, as the sidebar shows them, on its line and its From: line. diff --git a/webviews/src/agent-session/acpmux/chiefFace.ts b/webviews/src/agent-session/acpmux/chiefFace.ts new file mode 100644 index 000000000000..7d1cdd6bc8fb --- /dev/null +++ b/webviews/src/agent-session/acpmux/chiefFace.ts @@ -0,0 +1,78 @@ +// A Chief as an agent chat draws it (manaflow-ai/cmux#27914): a message a Chief's turn sent this +// chat names the Chief by its participant id (acpmux `fromChief`), and its line shows the name and +// avatar the sidebar and Home show for that Chief, never the turn's session name +// (`optchat--`) with a robot. The host answers `chief.face {chief}` with `name`, and `src` +// (a small data URL) or `text` (a text avatar: letters or an emoji) (HomeService.agentPageFace). +// An answer is kept for the page; a failed or empty one (an older host, the page not yet connected, +// Home not read yet) is not. +import { useEffect, useSyncExternalStore } from "react"; +import { postNative } from "./native"; + +type Call = (method: string, params: Record) => Promise; +let call: Call = postNative; + +/// What the page draws for a Chief: its name, and its picture or its text avatar. +export type ChiefFace = { name?: string; src?: string; text?: string }; + +const faces = new Map(); +const pending = new Map>(); +const listeners = new Set<() => void>(); + +/// Tests replace the host; replacing it forgets every answer. +export function setChiefFaceHost(next: Call | undefined): void { + call = next ?? postNative; + faces.clear(); + pending.clear(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function face(reply: unknown): ChiefFace { + const value = (reply ?? {}) as Record; + const text = (key: string) => (typeof value[key] === "string" && (value[key] as string).trim()) || undefined; + const src = text("src"); + return { + ...(text("name") && { name: text("name") }), + ...(src?.startsWith("data:image/") && { src }), + ...(text("text") && { text: text("text") }), + }; +} + +/// Asks the host for Chief `chief`'s face once at a time (exported for tests). +export function loadChiefFace(chief: string): Promise { + const started = pending.get(chief); + if (started) return started; + const request = Promise.resolve() + .then(() => call("chief.face", { chief })) + .then( + (reply) => { + const value = face(reply); + // An empty answer (Home not read yet, a Chief this Mac does not know) is not kept: the next + // line that draws the Chief asks again. + if (Object.keys(value).length === 0) return undefined; + faces.set(chief, value); + for (const listener of listeners) listener(); + return value; + }, + () => undefined, + ) + .finally(() => { + if (pending.get(chief) === request) pending.delete(chief); + }); + pending.set(chief, request); + return request; +} + +/// Chief `chief`'s face once the host answers; undefined before then, and for no Chief. +export function useChiefFace(chief: string | undefined): ChiefFace | undefined { + const value = useSyncExternalStore(subscribe, () => (chief ? faces.get(chief) : undefined)); + useEffect(() => { + if (chief && !faces.has(chief)) void loadChiefFace(chief); + }, [chief]); + return value; +} diff --git a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.gallery.ts b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.gallery.ts index 3dc234ff318c..77545a57f8e9 100644 --- a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.gallery.ts @@ -256,6 +256,29 @@ export default agentPaneEntry({ await ctx.waitFor(() => ctx.document.querySelector(".cv-received .cv-disclosure__icon img.cv-agent-avatar")); }, }, + chief: { + note: "A Chief's message to this chat (acpmux `fromChief`): the line and its From: line name the Chief and draw its avatar as the sidebar does, never the turn's session name (optchat-…) with a robot (manaflow-ai/cmux#27914).", + snapshot: chat([ + user("Ship the sidebar fix and tell the Chief when CI is green.", 9), + assistant("On it.", 8.5), + summary(8.5, { status: "completed" }), + user(chiefReport, 6, { + incoming: { + kind: "message", + message: { name: "optchat-b902d10e-2991", chief: "agent_ops", text: chiefReport, raw: chiefReport }, + }, + untyped: true, + }), + ]), + native: { "chief.face": { name: "Ops Chief", src: UPLOADED_AVATAR } }, + play: async (ctx) => { + for (const toggle of ctx.document.querySelectorAll(RECEIVED_LINE)) toggle.click(); + await ctx.waitFor(() => ctx.document.querySelector(".cv-received .cv-disclosure__icon img.cv-agent-avatar")); + await ctx.waitFor(() => + ctx.document.querySelector(".cv-received__from-line")?.textContent?.includes("Ops Chief"), + ); + }, + }, links: { note: "A message's line; opened, its panel shows its PR, HQ and commit references as links.", snapshot: chat([ diff --git a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx index 64d546a53e53..b4e2d125928b 100644 --- a/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx +++ b/webviews/src/agent-session/acpmux/conversation/IncomingMessageCard.tsx @@ -35,6 +35,7 @@ import { Markdown } from "./Markdown"; import { AgentMark } from "../../shared/AgentMark"; import { useSenderChat, useSenderPlace } from "../subagents/subsessions"; import { useAgentAvatar } from "../agentAvatar"; +import { useChiefFace } from "../chiefFace"; import { DisclosureRow } from "../subagents/DisclosureRow"; import "../subagents/AgentCard.css"; import { MessageScopeContext, type MessageScope } from "../chips/messageScope"; @@ -117,7 +118,11 @@ export function IncomingMessageLine({ // eslint-disable-next-line react-hooks/exhaustive-deps -- a new match opens, not a new callback }, [inText, inRaw, reveal]); const raw = message.raw.trim() === message.text ? undefined : message.raw; - const sender = useSenderChat(message.name, message.sessionId); + // A Chief's message names the Chief (chiefFace.ts): its name and avatar as the sidebar shows them. + // Its turn session is no chat of the list, so no listed chat with its name stands in for it. + const chief = useChiefFace(message.chief); + const name = (message.chief && chief?.name) || message.name; + const sender = useSenderChat(message.chief ? "" : message.name, message.sessionId); const sessionId = sender.sessionId; const place = useSenderPlace(sessionId); const avatar = useAgentAvatar(sessionId); @@ -132,10 +137,10 @@ export function IncomingMessageLine({ const preview = useMemo(() => messagePreview(body), [body]); // "Open chief-ui (huge-blue)": the chat Open chat opens, and the other machine it runs on. const openTitle = sender.host - ? t("subagents.openOnHost", { name: message.name, host: sender.host }) - : t("subagents.open", { name: message.name }); + ? t("subagents.openOnHost", { name, host: sender.host }) + : t("subagents.open", { name }); // A sub-session family's sender says who it is to this chat. - const from = message.relation ? t(RELATION_LABEL[message.relation], { name: message.name }) : message.name; + const from = message.relation ? t(RELATION_LABEL[message.relation], { name }) : name; // A reply needs a chat to go to and the message's id to answer: acpmux's `agent_message`. const replies = onReply !== undefined && message.sessionId !== undefined && message.msgId !== undefined; const [reply, setReplyState] = useState<"closed" | "open" | "sent" | "failed">(() => @@ -168,8 +173,13 @@ export function IncomingMessageLine({ if (!next && editing) setReply("closed"); show(next); }; - const icon = avatar ? ( - + const picture = message.chief ? chief?.src : avatar; + const icon = picture ? ( + + ) : message.chief && chief?.text ? ( + ) : ( } /> ); @@ -190,9 +200,10 @@ export function IncomingMessageLine({ } - onOpen={sessionId ? () => onOpen(sessionId) : undefined} - openLabel={sessionId ? t("subsession.openChat") : undefined} - openTitle={sessionId ? openTitle : undefined} + // A Chief's turn session is no chat to open. + onOpen={sessionId && !message.chief ? () => onOpen(sessionId) : undefined} + openLabel={sessionId && !message.chief ? t("subsession.openChat") : undefined} + openTitle={sessionId && !message.chief ? openTitle : undefined} // It opens at once, its height alone moving, as the message's card always did; closing fades. fadeIn={false} actions={ @@ -258,7 +269,7 @@ export function IncomingMessageLine({ ): string | undefined => + typeof provenance.fromChief === "string" && CHIEF_ID.test(provenance.fromChief) ? provenance.fromChief : undefined; export type IncomingMessage = { /// The sender's name, or its address when it gives none. @@ -39,6 +44,9 @@ export type IncomingMessage = { sessionId?: string; /// Who the sender is to this chat, for a sub-session family's message. relation?: IncomingRelation; + /// The Chief whose turn sent it (acpmux `fromChief`, its participant id): the line shows that + /// Chief's name and avatar (chiefFace.ts), not the turn's session name (manaflow-ai/cmux#27914). + chief?: string; /// The message's id and its thread's (the `agent_message` provenance), which a reply names /// (IncomingMessageCard.tsx's Reply); a Claude Code envelope has neither. msgId?: string; @@ -248,9 +256,11 @@ export function incomingPrompt( provenance?.kind === "session_message" || provenance?.kind === "task_result"; if (fromSession && typeof provenance.from === "string" && provenance.from) { + // A Chief's turn: the Chief, whose turn session is its own and is no chat to open. + const chief = chiefOf(provenance); // `from` is the sender's raw session id, always a chat here; its short name, when it is a // sub-session itself, reads better than its full name (`claude-sr2/coordinator`). - const sessionId = provenance.from.replace(/^acp:/, "") || undefined; + const sessionId = chief ? undefined : provenance.from.replace(/^acp:/, "") || undefined; const name = trimmed(provenance.fromShortName) ?? trimmed(provenance.fromName) ?? provenance.from; const relation = typeof provenance.fromRole === "string" && RELATIONS.includes(provenance.fromRole) @@ -264,6 +274,7 @@ export function incomingPrompt( message: { name: signed?.name ?? name, ...(sessionId && { sessionId }), + ...(chief && { chief }), ...(relation && { relation }), ...remote, text: signed?.text ?? text.trim(), @@ -300,6 +311,8 @@ export function incomingPrompt( // here, which the card opens; a terminal agent (`term_…`) or a person at a shell (`cli`) is not // one. const sessionId = provenance.from.startsWith("acp:") ? provenance.from.slice(4) || undefined : undefined; + // A Chief's turn (`agents_chat_send`): the line names the Chief; Reply still answers its turn. + const chief = chiefOf(provenance); const body = (renderedBody(text, provenance.msgId) ?? text).trim(); const signed = agentSigned(body, [provenance.fromName]); const id = (key: string) => @@ -309,6 +322,7 @@ export function incomingPrompt( message: { name: signed?.name ?? name, ...(sessionId && { sessionId }), + ...(chief && { chief }), ...id("msgId"), ...id("threadId"), ...remote, diff --git a/webviews/src/agent-session/acpmux/subagents/AgentCard.css b/webviews/src/agent-session/acpmux/subagents/AgentCard.css index dbf4e4e65dac..2ca2f5fc1859 100644 --- a/webviews/src/agent-session/acpmux/subagents/AgentCard.css +++ b/webviews/src/agent-session/acpmux/subagents/AgentCard.css @@ -96,3 +96,15 @@ white-space: pre-wrap; overflow-wrap: anywhere; } +/* A Chief's text avatar (chiefFace.ts): its letters or emoji where its picture would be. */ +.cv-chief-text-avatar { + display: inline-flex; + width: 16px; + aspect-ratio: 1; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--cv-card-fill); + font-size: var(--text-detail); + line-height: 1; +} diff --git a/webviews/test/incoming-sender-chip.test.tsx b/webviews/test/incoming-sender-chip.test.tsx index 3e170cf2faad..967235113f64 100644 --- a/webviews/test/incoming-sender-chip.test.tsx +++ b/webviews/test/incoming-sender-chip.test.tsx @@ -58,6 +58,8 @@ afterAll(() => { const { createRoot } = await import("react-dom/client"); const { IncomingMessageLine } = await import("../src/agent-session/acpmux/conversation/IncomingMessageCard"); const { SubsessionContext, SubsessionStore } = await import("../src/agent-session/acpmux/subagents/subsessions"); +const { setChiefFaceHost } = await import("../src/agent-session/acpmux/chiefFace"); +const { incomingPrompt } = await import("../src/agent-session/acpmux/conversation/incomingMessage"); const t = translatorFor("en"); const entry = (fields: Partial & { sessionId: string }) => @@ -68,7 +70,7 @@ const sessions = [ entry({ sessionId: "mux-deputy", name: "coord-deputy", hostKind: "local" }), ]; -async function line(message: { name: string; sessionId?: string }) { +async function line(message: { name: string; sessionId?: string; chief?: string }) { const store = new SubsessionStore(); store.set(sessions); const opened: string[] = []; @@ -119,3 +121,60 @@ test("a sender no list names has no Open chat: its line only opens the message", expect(host.querySelector(".cv-received")?.getAttribute("data-open")).toBe("true"); expect(opened).toEqual([]); }); + +// manaflow-ai/cmux#27914: a Chief's turn messages a GUI agent chat, by `agents_chat_send` (the cmux +// daemon's pump: an `agent_message` from `acp:`) or by `acpmux send` from inside the turn (a +// `session_message`). acpmux names the Chief (`fromChief`, prompt_provenance.rs `stamp_chief`); the +// line and its From: line name the Chief and draw its avatar as the sidebar does, not the turn's +// session name (`optchat--`) and a robot, and Open chat never opens the turn's session. +const { setMessageOpened } = await import("../src/agent-session/acpmux/conversation/incomingOpened"); +const sent = "Sidebar fix is up; CI is green."; +const chiefMessages = { + agent_message: { + kind: "agent_message", + from: "acp:turn-session-1", + fromName: "optchat-b902d10e-2991", + to: "acp:me", + msgId: "m-1", + threadId: "t-1", + fromChief: "agent_ops", + }, + session_message: { + kind: "session_message", + from: "turn-session-1", + fromName: "optchat-b902d10e-2991", + to: "me", + fromChief: "agent_ops", + }, +}; + +for (const [kind, provenance] of Object.entries(chiefMessages)) { + test(`a Chief's ${kind} shows the Chief's name and avatar, not its turn session`, async () => { + const asked: unknown[] = []; + const picture = "data:image/png;base64,QUJD"; + setChiefFaceHost(async (method, params) => { + asked.push([method, params]); + return { name: "Ops Chief", src: picture }; + }); + // Opened, so its From: line shows. + setMessageOpened(undefined, "row-1", true); + try { + const parsed = incomingPrompt(sent, { _meta: { provenance } }); + const message = parsed?.kind === "message" ? parsed.message : undefined; + expect(message).toMatchObject({ chief: "agent_ops" }); + const { open, from } = await line(message!); + await act(async () => undefined); + expect(asked).toEqual([["chief.face", { chief: "agent_ops" }]]); + expect(from?.textContent).toBe("Ops Chief"); + expect(host.querySelector(".cv-disclosure__icon img.cv-agent-avatar")?.getAttribute("src")).toBe(picture); + expect(open).toBeNull(); + expect(host.querySelector(".cv-received__from-line")?.textContent).toBe( + t("message.fromHeader", { name: "Ops Chief" }), + ); + expect(host.textContent).not.toContain("optchat-"); + } finally { + setMessageOpened(undefined, "row-1", false); + setChiefFaceHost(undefined); + } + }); +}