add skills to the chat composer (#8881)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user