add skills to the chat composer (#8881)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-04-28 14:21:45 -07:00
committed by GitHub
parent 23d3db445f
commit 69efb796ae
49 changed files with 2086 additions and 540 deletions
@@ -245,6 +245,49 @@ describe("acpNotificationHandler", () => {
});
});
it("replay restores skill chips from assistant-only user chunks", async () => {
const replaySessionId = "replay-skill-session";
useChatStore.setState({
loadingSessionIds: new Set<string>([replaySessionId]),
});
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "user_message_chunk",
messageId: "user-1",
content: {
type: "text",
text: "Use these skills for this request: capture-task.",
annotations: { audience: ["assistant"] },
},
},
} as never);
await handleSessionNotification({
sessionId: replaySessionId,
update: {
sessionUpdate: "user_message_chunk",
messageId: "user-1",
content: {
type: "text",
text: "redo the settings modal",
},
},
} as never);
const buffer = getReplayBuffer(replaySessionId);
expect(buffer).toHaveLength(1);
expect(buffer?.[0]).toMatchObject({
id: "user-1",
role: "user",
content: [{ type: "text", text: "redo the settings modal" }],
metadata: {
chips: [{ label: "capture-task", type: "skill" }],
},
});
});
it("replay preserves gooseSessionId in MCP app payloads before tracker registration", async () => {
const replaySessionId = "replay-goose-session-2";
useChatStore.setState({
+9 -1
View File
@@ -20,6 +20,7 @@ export interface AcpProvider {
export interface AcpSendMessageOptions {
systemPrompt?: string;
assistantPrompt?: string;
personaId?: string;
personaName?: string;
/** Image attachments as [base64Data, mimeType] pairs. */
@@ -64,7 +65,7 @@ export async function acpSendMessage(
prompt: string,
options: AcpSendMessageOptions = {},
): Promise<void> {
const { systemPrompt, personaId, images } = options;
const { systemPrompt, assistantPrompt, personaId, images } = options;
const sid = sessionId.slice(0, 8);
const tStart = performance.now();
@@ -81,6 +82,13 @@ export async function acpSendMessage(
annotations: { audience: ["assistant"] },
});
}
if (assistantPrompt?.trim()) {
content.push({
type: "text",
text: assistantPrompt,
annotations: { audience: ["assistant"] },
});
}
content.push({ type: "text", text: prompt });
if (images) {
for (const [data, mimeType] of images) {
@@ -5,16 +5,15 @@ import type {
import { useChatStore } from "@/features/chat/stores/chatStore";
import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore";
import {
ensureReplayBuffer,
getBufferedMessage,
findLatestUnpairedToolRequest,
} from "@/features/chat/hooks/replayBuffer";
import type {
TextContent,
ToolRequestContent,
ToolResponseContent,
} from "@/shared/types/messages";
import type { AcpNotificationHandler } from "./acpConnection";
import { handleReplayUserMessageChunk } from "./acpSkillReplayChips";
import {
attachMcpAppPayload,
extractToolResultText,
@@ -168,31 +167,7 @@ function handleReplay(
clearReplayAssistantMessage(sessionId);
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 (
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: [textBlock],
metadata: { userVisible: true, agentVisible: true },
});
} else {
existing.content.push(textBlock);
}
handleReplayUserMessageChunk(sessionId, messageId, update.content);
break;
}
@@ -469,13 +444,6 @@ function findStreamingMessageId(sessionId: string): string | null {
.streamingMessageId;
}
function makeTextBlock(
text: string,
ann?: TextContent["annotations"],
): TextContent {
return { type: "text", text, ...(ann ? { annotations: ann } : {}) };
}
function ensureLiveAssistantMessage(
sessionId: string,
gooseSessionId: string,
@@ -0,0 +1,124 @@
import { parseSkillInstructionPrompt } from "@/features/skills/lib/skillChatPrompt";
import {
ensureReplayBuffer,
getBufferedMessage,
} from "@/features/chat/hooks/replayBuffer";
import type { MessageChip, TextContent } from "@/shared/types/messages";
const pendingReplayChips = new Map<string, Map<string, MessageChip[]>>();
export function getPendingReplayChips(sessionId: string, messageId: string) {
const byMessage = pendingReplayChips.get(sessionId);
return byMessage?.get(messageId) ?? [];
}
export function setPendingReplayChips(
sessionId: string,
messageId: string,
chips: MessageChip[],
) {
if (chips.length === 0) return;
const byMessage = pendingReplayChips.get(sessionId) ?? new Map();
byMessage.set(messageId, chips);
pendingReplayChips.set(sessionId, byMessage);
}
export function clearPendingReplayChips(sessionId: string, messageId: string) {
const byMessage = pendingReplayChips.get(sessionId);
if (!byMessage) return;
byMessage.delete(messageId);
if (byMessage.size === 0) {
pendingReplayChips.delete(sessionId);
}
}
export function skillInstructionToChips(text: string): MessageChip[] {
return parseSkillInstructionPrompt(text).map((label) => ({
label,
type: "skill" as const,
}));
}
export function handleReplayUserMessageChunk(
sessionId: string,
messageId: string,
content: { text: string },
): void {
const buffer = ensureReplayBuffer(sessionId);
const existing = getBufferedMessage(sessionId, messageId);
const ann = getTextAnnotations(content);
if (isAssistantOnly(ann)) {
const chips = skillInstructionToChips(content.text);
if (chips.length > 0) {
attachReplayChips(sessionId, messageId, existing, chips);
}
return;
}
const textBlock = makeTextBlock(content.text, ann);
const chips = getPendingReplayChips(sessionId, messageId);
if (!existing) {
buffer.push({
id: messageId,
role: "user",
created: Date.now(),
content: [textBlock],
metadata: {
userVisible: true,
agentVisible: true,
...(chips.length > 0 ? { chips } : {}),
},
});
} else {
existing.content.push(textBlock);
attachReplayChips(sessionId, messageId, existing, chips);
}
clearPendingReplayChips(sessionId, messageId);
}
export function clearSkillReplayChips(): void {
pendingReplayChips.clear();
}
function getTextAnnotations(content: {
text: string;
annotations?: unknown;
}): TextContent["annotations"] | undefined {
const rawAnn = content.annotations;
return typeof rawAnn === "object" && rawAnn !== null
? (rawAnn as TextContent["annotations"])
: undefined;
}
function isAssistantOnly(ann?: TextContent["annotations"]) {
return Boolean(
ann?.audience && ann.audience.length > 0 && !ann.audience.includes("user"),
);
}
function attachReplayChips(
sessionId: string,
messageId: string,
existing: ReturnType<typeof getBufferedMessage>,
chips: MessageChip[],
) {
if (chips.length === 0) return;
if (existing) {
existing.metadata = {
...existing.metadata,
chips: [...(existing.metadata?.chips ?? []), ...chips],
};
} else {
setPendingReplayChips(sessionId, messageId, chips);
}
}
function makeTextBlock(
text: string,
ann?: TextContent["annotations"],
): TextContent {
return ann
? { type: "text", text, annotations: ann }
: { type: "text", text };
}
@@ -108,7 +108,7 @@
},
"input": {
"ariaLabel": "Chat message input",
"placeholder": "Message {{agent}}, @ to mention agents"
"placeholder": "Message {{agent}}, @ to mention agents or skills"
},
"loading": {
"compacting": "Compacting conversation...",
@@ -118,6 +118,7 @@
"mention": {
"ariaLabel": "Mention suggestions",
"title": "Mention an agent",
"skillsTitle": "Skills",
"filesTitle": "Files"
},
"notifications": {
@@ -135,6 +136,9 @@
"clearActive": "Clear active assistant",
"defaultDescription": "No agent selected - chat directly with Goose"
},
"skill": {
"clearSelected": "Remove {{skill}} skill"
},
"queue": {
"dismiss": "Dismiss queued message",
"label": "Queued: {{text}}"
@@ -108,7 +108,7 @@
},
"input": {
"ariaLabel": "Entrada de mensaje del chat",
"placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar agentes"
"placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar agentes o habilidades"
},
"loading": {
"compacting": "Compactando conversación...",
@@ -118,6 +118,7 @@
"mention": {
"ariaLabel": "Sugerencias de menciones",
"title": "Menciona un agente",
"skillsTitle": "Habilidades",
"filesTitle": "Archivos"
},
"notifications": {
@@ -135,6 +136,9 @@
"clearActive": "Quitar asistente activo",
"defaultDescription": "Sin agente seleccionado: chatea directamente con Goose"
},
"skill": {
"clearSelected": "Quitar habilidad {{skill}}"
},
"queue": {
"dismiss": "Descartar mensaje en cola",
"label": "En cola: {{text}}"
+2
View File
@@ -242,6 +242,7 @@ export function getTextContent(message: Message): string {
export function createUserMessage(
text: string,
attachments?: MessageAttachment[],
chips?: MessageChip[],
): Message {
return {
id: crypto.randomUUID(),
@@ -252,6 +253,7 @@ export function createUserMessage(
userVisible: true,
agentVisible: true,
...(attachments ? { attachments } : {}),
...(chips && chips.length > 0 ? { chips } : {}),
},
};
}