diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 04332e3e..57149cb4 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -28,28 +28,31 @@ use crate::providers::inventory::{ }; use crate::session::session_manager::SessionType; use crate::session::{EnabledExtensionsState, Session, SessionManager}; +use crate::utils::sanitize_unicode_tags; use anyhow::Result; use fs_err as fs; use futures::future::{BoxFuture, Either}; use goose_acp_macros::custom_methods; -use rmcp::model::{CallToolResult, RawContent, ResourceContents, Role}; +use rmcp::model::{ + AnnotateAble, CallToolResult, RawContent, RawTextContent, ResourceContents, Role, +}; use sacp::schema::{ - AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse, - BlobResourceContents, CancelNotification, CloseSessionRequest, CloseSessionResponse, - ConfigOptionUpdate, Content, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResource, - EmbeddedResourceResource, FileSystemCapabilities, ForkSessionRequest, ForkSessionResponse, - ImageContent, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, - LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, Meta, ModelId, ModelInfo, - NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, - PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, - RequestPermissionRequest, ResourceLink, SessionCapabilities, SessionCloseCapabilities, - SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionId, - SessionInfo, SessionListCapabilities, SessionMode, SessionModeId, SessionModeState, - SessionModelState, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, - SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, - SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents, - ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate, - ToolCallUpdateFields, ToolKind, Usage, UsageUpdate, + AgentCapabilities, Annotations, AuthMethod, AuthMethodAgent, AuthenticateRequest, + AuthenticateResponse, BlobResourceContents, CancelNotification, CloseSessionRequest, + CloseSessionResponse, ConfigOptionUpdate, Content, ContentBlock, ContentChunk, + CurrentModeUpdate, EmbeddedResource, EmbeddedResourceResource, FileSystemCapabilities, + ForkSessionRequest, ForkSessionResponse, ImageContent, InitializeRequest, InitializeResponse, + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + McpCapabilities, McpServer, Meta, ModelId, ModelInfo, NewSessionRequest, NewSessionResponse, + PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, + RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, + SessionCloseCapabilities, SessionConfigOption, SessionConfigOptionCategory, + SessionConfigSelectOption, SessionId, SessionInfo, SessionListCapabilities, SessionMode, + SessionModeId, SessionModeState, SessionModelState, SessionNotification, SessionUpdate, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, + SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason, + TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, + ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, Usage, UsageUpdate, }; use sacp::util::MatchDispatchFrom; use sacp::{ @@ -1155,16 +1158,49 @@ impl GooseAcpAgent { self.sessions.lock().await.contains_key(session_id) } - fn convert_acp_prompt_to_message(&self, prompt: Vec) -> Message { - let mut user_message = Message::user(); - + /// Convert ACP prompt content blocks into a user message. + fn convert_acp_prompt_to_message(prompt: &[ContentBlock]) -> Message { + let mut message = Message::user(); for block in prompt { match block { ContentBlock::Text(text) => { - user_message = user_message.with_text(&text.text); + let annotated = if let Some(ref ann) = text.annotations { + let audience: Vec = ann + .audience + .as_ref() + .map(|roles| { + roles + .iter() + .filter_map(|r| match r { + sacp::schema::Role::Assistant => Some(Role::Assistant), + sacp::schema::Role::User => Some(Role::User), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + let raw = RawTextContent { + text: sanitize_unicode_tags(&text.text), + meta: None, + }; + if audience.is_empty() { + raw.no_annotation() + } else { + raw.no_annotation().with_audience(audience) + } + } else { + // No annotations — regular user text. + let sanitized = sanitize_unicode_tags(&text.text); + RawTextContent { + text: sanitized, + meta: None, + } + .no_annotation() + }; + message = message.with_content(MessageContent::Text(annotated)); } ContentBlock::Image(image) => { - user_message = user_message.with_image(&image.data, &image.mime_type); + message = message.with_image(&image.data, &image.mime_type); } ContentBlock::Resource(resource) => { if let EmbeddedResourceResource::TextResourceContents(text_resource) = @@ -1172,19 +1208,18 @@ impl GooseAcpAgent { { let header = format!("--- Resource: {} ---\n", text_resource.uri); let content = format!("{}{}\n---\n", header, text_resource.text); - user_message = user_message.with_text(&content); + message = message.with_text(&content); } } ContentBlock::ResourceLink(link) => { - if let Some(text) = read_resource_link(link) { - user_message = user_message.with_text(text) + if let Some(text) = read_resource_link(link.clone()) { + message = message.with_text(text); } } ContentBlock::Audio(..) | _ => (), } } - - user_message + message } async fn handle_message_content( @@ -1943,9 +1978,21 @@ impl GooseAcpAgent { for content_item in &message.content { match content_item { MessageContent::Text(text) => { - let chunk = ContentChunk::new(ContentBlock::Text(TextContent::new( - text.text.clone(), - ))); + let mut tc = TextContent::new(text.text.clone()); + if let Some(audience) = text.audience() { + tc = tc.annotations( + Annotations::new().audience( + audience + .iter() + .map(|r| match r { + Role::Assistant => sacp::schema::Role::Assistant, + Role::User => sacp::schema::Role::User, + }) + .collect::>(), + ), + ); + } + let chunk = ContentChunk::new(ContentBlock::Text(tc)); let update = match message.role { Role::User => SessionUpdate::UserMessageChunk(chunk), Role::Assistant => SessionUpdate::AgentMessageChunk(chunk), @@ -2150,9 +2197,10 @@ impl GooseAcpAgent { .await?; debug!(target: "perf", sid = %sid, ms = t_agent.elapsed().as_millis() as u64, "perf: prompt get_session_agent (waits for agent setup)"); - let user_message = self.convert_acp_prompt_to_message(args.prompt); + let user_message = Self::convert_acp_prompt_to_message(&args.prompt); let t_persist = std::time::Instant::now(); + // Persist user message (may contain assistant-only annotated blocks) self.thread_manager .append_message(&thread_id, Some(&internal_session_id), &user_message) .await diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index f5fda520..d7d0808a 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -97,6 +97,14 @@ interface ContentSection { items: MessageContent[] | ToolChainItem[]; } +/** Keep only content blocks whose audience includes "user" (or has no audience). */ +function filterUserVisibleContent(content: MessageContent[]): MessageContent[] { + return content.filter((b) => { + const aud = b.annotations?.audience; + return !aud || aud.length === 0 || aud.includes("user"); + }); +} + function findMatchingToolChainIndex( items: ToolChainItem[], response: ToolResponseContent, @@ -224,29 +232,17 @@ function renderContentBlock( case "toolResponse": // Handled by groupContentSections toolChain rendering return null; - case "thinking": { - const th = content as ThinkingContent; - return ( - - - {th.text} - - ); - } + case "thinking": case "reasoning": { - const r = content as ReasoningContentType; + const text = (content as ThinkingContent | ReasoningContentType).text; return ( - {r.text} + {text} ); } @@ -318,7 +314,10 @@ export const MessageBubble = memo(function MessageBubble({ }: MessageBubbleProps) { const { t } = useTranslation(["chat", "common"]); const { formatDate } = useLocaleFormatting(); - const { role, content, created } = message; + const { role, content: rawContent, created } = message; + // Only user messages carry annotated blocks; skip the filter for others. + const content = + role === "user" ? filterUserVisibleContent(rawContent) : rawContent; const { handleContentClick, pathNotice } = useArtifactLinkHandler(); const persona = useAgentStore((state) => message.metadata?.personaId @@ -328,6 +327,9 @@ export const MessageBubble = memo(function MessageBubble({ const { isCopied: isCopyConfirmed, copyToClipboard } = useCopyToClipboard(); const personaAvatarUrl = useAvatarSrc(persona?.avatar); + // Skip empty user bubbles (all blocks filtered as assistant-only). + if (role === "user" && content.length === 0) return null; + const textContent = content .filter((c): c is TextContent => c.type === "text") .map((c) => c.text) @@ -347,7 +349,6 @@ export const MessageBubble = memo(function MessageBubble({ ); } - const isUser = role === "user"; const assistantProviderId = message.metadata?.providerId; const assistantProviderName = assistantProviderId diff --git a/ui/goose2/src/shared/api/acp.ts b/ui/goose2/src/shared/api/acp.ts index 087964f1..e303ec21 100644 --- a/ui/goose2/src/shared/api/acp.ts +++ b/ui/goose2/src/shared/api/acp.ts @@ -73,12 +73,15 @@ export async function acpSendMessage( throw new Error("Session not prepared. Call acpPrepareSession first."); } - const hasSystem = systemPrompt && systemPrompt.trim().length > 0; - const effectivePrompt = hasSystem - ? `\n${systemPrompt}\n\n\n\n${prompt}\n` - : prompt; - - const content: ContentBlock[] = [{ type: "text", text: effectivePrompt }]; + const content: ContentBlock[] = []; + if (systemPrompt?.trim()) { + content.push({ + type: "text", + text: systemPrompt, + annotations: { audience: ["assistant"] }, + }); + } + content.push({ type: "text", text: prompt }); if (images) { for (const [data, mimeType] of images) { content.push({ type: "image", data, mimeType } as ContentBlock); diff --git a/ui/goose2/src/shared/api/acpNotificationHandler.ts b/ui/goose2/src/shared/api/acpNotificationHandler.ts index 3d623d37..2ec627d9 100644 --- a/ui/goose2/src/shared/api/acpNotificationHandler.ts +++ b/ui/goose2/src/shared/api/acpNotificationHandler.ts @@ -10,6 +10,7 @@ import { findLatestUnpairedToolRequest, } from "@/features/chat/hooks/replayBuffer"; import type { + TextContent, ToolRequestContent, ToolResponseContent, } from "@/shared/types/messages"; @@ -196,32 +197,32 @@ function handleReplay(sessionId: string, update: SessionUpdate): void { } case "user_message_chunk": { + if (update.content.type !== "text" || !("text" in update.content)) break; const messageId = update.messageId ?? crypto.randomUUID(); const buffer = ensureReplayBuffer(sessionId); const existing = getBufferedMessage(sessionId, messageId); + // biome-ignore lint/suspicious/noExplicitAny: wire format has annotations but SDK types don't + const rawAnn = (update.content as any).annotations; + const ann: TextContent["annotations"] | undefined = + typeof rawAnn === "object" && rawAnn !== null ? rawAnn : undefined; + // Drop assistant-only blocks so they never enter chat state. if ( - !existing && - update.content.type === "text" && - "text" in update.content - ) { + ann?.audience && + ann.audience.length > 0 && + !ann.audience.includes("user") + ) + break; + const textBlock = makeTextBlock(update.content.text, ann); + if (!existing) { buffer.push({ id: messageId, role: "user", created: Date.now(), - content: [{ type: "text", text: update.content.text }], + content: [textBlock], metadata: { userVisible: true, agentVisible: true }, }); - } else if ( - existing && - update.content.type === "text" && - "text" in update.content - ) { - const last = existing.content[existing.content.length - 1]; - if (last?.type === "text") { - (last as { type: "text"; text: string }).text += update.content.text; - } else { - existing.content.push({ type: "text", text: update.content.text }); - } + } else { + existing.content.push(textBlock); } break; } @@ -486,6 +487,13 @@ function findStreamingMessageId(sessionId: string): string | null { .streamingMessageId; } +function makeTextBlock( + text: string, + ann?: TextContent["annotations"], +): TextContent { + return { type: "text", text, ...(ann ? { annotations: ann } : {}) }; +} + function findMessageInBuffer( sessionId: string, _toolCallId: string, diff --git a/ui/goose2/src/shared/types/messages.ts b/ui/goose2/src/shared/types/messages.ts index 24b3ed9e..d557c06b 100644 --- a/ui/goose2/src/shared/types/messages.ts +++ b/ui/goose2/src/shared/types/messages.ts @@ -33,10 +33,19 @@ export type ChatAttachmentDraft = // Message roles export type MessageRole = "user" | "assistant" | "system"; +/** ACP audience restriction — which roles may see a content block. */ +export type Audience = ("user" | "assistant")[]; + +/** ACP content-block annotations (mirrors the SDK's Annotations shape). */ +export interface ContentAnnotations { + audience?: Audience; +} + // Content block types export interface TextContent { type: "text"; text: string; + annotations?: ContentAnnotations; } export interface ImageContent { @@ -44,6 +53,7 @@ export interface ImageContent { source: | { type: "base64"; mediaType: string; data: string } | { type: "url"; url: string }; + annotations?: ContentAnnotations; } export type ToolCallStatus = @@ -67,6 +77,7 @@ export interface ToolRequestContent { status: ToolCallStatus; /** Epoch ms when the tool call started executing (set on event receipt). */ startedAt?: number; + annotations?: ContentAnnotations; } export interface ToolResponseContent { @@ -75,20 +86,24 @@ export interface ToolResponseContent { name: string; result: string; isError: boolean; + annotations?: ContentAnnotations; } export interface ThinkingContent { type: "thinking"; text: string; + annotations?: ContentAnnotations; } export interface RedactedThinkingContent { type: "redactedThinking"; + annotations?: ContentAnnotations; } export interface ReasoningContent { type: "reasoning"; text: string; + annotations?: ContentAnnotations; } export interface ActionRequiredContent { @@ -99,12 +114,14 @@ export interface ActionRequiredContent { toolName?: string; arguments?: Record; schema?: Record; + annotations?: ContentAnnotations; } export interface SystemNotificationContent { type: "systemNotification"; notificationType: "compaction" | "info" | "warning" | "error"; text: string; + annotations?: ContentAnnotations; } export type MessageContent =