From 8422a1157bc9da411dfef76f8dd775bc933ba1b2 Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Thu, 25 Jun 2026 10:24:59 +0100 Subject: [PATCH] feat(tasks): route local skill tags through the cloud prompt pipeline --- packages/core/src/editor/cloud-prompt.ts | 11 ++- .../core/src/message-editor/content.test.ts | 25 +++++++ packages/core/src/message-editor/content.ts | 38 +++++++--- packages/core/src/message-editor/skillTags.ts | 63 ++++++++++++++++ .../core/src/message-editor/suggestions.ts | 15 +++- packages/core/src/sessions/cloudPrompt.ts | 36 +++++++-- packages/core/src/sessions/sessionEvents.ts | 15 +++- packages/core/src/sessions/sessionService.ts | 74 ++++++++++++++++--- .../core/src/task-detail/taskCreationHost.ts | 3 + .../src/task-detail/taskCreationSaga.test.ts | 3 + .../core/src/task-detail/taskCreationSaga.ts | 1 + .../sessions/sessionServiceHost.test.ts | 1 + 12 files changed, 251 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/message-editor/skillTags.ts diff --git a/packages/core/src/editor/cloud-prompt.ts b/packages/core/src/editor/cloud-prompt.ts index 6ab68831be..969ebeb9ca 100644 --- a/packages/core/src/editor/cloud-prompt.ts +++ b/packages/core/src/editor/cloud-prompt.ts @@ -11,6 +11,7 @@ import { serializeCloudPrompt, unescapeXmlAttr, } from "@posthog/shared"; +import { skillTagsToSlashCommands } from "../message-editor/skillTags"; export type ReadFileAsBase64 = (filePath: string) => Promise; @@ -111,7 +112,11 @@ function normalizePromptText(prompt: string): string { return prompt.replace(/\n{3,}/g, "\n\n").trim(); } -export function stripAbsoluteFileTags(prompt: string): string { +export function stripSkillTags(prompt: string): string { + return skillTagsToSlashCommands(prompt); +} + +export function stripAttachmentTags(prompt: string): string { return normalizePromptText( prompt .replaceAll(ABSOLUTE_FILE_TAG_REGEX, (match, rawPath: string) => { @@ -122,6 +127,10 @@ export function stripAbsoluteFileTags(prompt: string): string { ); } +export function stripAbsoluteFileTags(prompt: string): string { + return stripSkillTags(stripAttachmentTags(prompt)); +} + export function getAbsoluteAttachmentPaths( prompt: string, filePaths: string[] = [], diff --git a/packages/core/src/message-editor/content.test.ts b/packages/core/src/message-editor/content.test.ts index beb226b2b9..c7aac6c249 100644 --- a/packages/core/src/message-editor/content.test.ts +++ b/packages/core/src/message-editor/content.test.ts @@ -180,6 +180,31 @@ describe("xmlToContent", () => { ); }); + it("round-trips a local skill command chip", () => { + const content: EditorContent = { + segments: [ + { + type: "chip", + chip: { + type: "command", + id: "/Users/alessandro/.claude/skills/local-skill", + label: "local-skill", + skillName: "local-skill", + skillSource: "user", + skillPath: "/Users/alessandro/.claude/skills/local-skill", + }, + }, + ], + }; + + expect(contentToXml(content)).toBe( + '', + ); + expect(xmlToContent(contentToXml(content)).segments).toEqual( + content.segments, + ); + }); + it("extractFilePaths includes folder chips alongside file chips", () => { const content: EditorContent = { segments: [ diff --git a/packages/core/src/message-editor/content.ts b/packages/core/src/message-editor/content.ts index 07b8646a36..c997c11add 100644 --- a/packages/core/src/message-editor/content.ts +++ b/packages/core/src/message-editor/content.ts @@ -1,4 +1,5 @@ -import { escapeXmlAttr, unescapeXmlAttr } from "@posthog/shared"; +import { escapeXmlAttr, type UploadableSkillSource } from "@posthog/shared"; +import { isUploadableSkillSource, parseXmlAttrs } from "./skillTags"; export interface MentionChip { type: @@ -15,6 +16,9 @@ export interface MentionChip { label: string; pastedText?: boolean; chipId?: string; + skillPath?: string; + skillSource?: UploadableSkillSource; + skillName?: string; } export interface FileAttachment { @@ -60,6 +64,9 @@ export function contentToXml(content: EditorContent): string { inlineFilePaths.add(chip.id); return ``; case "command": + if (chip.skillPath && chip.skillSource) { + return ``; + } if (chip.id && chip.id !== chip.label && isAbsolutePathLike(chip.id)) { return ``; } @@ -97,8 +104,7 @@ export function contentToXml(content: EditorContent): string { } const CHIP_TAG_REGEX = - /<(file|folder|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g; -const ATTR_REGEX = /(\w+)="([^"]*)"/g; + /<(file|folder|skill|error|experiment|insight|feature_flag|github_issue|github_pr)\b([^>]*?)\s*\/>/g; export function deriveFileLabel(filePath: string): string { const segments = filePath.split("/").filter(Boolean); @@ -107,16 +113,8 @@ export function deriveFileLabel(filePath: string): string { return parentDir ? `${parentDir}/${fileName}` : fileName; } -function parseAttrs(raw: string): Record { - const attrs: Record = {}; - for (const match of raw.matchAll(ATTR_REGEX)) { - attrs[match[1]] = unescapeXmlAttr(match[2]); - } - return attrs; -} - function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { - const attrs = parseAttrs(rawAttrs); + const attrs = parseXmlAttrs(rawAttrs); switch (tag) { case "file": { const path = attrs.path; @@ -128,6 +126,22 @@ function chipFromTag(tag: string, rawAttrs: string): MentionChip | null { if (!path) return null; return { type: "folder", id: path, label: deriveFileLabel(path) }; } + case "skill": { + const path = attrs.path; + const name = attrs.name; + const source = attrs.source; + if (!path || !name || !isUploadableSkillSource(source)) { + return null; + } + return { + type: "command", + id: path, + label: name, + skillPath: path, + skillSource: source, + skillName: name, + }; + } case "error": case "experiment": case "insight": diff --git a/packages/core/src/message-editor/skillTags.ts b/packages/core/src/message-editor/skillTags.ts new file mode 100644 index 0000000000..699799e67d --- /dev/null +++ b/packages/core/src/message-editor/skillTags.ts @@ -0,0 +1,63 @@ +import { type UploadableSkillSource, unescapeXmlAttr } from "@posthog/shared"; + +const SKILL_TAG_REGEX = /]*?)\s*\/>/g; +const XML_ATTR_REGEX = /(\w+)="([^"]*)"/g; + +export interface UploadableSkillTag { + name: string; + source: UploadableSkillSource; + path: string; +} + +export function parseXmlAttrs(raw: string): Record { + const attrs: Record = {}; + for (const match of raw.matchAll(XML_ATTR_REGEX)) { + attrs[match[1]] = unescapeXmlAttr(match[2]); + } + return attrs; +} + +export function isUploadableSkillSource( + source: string | undefined, +): source is UploadableSkillSource { + return ( + source === "user" || + source === "repo" || + source === "marketplace" || + source === "codex" + ); +} + +export function replaceSkillTags( + prompt: string, + replacer: (attrs: Record) => string, +): string { + return prompt.replaceAll(SKILL_TAG_REGEX, (_match, rawAttrs: string) => + replacer(parseXmlAttrs(rawAttrs)), + ); +} + +export function skillTagsToSlashCommands(prompt: string): string { + return replaceSkillTags(prompt, (attrs) => + attrs.name ? `/${attrs.name}` : "", + ); +} + +export function collectUploadableSkillTags( + prompt: string, +): UploadableSkillTag[] { + const tags: UploadableSkillTag[] = []; + + replaceSkillTags(prompt, (attrs) => { + if (attrs.name && attrs.path && isUploadableSkillSource(attrs.source)) { + tags.push({ + name: attrs.name, + source: attrs.source, + path: attrs.path, + }); + } + return ""; + }); + + return tags; +} diff --git a/packages/core/src/message-editor/suggestions.ts b/packages/core/src/message-editor/suggestions.ts index 6cffc1cb2b..ba1255c4a4 100644 --- a/packages/core/src/message-editor/suggestions.ts +++ b/packages/core/src/message-editor/suggestions.ts @@ -1,9 +1,14 @@ -import { isAbsolutePath } from "@posthog/shared"; +import { isAbsolutePath, type SkillSource } from "@posthog/shared"; import Fuse, { type IFuseOptions } from "fuse.js"; export interface CommandLike { name: string; description?: string; + localSkill?: { + name: string; + source: Exclude; + path: string; + }; } export interface FileItemLike { @@ -27,6 +32,9 @@ export interface CommandSuggestionShape { id: string; label: string; description?: string; + skillPath?: string; + skillSource?: Exclude; + skillName?: string; command: T; } @@ -75,9 +83,12 @@ export function shapeCommandSuggestions( commands: T[], ): CommandSuggestionShape[] { return commands.map((cmd) => ({ - id: cmd.name, + id: cmd.localSkill?.path ?? cmd.name, label: cmd.name, description: cmd.description, + skillPath: cmd.localSkill?.path, + skillSource: cmd.localSkill?.source, + skillName: cmd.localSkill?.name, command: cmd, })); } diff --git a/packages/core/src/sessions/cloudPrompt.ts b/packages/core/src/sessions/cloudPrompt.ts index 9a554f36cf..7b2ef124b0 100644 --- a/packages/core/src/sessions/cloudPrompt.ts +++ b/packages/core/src/sessions/cloudPrompt.ts @@ -3,14 +3,19 @@ import { buildCloudTaskDescription, getAbsoluteAttachmentPaths, stripAbsoluteFileTags, + stripAttachmentTags, + stripSkillTags, } from "@posthog/core/editor/cloud-prompt"; import type { EditorContent } from "@posthog/core/message-editor/content"; +import { collectUploadableSkillTags } from "@posthog/core/message-editor/skillTags"; import { getFileName, pathToFileUri } from "@posthog/shared"; +import type { CloudSkillBundleRef } from "./cloudArtifactIdentifiers"; const FILE_URI_PREFIX = "file://"; export interface CloudPromptTransport { filePaths: string[]; + skillBundles: CloudSkillBundleRef[]; messageText?: string; promptText: string; } @@ -61,6 +66,22 @@ function collectBlockAttachmentPaths(prompt: ContentBlock[]): string[] { return Array.from(new Set(filePaths)); } +function collectSkillBundleRefs(prompt: string): CloudSkillBundleRef[] { + const refs: CloudSkillBundleRef[] = []; + const seen = new Set(); + + for (const tag of collectUploadableSkillTags(prompt)) { + const key = `${tag.source}:${tag.path}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + refs.push(tag); + } + + return refs; +} + function summarizePrompt(text: string, filePaths: string[]): string { if (filePaths.length === 0) { return text.trim(); @@ -78,27 +99,31 @@ export function getCloudPromptTransport( ): CloudPromptTransport { if (typeof prompt === "string") { const attachmentPaths = getAbsoluteAttachmentPaths(prompt, filePaths); + const skillBundles = collectSkillBundleRefs(prompt); const messageText = stripAbsoluteFileTags(prompt).trim(); return { filePaths: attachmentPaths, + skillBundles, messageText: messageText || undefined, promptText: buildCloudTaskDescription(prompt, filePaths).trim(), }; } - const promptText = prompt + const rawPromptText = prompt .filter( (block): block is Extract => block.type === "text", ) .map((block) => block.text) - .join("") - .trim(); + .join(""); + const promptText = stripSkillTags(rawPromptText).trim(); const attachmentPaths = collectBlockAttachmentPaths(prompt); + const skillBundles = collectSkillBundleRefs(rawPromptText); return { filePaths: attachmentPaths, + skillBundles, messageText: promptText || undefined, promptText: summarizePrompt(promptText, attachmentPaths), }; @@ -111,9 +136,10 @@ export function cloudPromptToBlocks(prompt: QueuedCloudPrompt): ContentBlock[] { const transport = getCloudPromptTransport(prompt); const blocks: ContentBlock[] = []; + const textWithSkillTags = stripAttachmentTags(prompt); - if (transport.messageText) { - blocks.push({ type: "text", text: transport.messageText }); + if (textWithSkillTags) { + blocks.push({ type: "text", text: textWithSkillTags }); } for (const filePath of transport.filePaths) { diff --git a/packages/core/src/sessions/sessionEvents.ts b/packages/core/src/sessions/sessionEvents.ts index 9cba7d4c13..7bd9df93ee 100644 --- a/packages/core/src/sessions/sessionEvents.ts +++ b/packages/core/src/sessions/sessionEvents.ts @@ -15,6 +15,7 @@ import type { UserShellExecuteParams, } from "@posthog/shared"; import { isJsonRpcNotification, isJsonRpcRequest } from "@posthog/shared"; +import { skillTagsToSlashCommands } from "../message-editor/skillTags"; import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; import { extractPromptDisplayContent } from "./promptContent"; @@ -208,8 +209,8 @@ export function extractUserPromptsFromEvents(events: AcpMessage[]): string[] { } export function extractPromptText(prompt: string | ContentBlock[]): string { - if (typeof prompt === "string") return prompt; - return extractPromptDisplayContent(prompt).text; + if (typeof prompt === "string") return skillTagsToSlashCommands(prompt); + return skillTagsToSlashCommands(extractPromptDisplayContent(prompt).text); } /** @@ -218,7 +219,15 @@ export function extractPromptText(prompt: string | ContentBlock[]): string { export function normalizePromptToBlocks( prompt: string | ContentBlock[], ): ContentBlock[] { - return typeof prompt === "string" ? [{ type: "text", text: prompt }] : prompt; + if (typeof prompt === "string") { + return [{ type: "text", text: skillTagsToSlashCommands(prompt) }]; + } + + return prompt.map((block) => + block.type === "text" + ? { ...block, text: skillTagsToSlashCommands(block.text) } + : block, + ); } export { isFatalSessionError, isRateLimitError } from "@posthog/shared"; diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 28993a584a..5ea21f79dd 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -40,7 +40,10 @@ import { } from "@posthog/shared/domain-types"; import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications"; import { createAppendOnlyTracker } from "./appendOnlyTracker"; -import type { CloudArtifactClient } from "./cloudArtifactIdentifiers"; +import type { + CloudArtifactClient, + CloudSkillBundleRef, +} from "./cloudArtifactIdentifiers"; import { classifyCloudLogAppend } from "./cloudLogGap"; import { CloudLogGapReconciler } from "./cloudLogGapReconciler"; import { CloudRunIdleTracker } from "./cloudRunIdleTracker"; @@ -205,16 +208,19 @@ export interface SessionServiceHelpers { cloudPromptToBlocks: (...args: any[]) => any; combineQueuedCloudPrompts: (...args: any[]) => any; getCloudPromptTransport: (...args: any[]) => any; + resolveLocalSkillCommandPrompt?: (prompt: string) => Promise; uploadRunAttachments: ( client: CloudArtifactClient, taskId: string, runId: string, filePaths: string[], + skillBundles?: CloudSkillBundleRef[], ) => Promise; uploadTaskStagedAttachments: ( client: CloudArtifactClient, taskId: string, filePaths: string[], + skillBundles?: CloudSkillBundleRef[], ) => Promise; } @@ -2224,8 +2230,13 @@ export class SessionService { prompt: string | ContentBlock[], options?: { skipQueueGuard?: boolean }, ): Promise<{ stopReason: string }> { - const transport = this.d.h.getCloudPromptTransport(prompt); - if (!transport.messageText && transport.filePaths.length === 0) { + const normalizedPrompt = await this.resolveCloudPrompt(prompt); + const transport = this.d.h.getCloudPromptTransport(normalizedPrompt); + if ( + !transport.messageText && + transport.filePaths.length === 0 && + transport.skillBundles.length === 0 + ) { return { stopReason: "empty" }; } @@ -2239,11 +2250,15 @@ export class SessionService { "Cloud run couldn't start. Check that GitHub is connected for this project, then try again.", ); } - return this.resumeCloudRun(session, prompt); + return this.resumeCloudRun(session, normalizedPrompt); } if (session.cloudStatus !== "in_progress") { - this.d.store.enqueueMessage(session.taskId, transport.promptText); + this.d.store.enqueueMessage( + session.taskId, + transport.promptText, + normalizedPrompt, + ); this.d.log.info("Cloud message queued (sandbox not ready)", { taskId: session.taskId, cloudStatus: session.cloudStatus, @@ -2263,7 +2278,11 @@ export class SessionService { session.isCloud && session.status !== "connected" ) { - this.d.store.enqueueMessage(session.taskId, transport.promptText, prompt); + this.d.store.enqueueMessage( + session.taskId, + transport.promptText, + normalizedPrompt, + ); this.d.log.info("Cloud message queued (agent not ready)", { taskId: session.taskId, sessionStatus: session.status, @@ -2289,7 +2308,11 @@ export class SessionService { } if (!options?.skipQueueGuard && session.isPromptPending) { - this.d.store.enqueueMessage(session.taskId, transport.promptText, prompt); + this.d.store.enqueueMessage( + session.taskId, + transport.promptText, + normalizedPrompt, + ); this.d.log.info("Cloud message queued", { taskId: session.taskId, queueLength: session.messageQueue.length + 1, @@ -2301,7 +2324,7 @@ export class SessionService { if (authStatus.kind === "restoring") { return this.queueRestoringCloudPrompt( session, - prompt, + normalizedPrompt, "Cloud message queued (auth restoring)", ); } @@ -2328,6 +2351,7 @@ export class SessionService { session.taskId, session.taskRunId, transport.filePaths, + transport.skillBundles, ); const params: Record = {}; if (transport.messageText) { @@ -2484,11 +2508,12 @@ export class SessionService { session: AgentSession, prompt: string | ContentBlock[], ): Promise<{ stopReason: string }> { + const normalizedPrompt = await this.resolveCloudPrompt(prompt); const authStatus = await this.getAuthCredentialsStatus(); if (authStatus.kind === "restoring") { return this.queueRestoringCloudPrompt( session, - prompt, + normalizedPrompt, "Cloud resume queued (auth restoring)", ); } @@ -2502,14 +2527,19 @@ export class SessionService { throw new Error("Authentication required for cloud commands"); } - const transport = this.d.h.getCloudPromptTransport(prompt); - if (!transport.messageText && transport.filePaths.length === 0) { + const transport = this.d.h.getCloudPromptTransport(normalizedPrompt); + if ( + !transport.messageText && + transport.filePaths.length === 0 && + transport.skillBundles.length === 0 + ) { return { stopReason: "empty" }; } const artifactIds = await this.d.h.uploadTaskStagedAttachments( authCredentials.client, session.taskId, transport.filePaths, + transport.skillBundles, ); const previousRun = await authCredentials.client.getTaskRun( @@ -4396,6 +4426,28 @@ export class SessionService { // --- Helper Methods --- + private async resolveCloudPrompt( + prompt: string | ContentBlock[], + ): Promise { + if (typeof prompt !== "string") { + return prompt; + } + + const resolver = this.d.h.resolveLocalSkillCommandPrompt; + if (!resolver) { + return prompt; + } + + try { + return (await resolver(prompt)) ?? prompt; + } catch (error) { + this.d.log.warn("Failed to resolve local skill command prompt", { + error: String(error), + }); + return prompt; + } + } + private async getAuthCredentialsStatus(): Promise { const authState = await this.d.fetchAuthState(); // `bootstrapComplete === false` also covers the pre-initialize window where diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts index b67faa501b..7dd668b3d5 100644 --- a/packages/core/src/task-detail/taskCreationHost.ts +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -1,9 +1,11 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; +import type { CloudSkillBundleRef } from "@posthog/core/sessions/cloudArtifactIdentifiers"; import type { Workspace, WorkspaceMode } from "@posthog/shared"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; export interface CloudPromptTransport { filePaths: string[]; + skillBundles: CloudSkillBundleRef[]; messageText?: string; promptText: string; } @@ -88,6 +90,7 @@ export interface ITaskCreationHost { taskId: string, runId: string, filePaths: string[], + skillBundles?: CloudSkillBundleRef[], ): Promise; setProvisioningActive(taskId: string): void; clearProvisioning(taskId: string): void; diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 3729cfa27e..7e769ec936 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -82,6 +82,7 @@ describe("TaskCreationSaga", () => { filePaths: string[] = [], ): CloudPromptTransport => ({ filePaths, + skillBundles: [], messageText: typeof prompt === "string" ? prompt : undefined, promptText: typeof prompt === "string" ? prompt : "", }), @@ -250,6 +251,7 @@ describe("TaskCreationSaga", () => { mockHost.getCloudPromptTransport.mockReturnValue({ filePaths: ["/tmp/test.txt"], + skillBundles: [], messageText: "read this file", promptText: "read this file\n\nAttached files: test.txt", }); @@ -311,6 +313,7 @@ describe("TaskCreationSaga", () => { "task-123", "run-123", ["/tmp/test.txt"], + [], ); expect(startTaskRunMock).toHaveBeenCalledWith("task-123", "run-123", { pendingUserMessage: "read this file", diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 77ab27ba97..452cd76a84 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -306,6 +306,7 @@ export class TaskCreationSaga extends Saga< task.id, taskRun.id, transport.filePaths, + transport.skillBundles, ) : []; diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index 82a27d257f..2de4863f2a 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -3871,6 +3871,7 @@ describe("SessionService", () => { expect(mockSessionStoreSetters.enqueueMessage).toHaveBeenCalledWith( "task-123", "before boot", + prompt, ); const wroteIsPromptPendingTrue = mockSessionStoreSetters.updateSession.mock.calls.some(