fix: preprompt would show after loading session (#8744)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-04-23 11:06:27 +12:00
committed by GitHub
parent e7e50320a1
commit 4928ce55e2
5 changed files with 148 additions and 71 deletions
+79 -31
View File
@@ -28,28 +28,31 @@ use crate::providers::inventory::{
}; };
use crate::session::session_manager::SessionType; use crate::session::session_manager::SessionType;
use crate::session::{EnabledExtensionsState, Session, SessionManager}; use crate::session::{EnabledExtensionsState, Session, SessionManager};
use crate::utils::sanitize_unicode_tags;
use anyhow::Result; use anyhow::Result;
use fs_err as fs; use fs_err as fs;
use futures::future::{BoxFuture, Either}; use futures::future::{BoxFuture, Either};
use goose_acp_macros::custom_methods; 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::{ use sacp::schema::{
AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse, AgentCapabilities, Annotations, AuthMethod, AuthMethodAgent, AuthenticateRequest,
BlobResourceContents, CancelNotification, CloseSessionRequest, CloseSessionResponse, AuthenticateResponse, BlobResourceContents, CancelNotification, CloseSessionRequest,
ConfigOptionUpdate, Content, ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResource, CloseSessionResponse, ConfigOptionUpdate, Content, ContentBlock, ContentChunk,
EmbeddedResourceResource, FileSystemCapabilities, ForkSessionRequest, ForkSessionResponse, CurrentModeUpdate, EmbeddedResource, EmbeddedResourceResource, FileSystemCapabilities,
ImageContent, InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse, ForkSessionRequest, ForkSessionResponse, ImageContent, InitializeRequest, InitializeResponse,
LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, Meta, ModelId, ModelInfo, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, McpCapabilities, McpServer, Meta, ModelId, ModelInfo, NewSessionRequest, NewSessionResponse,
PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, PermissionOption, PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse,
RequestPermissionRequest, ResourceLink, SessionCapabilities, SessionCloseCapabilities, RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities,
SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, SessionId, SessionCloseCapabilities, SessionConfigOption, SessionConfigOptionCategory,
SessionInfo, SessionListCapabilities, SessionMode, SessionModeId, SessionModeState, SessionConfigSelectOption, SessionId, SessionInfo, SessionListCapabilities, SessionMode,
SessionModelState, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, SessionModeId, SessionModeState, SessionModelState, SessionNotification, SessionUpdate,
SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest,
SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents, SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, StopReason,
ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate, TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation,
ToolCallUpdateFields, ToolKind, Usage, UsageUpdate, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, Usage, UsageUpdate,
}; };
use sacp::util::MatchDispatchFrom; use sacp::util::MatchDispatchFrom;
use sacp::{ use sacp::{
@@ -1155,16 +1158,49 @@ impl GooseAcpAgent {
self.sessions.lock().await.contains_key(session_id) self.sessions.lock().await.contains_key(session_id)
} }
fn convert_acp_prompt_to_message(&self, prompt: Vec<ContentBlock>) -> Message { /// Convert ACP prompt content blocks into a user message.
let mut user_message = Message::user(); fn convert_acp_prompt_to_message(prompt: &[ContentBlock]) -> Message {
let mut message = Message::user();
for block in prompt { for block in prompt {
match block { match block {
ContentBlock::Text(text) => { ContentBlock::Text(text) => {
user_message = user_message.with_text(&text.text); let annotated = if let Some(ref ann) = text.annotations {
let audience: Vec<Role> = 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) => { 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) => { ContentBlock::Resource(resource) => {
if let EmbeddedResourceResource::TextResourceContents(text_resource) = if let EmbeddedResourceResource::TextResourceContents(text_resource) =
@@ -1172,19 +1208,18 @@ impl GooseAcpAgent {
{ {
let header = format!("--- Resource: {} ---\n", text_resource.uri); let header = format!("--- Resource: {} ---\n", text_resource.uri);
let content = format!("{}{}\n---\n", header, text_resource.text); let content = format!("{}{}\n---\n", header, text_resource.text);
user_message = user_message.with_text(&content); message = message.with_text(&content);
} }
} }
ContentBlock::ResourceLink(link) => { ContentBlock::ResourceLink(link) => {
if let Some(text) = read_resource_link(link) { if let Some(text) = read_resource_link(link.clone()) {
user_message = user_message.with_text(text) message = message.with_text(text);
} }
} }
ContentBlock::Audio(..) | _ => (), ContentBlock::Audio(..) | _ => (),
} }
} }
message
user_message
} }
async fn handle_message_content( async fn handle_message_content(
@@ -1943,9 +1978,21 @@ impl GooseAcpAgent {
for content_item in &message.content { for content_item in &message.content {
match content_item { match content_item {
MessageContent::Text(text) => { MessageContent::Text(text) => {
let chunk = ContentChunk::new(ContentBlock::Text(TextContent::new( let mut tc = TextContent::new(text.text.clone());
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::<Vec<_>>(),
),
);
}
let chunk = ContentChunk::new(ContentBlock::Text(tc));
let update = match message.role { let update = match message.role {
Role::User => SessionUpdate::UserMessageChunk(chunk), Role::User => SessionUpdate::UserMessageChunk(chunk),
Role::Assistant => SessionUpdate::AgentMessageChunk(chunk), Role::Assistant => SessionUpdate::AgentMessageChunk(chunk),
@@ -2150,9 +2197,10 @@ impl GooseAcpAgent {
.await?; .await?;
debug!(target: "perf", sid = %sid, ms = t_agent.elapsed().as_millis() as u64, "perf: prompt get_session_agent (waits for agent setup)"); 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(); let t_persist = std::time::Instant::now();
// Persist user message (may contain assistant-only annotated blocks)
self.thread_manager self.thread_manager
.append_message(&thread_id, Some(&internal_session_id), &user_message) .append_message(&thread_id, Some(&internal_session_id), &user_message)
.await .await
@@ -97,6 +97,14 @@ interface ContentSection {
items: MessageContent[] | ToolChainItem[]; 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( function findMatchingToolChainIndex(
items: ToolChainItem[], items: ToolChainItem[],
response: ToolResponseContent, response: ToolResponseContent,
@@ -224,29 +232,17 @@ function renderContentBlock(
case "toolResponse": case "toolResponse":
// Handled by groupContentSections toolChain rendering // Handled by groupContentSections toolChain rendering
return null; return null;
case "thinking": { case "thinking":
const th = content as ThinkingContent;
return (
<Reasoning
key={`thinking-${index}`}
isStreaming={isStreamingMsg}
defaultOpen={false}
>
<ReasoningTrigger />
<ReasoningContent>{th.text}</ReasoningContent>
</Reasoning>
);
}
case "reasoning": { case "reasoning": {
const r = content as ReasoningContentType; const text = (content as ThinkingContent | ReasoningContentType).text;
return ( return (
<Reasoning <Reasoning
key={`reasoning-${index}`} key={`${content.type}-${index}`}
isStreaming={isStreamingMsg} isStreaming={isStreamingMsg}
defaultOpen={false} defaultOpen={false}
> >
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent>{r.text}</ReasoningContent> <ReasoningContent>{text}</ReasoningContent>
</Reasoning> </Reasoning>
); );
} }
@@ -318,7 +314,10 @@ export const MessageBubble = memo(function MessageBubble({
}: MessageBubbleProps) { }: MessageBubbleProps) {
const { t } = useTranslation(["chat", "common"]); const { t } = useTranslation(["chat", "common"]);
const { formatDate } = useLocaleFormatting(); 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 { handleContentClick, pathNotice } = useArtifactLinkHandler();
const persona = useAgentStore((state) => const persona = useAgentStore((state) =>
message.metadata?.personaId message.metadata?.personaId
@@ -328,6 +327,9 @@ export const MessageBubble = memo(function MessageBubble({
const { isCopied: isCopyConfirmed, copyToClipboard } = useCopyToClipboard(); const { isCopied: isCopyConfirmed, copyToClipboard } = useCopyToClipboard();
const personaAvatarUrl = useAvatarSrc(persona?.avatar); 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 const textContent = content
.filter((c): c is TextContent => c.type === "text") .filter((c): c is TextContent => c.type === "text")
.map((c) => c.text) .map((c) => c.text)
@@ -347,7 +349,6 @@ export const MessageBubble = memo(function MessageBubble({
</div> </div>
); );
} }
const isUser = role === "user"; const isUser = role === "user";
const assistantProviderId = message.metadata?.providerId; const assistantProviderId = message.metadata?.providerId;
const assistantProviderName = assistantProviderId const assistantProviderName = assistantProviderId
+9 -6
View File
@@ -73,12 +73,15 @@ export async function acpSendMessage(
throw new Error("Session not prepared. Call acpPrepareSession first."); throw new Error("Session not prepared. Call acpPrepareSession first.");
} }
const hasSystem = systemPrompt && systemPrompt.trim().length > 0; const content: ContentBlock[] = [];
const effectivePrompt = hasSystem if (systemPrompt?.trim()) {
? `<persona-instructions>\n${systemPrompt}\n</persona-instructions>\n\n<user-message>\n${prompt}\n</user-message>` content.push({
: prompt; type: "text",
text: systemPrompt,
const content: ContentBlock[] = [{ type: "text", text: effectivePrompt }]; annotations: { audience: ["assistant"] },
});
}
content.push({ type: "text", text: prompt });
if (images) { if (images) {
for (const [data, mimeType] of images) { for (const [data, mimeType] of images) {
content.push({ type: "image", data, mimeType } as ContentBlock); content.push({ type: "image", data, mimeType } as ContentBlock);
@@ -10,6 +10,7 @@ import {
findLatestUnpairedToolRequest, findLatestUnpairedToolRequest,
} from "@/features/chat/hooks/replayBuffer"; } from "@/features/chat/hooks/replayBuffer";
import type { import type {
TextContent,
ToolRequestContent, ToolRequestContent,
ToolResponseContent, ToolResponseContent,
} from "@/shared/types/messages"; } from "@/shared/types/messages";
@@ -196,32 +197,32 @@ function handleReplay(sessionId: string, update: SessionUpdate): void {
} }
case "user_message_chunk": { case "user_message_chunk": {
if (update.content.type !== "text" || !("text" in update.content)) break;
const messageId = update.messageId ?? crypto.randomUUID(); const messageId = update.messageId ?? crypto.randomUUID();
const buffer = ensureReplayBuffer(sessionId); const buffer = ensureReplayBuffer(sessionId);
const existing = getBufferedMessage(sessionId, messageId); 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 ( if (
!existing && ann?.audience &&
update.content.type === "text" && ann.audience.length > 0 &&
"text" in update.content !ann.audience.includes("user")
) { )
break;
const textBlock = makeTextBlock(update.content.text, ann);
if (!existing) {
buffer.push({ buffer.push({
id: messageId, id: messageId,
role: "user", role: "user",
created: Date.now(), created: Date.now(),
content: [{ type: "text", text: update.content.text }], content: [textBlock],
metadata: { userVisible: true, agentVisible: true }, metadata: { userVisible: true, agentVisible: true },
}); });
} else if ( } else {
existing && existing.content.push(textBlock);
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 });
}
} }
break; break;
} }
@@ -486,6 +487,13 @@ function findStreamingMessageId(sessionId: string): string | null {
.streamingMessageId; .streamingMessageId;
} }
function makeTextBlock(
text: string,
ann?: TextContent["annotations"],
): TextContent {
return { type: "text", text, ...(ann ? { annotations: ann } : {}) };
}
function findMessageInBuffer( function findMessageInBuffer(
sessionId: string, sessionId: string,
_toolCallId: string, _toolCallId: string,
+17
View File
@@ -33,10 +33,19 @@ export type ChatAttachmentDraft =
// Message roles // Message roles
export type MessageRole = "user" | "assistant" | "system"; 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 // Content block types
export interface TextContent { export interface TextContent {
type: "text"; type: "text";
text: string; text: string;
annotations?: ContentAnnotations;
} }
export interface ImageContent { export interface ImageContent {
@@ -44,6 +53,7 @@ export interface ImageContent {
source: source:
| { type: "base64"; mediaType: string; data: string } | { type: "base64"; mediaType: string; data: string }
| { type: "url"; url: string }; | { type: "url"; url: string };
annotations?: ContentAnnotations;
} }
export type ToolCallStatus = export type ToolCallStatus =
@@ -67,6 +77,7 @@ export interface ToolRequestContent {
status: ToolCallStatus; status: ToolCallStatus;
/** Epoch ms when the tool call started executing (set on event receipt). */ /** Epoch ms when the tool call started executing (set on event receipt). */
startedAt?: number; startedAt?: number;
annotations?: ContentAnnotations;
} }
export interface ToolResponseContent { export interface ToolResponseContent {
@@ -75,20 +86,24 @@ export interface ToolResponseContent {
name: string; name: string;
result: string; result: string;
isError: boolean; isError: boolean;
annotations?: ContentAnnotations;
} }
export interface ThinkingContent { export interface ThinkingContent {
type: "thinking"; type: "thinking";
text: string; text: string;
annotations?: ContentAnnotations;
} }
export interface RedactedThinkingContent { export interface RedactedThinkingContent {
type: "redactedThinking"; type: "redactedThinking";
annotations?: ContentAnnotations;
} }
export interface ReasoningContent { export interface ReasoningContent {
type: "reasoning"; type: "reasoning";
text: string; text: string;
annotations?: ContentAnnotations;
} }
export interface ActionRequiredContent { export interface ActionRequiredContent {
@@ -99,12 +114,14 @@ export interface ActionRequiredContent {
toolName?: string; toolName?: string;
arguments?: Record<string, unknown>; arguments?: Record<string, unknown>;
schema?: Record<string, unknown>; schema?: Record<string, unknown>;
annotations?: ContentAnnotations;
} }
export interface SystemNotificationContent { export interface SystemNotificationContent {
type: "systemNotification"; type: "systemNotification";
notificationType: "compaction" | "info" | "warning" | "error"; notificationType: "compaction" | "info" | "warning" | "error";
text: string; text: string;
annotations?: ContentAnnotations;
} }
export type MessageContent = export type MessageContent =