From 4d27d419f50b5c181f824ced3a4407785145a9fc Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:38 -0400 Subject: [PATCH 01/12] feat(skills): add always-on activation Generated-By: PostHog Code Task-Id: 73390913-7191-4450-9fd3-31dd12879508 --- .../agent/src/server/agent-server.test.ts | 5 ++ packages/agent/src/server/agent-server.ts | 60 +++++++++++++++--- .../src/sessions/cloudArtifactIdentifiers.ts | 3 + .../src/sessions/cloudArtifactService.test.ts | 12 +++- .../core/src/sessions/cloudArtifactService.ts | 4 ++ packages/core/src/sessions/cloudPrompt.ts | 2 +- packages/core/src/sessions/sessionService.ts | 26 +++++++- .../core/src/task-detail/taskCreationHost.ts | 8 ++- .../core/src/task-detail/taskCreationSaga.ts | 44 +++++++++++-- packages/core/src/task-detail/taskInput.ts | 2 + .../host-router/src/routers/skills.router.ts | 10 +++ packages/shared/src/index.ts | 11 +++- packages/shared/src/sessions.ts | 1 + packages/shared/src/task-creation-domain.ts | 8 +++ .../features/settings/settingsStore.test.ts | 23 +++++++ .../ui/src/features/settings/settingsStore.ts | 22 +++++++ packages/ui/src/features/skills/SkillCard.tsx | 11 ++++ .../src/features/skills/SkillDetailPanel.tsx | 44 +++++++++++++ .../AlwaysOnSkillsFailureDialog.tsx | 63 +++++++++++++++++++ .../task-detail/hooks/useTaskCreation.ts | 32 +++++++++- .../stores/alwaysOnSkillsFailureStore.test.ts | 33 ++++++++++ .../stores/alwaysOnSkillsFailureStore.ts | 37 +++++++++++ .../task-detail/taskCreationHostImpl.ts | 6 ++ packages/ui/src/router/routes/__root.tsx | 3 + .../src/services/skills/schemas.ts | 7 +++ .../src/services/skills/skills.test.ts | 18 ++++++ .../src/services/skills/skills.ts | 18 ++++++ 27 files changed, 493 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/features/task-detail/components/AlwaysOnSkillsFailureDialog.tsx create mode 100644 packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts create mode 100644 packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.ts diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 47e8c485b0..1eb6bab178 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -2346,6 +2346,8 @@ describe("AgentServer HTTP Mode", () => { content_sha256: checksum, bundle_format: "zip", schema_version: 1, + activation: "always", + activation_order: 0, }, }, ], @@ -2377,6 +2379,9 @@ describe("AgentServer HTTP Mode", () => { 'local skill "/local-test-skill"', ); expect(sentMeta?.localSkillContext).toContain("LOCAL_SKILL_MARKER"); + expect(sentMeta?.localSkillContext).toContain( + "always-on skills apply for the entire session", + ); expect(sentMeta?.localSkillContext).toContain("with context"); expect(sentMeta?.localSkillName).toBe("local-test-skill"); }, 20000); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 990fd388db..9319ed7ed9 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -2838,8 +2838,41 @@ export class AgentServer { runId: string, artifacts: TaskRunArtifact[], ): LocalSkillPromptContext | null { + const alwaysOnSkills = artifacts + .filter( + (artifact) => + artifact.type === "skill_bundle" && + artifact.metadata?.activation === "always", + ) + .sort( + (left, right) => + (left.metadata?.activation_order ?? 0) - + (right.metadata?.activation_order ?? 0), + ) + .map((artifact) => + this.installedSkillBundleInfo.get( + this.getInstalledSkillBundleInfoKey( + runId, + artifact.metadata?.skill_name ?? "", + ), + ), + ) + .filter((skill): skill is InstalledSkillBundle => !!skill); + const alwaysOnContext = + alwaysOnSkills.length > 0 + ? [ + "The following always-on skills apply for the entire session. Follow each skill in the listed order.", + ...alwaysOnSkills.flatMap((skill) => [ + "", + `--- BEGIN ALWAYS-ON SKILL ${skill.skillName} ---`, + skill.skillDefinition.trim(), + `--- END ALWAYS-ON SKILL ${skill.skillName} ---`, + `Installed skill path: ${skill.skillRoot}`, + ]), + ].join("\n") + : null; if (contentBlocks.length === 0) { - return null; + return alwaysOnContext ? { context: alwaysOnContext } : null; } const textBlockIndex = contentBlocks.findIndex( @@ -2865,13 +2898,16 @@ export class AgentServer { ) : undefined; if (installedSkill) { + const invokedContext = this.buildInstalledSkillPrompt( + installedSkill, + invocation.args, + this.getCoInstalledSkillBundles(runId, invocation.skillName), + ); return { skillName: invocation.skillName, - context: this.buildInstalledSkillPrompt( - installedSkill, - invocation.args, - this.getCoInstalledSkillBundles(runId, invocation.skillName), - ), + context: alwaysOnContext + ? `${alwaysOnContext}\n\n${invokedContext}` + : invokedContext, }; } } @@ -2883,7 +2919,17 @@ export class AgentServer { ) .map((block) => block.text) .join("\n"); - return this.buildAttachedSkillsPromptContext(runId, artifacts, messageText); + const attachedContext = this.buildAttachedSkillsPromptContext( + runId, + artifacts, + messageText, + ); + if (!alwaysOnContext) return attachedContext; + return { + context: attachedContext + ? `${alwaysOnContext}\n\n${attachedContext.context}` + : alwaysOnContext, + }; } /** diff --git a/packages/core/src/sessions/cloudArtifactIdentifiers.ts b/packages/core/src/sessions/cloudArtifactIdentifiers.ts index 69b1f90116..0a15d3d099 100644 --- a/packages/core/src/sessions/cloudArtifactIdentifiers.ts +++ b/packages/core/src/sessions/cloudArtifactIdentifiers.ts @@ -1,4 +1,5 @@ import type { + SkillActivation, TaskRunArtifactMetadata, UploadableSkillSource, } from "@posthog/shared"; @@ -51,6 +52,8 @@ export interface CloudSkillBundleRef { name: string; source: UploadableSkillSource; path: string; + activation?: SkillActivation; + activationOrder?: number; } export interface LocalSkillBundle { diff --git a/packages/core/src/sessions/cloudArtifactService.test.ts b/packages/core/src/sessions/cloudArtifactService.test.ts index d76644491c..661a7571cf 100644 --- a/packages/core/src/sessions/cloudArtifactService.test.ts +++ b/packages/core/src/sessions/cloudArtifactService.test.ts @@ -165,7 +165,15 @@ describe("CloudArtifactService", () => { "task-1", "run-1", [], - [{ name: "local-skill", source: "user", path: "/tmp/local-skill" }], + [ + { + name: "local-skill", + source: "user", + path: "/tmp/local-skill", + activation: "always", + activationOrder: 2, + }, + ], ); expect(ids).toEqual(["skill-artifact-1"]); @@ -182,6 +190,8 @@ describe("CloudArtifactService", () => { skill_source: "user", bundle_format: "zip", schema_version: 1, + activation: "always", + activation_order: 2, }), }), ], diff --git a/packages/core/src/sessions/cloudArtifactService.ts b/packages/core/src/sessions/cloudArtifactService.ts index 8769c7f305..a2fabfe27d 100644 --- a/packages/core/src/sessions/cloudArtifactService.ts +++ b/packages/core/src/sessions/cloudArtifactService.ts @@ -266,6 +266,10 @@ export class CloudArtifactService { content_sha256: bundle.contentSha256, bundle_format: "zip", schema_version: 1, + activation: skillBundleRef.activation ?? "explicit", + ...(skillBundleRef.activationOrder !== undefined + ? { activation_order: skillBundleRef.activationOrder } + : {}), }, }, }; diff --git a/packages/core/src/sessions/cloudPrompt.ts b/packages/core/src/sessions/cloudPrompt.ts index da0f527b34..8596b2e3f1 100644 --- a/packages/core/src/sessions/cloudPrompt.ts +++ b/packages/core/src/sessions/cloudPrompt.ts @@ -77,7 +77,7 @@ function collectSkillBundleRefs(prompt: string): CloudSkillBundleRef[] { continue; } seen.add(key); - refs.push(tag); + refs.push({ ...tag, activation: "explicit" }); } return refs; diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index cf2da4368c..8ef3ee2371 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -404,6 +404,7 @@ export interface ConnectParams { reasoningLevel?: string; contextWindow?: "200k" | "1m"; fastMode?: boolean; + alwaysOnSkillInstructions?: string; /** * Session ID of an imported Claude Code CLI transcript already copied into * the app's Claude config dir. The agent loads it and replays its history. @@ -1715,6 +1716,7 @@ export class SessionService { session.reasoningLevel = params.reasoningLevel; session.contextWindow = params.contextWindow; session.fastMode = params.fastMode; + session.alwaysOnSkillInstructions = params.alwaysOnSkillInstructions; if (params.initialPrompt?.length) { session.initialPrompt = params.initialPrompt; } @@ -1732,6 +1734,7 @@ export class SessionService { contextWindow, fastMode, importedSessionId, + alwaysOnSkillInstructions, } = params; const { id: taskId, latest_run: latestRun } = task; const taskTitle = task.title || task.description || "Task"; @@ -1820,6 +1823,7 @@ export class SessionService { repoPath, auth, logResult, + alwaysOnSkillInstructions, ); } else { if (!this.d.getIsOnline()) { @@ -1846,6 +1850,7 @@ export class SessionService { importedSessionId, contextWindow, fastMode, + alwaysOnSkillInstructions, ); } } catch (error) { @@ -1953,6 +1958,7 @@ export class SessionService { sessionId?: string; adapter?: Adapter; }, + alwaysOnSkillInstructions?: string, ): Promise { const { rawEntries, sessionId, adapter } = prefetchedLogs ?? (await this.fetchSessionLogs(logUrl, taskRunId)); @@ -2058,6 +2064,12 @@ export class SessionService { const { customInstructions, rtkEnabledLocal, spokenNarrationEnabled } = this.d.settings; + const effectiveCustomInstructions = [ + customInstructions, + alwaysOnSkillInstructions ?? previous?.alwaysOnSkillInstructions, + ] + .filter((value): value is string => Boolean(value)) + .join("\n\n"); const result = await this.d.trpc.agent.reconnect.mutate({ taskId, taskRunId, @@ -2074,10 +2086,12 @@ export class SessionService { effort: persistedEffort, contextWindow: persistedContextWindow, fastMode: persistedFastMode, - customInstructions: customInstructions || undefined, + customInstructions: effectiveCustomInstructions || undefined, }); if (result) { + session.alwaysOnSkillInstructions = + alwaysOnSkillInstructions ?? previous?.alwaysOnSkillInstructions; const liveConfigOptions = result.configOptions as | SessionConfigOption[] | undefined; @@ -2392,6 +2406,7 @@ export class SessionService { importedSessionId?: string, contextWindow?: "200k" | "1m", fastMode?: boolean, + alwaysOnSkillInstructions?: string, ): Promise { const { client } = auth; if (!client) { @@ -2409,6 +2424,12 @@ export class SessionService { spokenNarrationEnabled, } = this.d.settings; const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL; + const effectiveCustomInstructions = [ + startCustomInstructions, + alwaysOnSkillInstructions, + ] + .filter((value): value is string => Boolean(value)) + .join("\n\n"); const result = await this.d.trpc.agent.start.mutate({ taskId, taskRunId: taskRun.id, @@ -2417,7 +2438,7 @@ export class SessionService { projectId: auth.projectId, permissionMode: executionMode, adapter, - customInstructions: startCustomInstructions || undefined, + customInstructions: effectiveCustomInstructions || undefined, rtkEnabled: rtkEnabledLocal, spokenNarration: spokenNarrationEnabled === true, effort: effortLevelSchema.safeParse(reasoningLevel).success @@ -2438,6 +2459,7 @@ export class SessionService { session.reasoningLevel = reasoningLevel; session.contextWindow = contextWindow; session.fastMode = fastMode; + session.alwaysOnSkillInstructions = alwaysOnSkillInstructions; // An imported CLI session had its history replayed during agent.start; // the replay is already in the local run log, so load it for the UI. diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts index 2d29814552..838c147651 100644 --- a/packages/core/src/task-detail/taskCreationHost.ts +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -1,6 +1,11 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import type { CloudSkillBundleRef } from "@posthog/core/sessions/cloudArtifactIdentifiers"; -import type { Workspace, WorkspaceInfo, WorkspaceMode } from "@posthog/shared"; +import type { + AlwaysOnSkillRef, + Workspace, + WorkspaceInfo, + WorkspaceMode, +} from "@posthog/shared"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; export interface CloudPromptTransport { @@ -103,6 +108,7 @@ export interface ITaskCreationHost { * too, or a typed `/my-skill` reaches the sandbox with no bundle attached. */ resolveLocalSkillCommandPrompt(prompt: string): Promise; + renderAlwaysOnSkillInstructions(skills: AlwaysOnSkillRef[]): Promise; /** * Return-and-clear the pre-warmed sandbox lease matching the composer * selection, if one was provisioned while the user typed. The saga uploads diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index da3f55a9ca..9d037e40ca 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -73,6 +73,26 @@ function buildCloudFirstMessage( }; } +function addAlwaysOnSkills( + transport: CloudPromptTransport, + input: TaskCreationInput, +): CloudPromptTransport { + const refs = new Map( + (transport.skillBundles ?? []).map((skill) => [ + `${skill.source}:${skill.path}`, + skill, + ]), + ); + for (const skill of input.alwaysOnSkills ?? []) { + refs.set(`${skill.source}:${skill.path}`, { + ...skill, + activation: "always", + activationOrder: skill.order, + }); + } + return { ...transport, skillBundles: [...refs.values()] }; +} + export class TaskCreationSaga extends Saga< TaskCreationInput, TaskCreationOutput @@ -376,9 +396,12 @@ export class TaskCreationSaga extends Saga< input.content, ) : ""; - return this.deps.host.getCloudPromptTransport( - resolvedContent, - input.filePaths, + return addAlwaysOnSkills( + this.deps.host.getCloudPromptTransport( + resolvedContent, + input.filePaths, + ), + input, ); }; const transport = warmPayload @@ -497,6 +520,13 @@ export class TaskCreationSaga extends Saga< const shouldConnect = !isCloudCreate && (!!input.taskId || !!agentCwd); if (shouldConnect) { + const alwaysOnSkillInstructions = input.alwaysOnSkills?.length + ? await this.readOnlyStep("resolve_always_on_skills", () => + this.deps.host.renderAlwaysOnSkillInstructions( + input.alwaysOnSkills ?? [], + ), + ) + : undefined; const initialPrompt = !isPiRuntime && !input.taskId && input.content ? await this.readOnlyStep("build_prompt_blocks", () => @@ -553,6 +583,8 @@ export class TaskCreationSaga extends Saga< connectParams.contextWindow = input.contextWindow; if (input.fastMode !== undefined) connectParams.fastMode = input.fastMode; + if (alwaysOnSkillInstructions) + connectParams.alwaysOnSkillInstructions = alwaysOnSkillInstructions; if (importedClaude) { connectParams.importedSessionId = importedClaude.importedSessionId; connectParams.adapter = "claude"; @@ -716,9 +748,9 @@ export class TaskCreationSaga extends Saga< const resolvedContent = input.content ? await this.deps.host.resolveLocalSkillCommandPrompt(input.content) : ""; - const transport = this.deps.host.getCloudPromptTransport( - resolvedContent, - input.filePaths, + const transport = addAlwaysOnSkills( + this.deps.host.getCloudPromptTransport(resolvedContent, input.filePaths), + input, ); const { pendingUserMessage, augmented } = buildCloudFirstMessage( transport.messageText, diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 64544f9f32..45d2fbf9fc 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -35,6 +35,7 @@ export interface PrepareTaskInputOptions { channelId?: string; channelContextId?: string; customInstructions?: string; + alwaysOnSkills?: TaskCreationInput["alwaysOnSkills"]; autoPublishCloudRuns?: boolean; rtkEnabledCloud?: boolean; allowNoRepo?: boolean; @@ -85,6 +86,7 @@ export function prepareTaskInput( channelId: options.channelId, channelContextId: options.channelContextId, customInstructions: isCloud ? options.customInstructions : undefined, + alwaysOnSkills: options.alwaysOnSkills, allowNoRepo: options.allowNoRepo, importedMcpServers: isCloud ? options.importedMcpServers : undefined, relayedMcpServers: isCloud ? options.relayedMcpServers : undefined, diff --git a/packages/host-router/src/routers/skills.router.ts b/packages/host-router/src/routers/skills.router.ts index 75e9a94e8e..b2de74d8df 100644 --- a/packages/host-router/src/routers/skills.router.ts +++ b/packages/host-router/src/routers/skills.router.ts @@ -14,6 +14,8 @@ import { readSkillFileInput, readSkillFileOutput, renameSkillFileInput, + renderAlwaysOnSkillsInput, + renderAlwaysOnSkillsOutput, resolveSkillDependenciesInput, resolveSkillDependenciesOutput, saveSkillFileInput, @@ -54,6 +56,14 @@ export const skillsRouter = router({ .get(SKILLS_SERVICE) .resolveSkillBundleDependencies(input), ), + renderAlwaysOn: publicProcedure + .input(renderAlwaysOnSkillsInput) + .output(renderAlwaysOnSkillsOutput) + .query(({ ctx, input }) => + ctx.container + .get(SKILLS_SERVICE) + .renderAlwaysOnSkillInstructions(input), + ), contents: publicProcedure .input(skillContentsInput) .output(skillContentsOutput) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 56ce3353c3..83bcc410be 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -340,7 +340,15 @@ export { serializeSkillMarkdown, stripFrontmatter, } from "./skills"; -export type { PostHogAPIConfig } from "./task"; +export type { + ArtifactType, + PostHogAPIConfig, + TaskRun, + TaskRunArtifact, + TaskRunArtifactMetadata, + TaskRunEnvironment, + TaskRunStatus, +} from "./task"; export { type CreateTaskAutomationOptions, createTaskAutomationSchema, @@ -354,6 +362,7 @@ export { updateTaskAutomationSchema, } from "./task-automation"; export type { + AlwaysOnSkillRef, TaskCreationInput, TaskCreationOutput, } from "./task-creation-domain"; diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index c682edb694..ccf15713ef 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -107,6 +107,7 @@ export interface AgentSession { contextUsed?: number; contextSize?: number; conversationSummary?: string; + alwaysOnSkillInstructions?: string; idleKilled?: boolean; agentVersion?: string; agentIdleForRunId?: string; diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index a3e9ca4644..d5c48a1ab4 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -10,6 +10,13 @@ import type { import type { WorkspaceMode } from "./workspace"; import type { Workspace } from "./workspace-domain"; +export interface AlwaysOnSkillRef { + name: string; + source: "user" | "repo" | "marketplace" | "codex"; + path: string; + order: number; +} + // Host-agnostic input/output for the task-creation flow. The renderer // TaskCreationSaga owns the orchestration; these are the plain data shapes its // consumers (inbox direct-create hooks, deep-link open, task-input) pass and @@ -83,6 +90,7 @@ export interface TaskCreationInput { * first message instead, to avoid double-injecting. */ customInstructions?: string; + alwaysOnSkills?: AlwaysOnSkillRef[]; /** * Local (~/.claude.json) MCP servers classified as importable, forwarded to * the cloud sandbox in the run-creation payload. Cloud-only; local sessions diff --git a/packages/ui/src/features/settings/settingsStore.test.ts b/packages/ui/src/features/settings/settingsStore.test.ts index 917bf24fa7..d37e9557b8 100644 --- a/packages/ui/src/features/settings/settingsStore.test.ts +++ b/packages/ui/src/features/settings/settingsStore.test.ts @@ -599,3 +599,26 @@ describe("feature settingsStore hydration", () => { expect(useSettingsStore.getState()._hasHydrated).toBe(true); }); }); + +describe("feature settingsStore always-on skills", () => { + it("persists enabled skills and removes them by source and path", async () => { + await resetPersistenceMocks(); + useSettingsStore.setState({ alwaysOnSkills: [] }); + const skill = { + name: "i-have-adhd", + source: "user" as const, + path: "/home/u/.claude/skills/i-have-adhd", + }; + + useSettingsStore.getState().setSkillAlwaysOn(skill, true); + useSettingsStore.getState().setSkillAlwaysOn(skill, true); + expect(useSettingsStore.getState().alwaysOnSkills).toEqual([skill]); + + await waitForPersistedWrite(); + const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; + expect(JSON.parse(lastCall[1]).state.alwaysOnSkills).toEqual([skill]); + + useSettingsStore.getState().setSkillAlwaysOn(skill, false); + expect(useSettingsStore.getState().alwaysOnSkills).toEqual([]); + }); +}); diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index 806000165e..c9993a3b80 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -2,6 +2,7 @@ import type { UserRepositoryIntegrationRef } from "@posthog/core/integrations/re import type { Adapter, AgentRuntime, + AlwaysOnSkillRef, ExecutionMode, WorkspaceMode, } from "@posthog/shared"; @@ -95,6 +96,8 @@ export interface SyncedCustomInstructions { truncated: boolean; } +export type AlwaysOnSkillPreference = Omit; + // ---------- Store shape ---------- interface SettingsStore { @@ -198,6 +201,7 @@ interface SettingsStore { // instead of the hand-typed customInstructions above. syncCustomInstructionsFromFile: boolean; syncedCustomInstructions: SyncedCustomInstructions | null; + alwaysOnSkills: AlwaysOnSkillPreference[]; setAutoConvertLongText: (value: AutoConvertLongText) => void; setSendMessagesWith: (mode: SendMessagesWith) => void; setCustomInstructions: (instructions: string) => void; @@ -205,6 +209,7 @@ interface SettingsStore { setSyncedCustomInstructions: ( synced: SyncedCustomInstructions | null, ) => void; + setSkillAlwaysOn: (skill: AlwaysOnSkillPreference, enabled: boolean) => void; // Diff viewer diffOpenMode: DiffOpenMode; @@ -432,6 +437,7 @@ export const useSettingsStore = create()( customInstructions: "", syncCustomInstructionsFromFile: false, syncedCustomInstructions: null, + alwaysOnSkills: [], setAutoConvertLongText: (value) => set({ autoConvertLongText: value }), setSendMessagesWith: (mode) => set({ sendMessagesWith: mode }), setCustomInstructions: (instructions) => @@ -440,6 +446,21 @@ export const useSettingsStore = create()( set({ syncCustomInstructionsFromFile: enabled }), setSyncedCustomInstructions: (synced) => set({ syncedCustomInstructions: synced }), + setSkillAlwaysOn: (skill, enabled) => + set((state) => ({ + alwaysOnSkills: enabled + ? [ + ...state.alwaysOnSkills.filter( + (item) => + item.source !== skill.source || item.path !== skill.path, + ), + skill, + ] + : state.alwaysOnSkills.filter( + (item) => + item.source !== skill.source || item.path !== skill.path, + ), + })), // Diff viewer diffOpenMode: "auto", @@ -597,6 +618,7 @@ export const useSettingsStore = create()( sendMessagesWith: state.sendMessagesWith, customInstructions: state.customInstructions, syncCustomInstructionsFromFile: state.syncCustomInstructionsFromFile, + alwaysOnSkills: state.alwaysOnSkills, // Diff viewer diffOpenMode: state.diffOpenMode, diff --git a/packages/ui/src/features/skills/SkillCard.tsx b/packages/ui/src/features/skills/SkillCard.tsx index 5f896404a9..ad1ad36ba0 100644 --- a/packages/ui/src/features/skills/SkillCard.tsx +++ b/packages/ui/src/features/skills/SkillCard.tsx @@ -11,6 +11,7 @@ import type { SkillIssue, } from "@posthog/core/skills/analyzeSkills"; import type { SkillInfo, SkillSource } from "@posthog/shared"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { Badge, Flex, Text, Tooltip } from "@radix-ui/themes"; import { useEffect, useRef } from "react"; import { SkillListCard } from "./SkillListCard"; @@ -54,6 +55,11 @@ export function SkillCard({ }: SkillCardProps) { const config = SOURCE_CONFIG[skill.source]; const Icon = config?.icon ?? Package; + const alwaysOn = useSettingsStore((state) => + state.alwaysOnSkills.some( + (item) => item.source === skill.source && item.path === skill.path, + ), + ); const ref = useRef(null); useEffect(() => { @@ -72,6 +78,11 @@ export function SkillCard({ onClick={onClick} trailing={ <> + {alwaysOn && ( + + Always on + + )} {issues.length > 0 && ( state.alwaysOnSkills); + const setSkillAlwaysOn = useSettingsStore((state) => state.setSkillAlwaysOn); + const alwaysOn = alwaysOnSkills.some( + (item) => item.source === skill.source && item.path === skill.path, + ); const files = contents?.files ?? []; const isSkillMd = selectedFile === "SKILL.md"; @@ -299,6 +307,42 @@ export function SkillDetailPanel({ )} + + + + Always on for new tasks + + + Apply this skill to every new local and cloud task + + + + + setSkillAlwaysOn( + { + name: skill.name, + source: skill.source as Exclude< + typeof skill.source, + "bundled" + >, + path: skill.path, + }, + checked, + ) + } + /> + + + {issues.length > 0 && ( {issues.map((issue) => ( diff --git a/packages/ui/src/features/task-detail/components/AlwaysOnSkillsFailureDialog.tsx b/packages/ui/src/features/task-detail/components/AlwaysOnSkillsFailureDialog.tsx new file mode 100644 index 0000000000..c0d150ce98 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/AlwaysOnSkillsFailureDialog.tsx @@ -0,0 +1,63 @@ +import { Warning } from "@phosphor-icons/react"; +import { AlertDialog, Button, Flex, Text } from "@radix-ui/themes"; +import { useAlwaysOnSkillsFailureStore } from "../stores/alwaysOnSkillsFailureStore"; + +export function AlwaysOnSkillsFailureDialog() { + const isOpen = useAlwaysOnSkillsFailureStore((state) => state.isOpen); + const error = useAlwaysOnSkillsFailureStore((state) => state.error); + const skills = useAlwaysOnSkillsFailureStore((state) => state.skills); + const choose = useAlwaysOnSkillsFailureStore((state) => state.choose); + + return ( + { + if (!open) choose("cancel"); + }} + > + + + + + Always-on skills could not be loaded + + + + {skills.map((skill) => skill.name).join(", ")} + + + {error} + + + + + + + + + + ); +} diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 9bbfaf88cd..7e17b89e0d 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -14,6 +14,7 @@ import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { type Adapter, type AgentRuntime, + type AlwaysOnSkillRef, ANALYTICS_EVENTS, PROJECT_BLUEBIRD_FLAG, type TaskCreationInput, @@ -59,6 +60,7 @@ import { useCreateTask } from "../../tasks/useTaskCrudMutations"; import { useTasks } from "../../tasks/useTasks"; import { useTourStore } from "../../tour/tourStore"; import { createFirstTaskTour } from "../../tour/tours/createFirstTaskTour"; +import { useAlwaysOnSkillsFailureStore } from "../stores/alwaysOnSkillsFailureStore"; import { useExistingWorktreeConfirmStore } from "../stores/existingWorktreeConfirmStore"; import { useRemoteBranchConfirmStore } from "../stores/remoteBranchConfirmStore"; @@ -323,6 +325,34 @@ export function useTaskCreation({ const plainPromptText = contentToPlainText(content).trim(); const serializedContent = contentToXml(content).trim(); const filePaths = extractFilePaths(content); + const settings = useSettingsStore.getState(); + let alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills.map( + (skill, order) => ({ ...skill, order }), + ); + while (alwaysOnSkills.length > 0) { + try { + await hostClient.skills.renderAlwaysOn.query(alwaysOnSkills); + break; + } catch (error) { + const action = await useAlwaysOnSkillsFailureStore + .getState() + .confirm( + error instanceof Error ? error.message : String(error), + alwaysOnSkills, + ); + if (action === "retry") continue; + if (action === "cancel") { + setIsCreatingTask(false); + return false; + } + if (action === "disable") { + for (const skill of alwaysOnSkills) { + useSettingsStore.getState().setSkillAlwaysOn(skill, false); + } + } + alwaysOnSkills = []; + } + } const shouldShowPendingView = !onTaskCreated && !!plainPromptText; const pendingTaskKey = shouldShowPendingView @@ -353,7 +383,6 @@ export function useTaskCreation({ } } - const settings = useSettingsStore.getState(); const defaultedChannelId = bluebirdEnabled && !channelId && !channelName ? personalChannel?.id @@ -391,6 +420,7 @@ export function useTaskCreation({ channelId: channelId ?? defaultedChannelId, channelContextId, customInstructions: getEffectiveCustomInstructions(settings), + alwaysOnSkills, autoPublishCloudRuns: settings.autoPublishCloudRuns, rtkEnabledCloud: settings.rtkEnabledCloud, allowNoRepo, diff --git a/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts b/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts new file mode 100644 index 0000000000..583e0247f4 --- /dev/null +++ b/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useAlwaysOnSkillsFailureStore } from "./alwaysOnSkillsFailureStore"; + +describe("alwaysOnSkillsFailureStore", () => { + beforeEach(() => { + useAlwaysOnSkillsFailureStore.setState({ + isOpen: false, + error: null, + skills: [], + resolve: null, + }); + }); + + it.each(["retry", "continue", "disable", "cancel"] as const)( + "resolves the %s recovery action", + async (action) => { + const skill = { + name: "example", + source: "user" as const, + path: "/skills/example", + order: 0, + }; + const result = useAlwaysOnSkillsFailureStore + .getState() + .confirm("missing", [skill]); + + useAlwaysOnSkillsFailureStore.getState().choose(action); + + await expect(result).resolves.toBe(action); + expect(useAlwaysOnSkillsFailureStore.getState().isOpen).toBe(false); + }, + ); +}); diff --git a/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.ts b/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.ts new file mode 100644 index 0000000000..aba4821e79 --- /dev/null +++ b/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.ts @@ -0,0 +1,37 @@ +import type { AlwaysOnSkillRef } from "@posthog/shared"; +import { create } from "zustand"; + +export type AlwaysOnSkillsFailureAction = + | "retry" + | "continue" + | "disable" + | "cancel"; + +interface AlwaysOnSkillsFailureState { + isOpen: boolean; + error: string | null; + skills: AlwaysOnSkillRef[]; + resolve: ((action: AlwaysOnSkillsFailureAction) => void) | null; + confirm: ( + error: string, + skills: AlwaysOnSkillRef[], + ) => Promise; + choose: (action: AlwaysOnSkillsFailureAction) => void; +} + +export const useAlwaysOnSkillsFailureStore = + create()((set, get) => ({ + isOpen: false, + error: null, + skills: [], + resolve: null, + confirm: (error, skills) => + new Promise((resolve) => { + get().resolve?.("cancel"); + set({ isOpen: true, error, skills, resolve }); + }), + choose: (action) => { + get().resolve?.(action); + set({ isOpen: false, error: null, skills: [], resolve: null }); + }, + })); diff --git a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts index 6862976fe6..9ca29c07db 100644 --- a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts +++ b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts @@ -156,6 +156,12 @@ export class TrpcTaskCreationHost implements ITaskCreationHost { ); } + renderAlwaysOnSkillInstructions( + skills: Parameters[0], + ): Promise { + return hostClient().skills.renderAlwaysOn.query(skills); + } + takeWarmTaskLease(args: { repository: string; branch?: string | null; diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index fe2122fdad..97e63dacb3 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -54,6 +54,7 @@ import { import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData"; import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOrder"; +import { AlwaysOnSkillsFailureDialog } from "@posthog/ui/features/task-detail/components/AlwaysOnSkillsFailureDialog"; import { ExistingWorktreeDialog } from "@posthog/ui/features/task-detail/components/ExistingWorktreeDialog"; import { RemoteBranchCheckoutDialog } from "@posthog/ui/features/task-detail/components/RemoteBranchCheckoutDialog"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; @@ -332,6 +333,7 @@ function RootLayout() { + ); @@ -516,6 +518,7 @@ function RootLayout() { + ; export type BundleLocalSkillOutput = z.infer; export type SkillBundleRef = z.infer; diff --git a/packages/workspace-server/src/services/skills/skills.test.ts b/packages/workspace-server/src/services/skills/skills.test.ts index 8feeb6a074..5d01a00cb4 100644 --- a/packages/workspace-server/src/services/skills/skills.test.ts +++ b/packages/workspace-server/src/services/skills/skills.test.ts @@ -911,3 +911,21 @@ describe("resolveSkillBundleDependencies", () => { ).rejects.toThrow(/exceeds the 50-skill limit/); }); }); + +describe("renderAlwaysOnSkillInstructions", () => { + it("renders validated skills in configured order without frontmatter", async () => { + const first = await createSkill(repoSkillsDir, "first"); + const second = await createSkill(repoSkillsDir, "second"); + + const rendered = await makeService().renderAlwaysOnSkillInstructions([ + { name: "second", source: "repo", path: second, order: 1 }, + { name: "first", source: "repo", path: first, order: 0 }, + ]); + + expect(rendered.indexOf("## first")).toBeLessThan( + rendered.indexOf("## second"), + ); + expect(rendered).toContain(`Installed at: ${first}`); + expect(rendered).not.toContain("description: about first"); + }); +}); diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts index 039a875935..4617f8ab56 100644 --- a/packages/workspace-server/src/services/skills/skills.ts +++ b/packages/workspace-server/src/services/skills/skills.ts @@ -509,6 +509,24 @@ export class SkillsService { }); } + async renderAlwaysOnSkillInstructions( + refs: Array, + ): Promise { + const blocks = await Promise.all( + [...refs] + .sort((left, right) => left.order - right.order) + .map(async (ref) => { + const skillDir = await this.resolveKnownSkillDir(ref.path); + const manifest = await fs.promises.readFile( + path.join(skillDir, "SKILL.md"), + "utf-8", + ); + return `## ${ref.name}\n\nInstalled at: ${skillDir}\n\n${stripFrontmatter(manifest).trim()}`; + }), + ); + return `Always-on skills apply for the entire session. Follow every skill below in the listed order. Supporting files are available at each installed path. Do not execute scripts unless the task requires them.\n\n${blocks.join("\n\n---\n\n")}`; + } + /** * A repository can commit any ancestor of its skills (`.claude` or * `.claude/skills`) as a symlink pointing outside the repo, which passes the From ebd71dd391678767a490f9937340a5f960b223c1 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:40 -0400 Subject: [PATCH 02/12] refactor(skills): simplify always-on metadata Generated-By: PostHog Code Task-Id: 73390913-7191-4450-9fd3-31dd12879508 --- packages/agent/src/server/agent-server.test.ts | 3 +-- packages/agent/src/server/agent-server.ts | 10 +++++----- packages/core/src/sessions/cloudArtifactIdentifiers.ts | 4 +--- .../core/src/sessions/cloudArtifactService.test.ts | 6 ++---- packages/core/src/sessions/cloudArtifactService.ts | 5 +---- packages/core/src/sessions/cloudPrompt.ts | 2 +- packages/core/src/task-detail/taskCreationSaga.ts | 3 +-- packages/shared/src/domain-types.ts | 1 + packages/shared/src/task-creation-domain.ts | 1 - .../src/features/task-detail/hooks/useTaskCreation.ts | 4 +--- .../stores/alwaysOnSkillsFailureStore.test.ts | 1 - .../workspace-server/src/services/skills/schemas.ts | 7 ++----- .../src/services/skills/skills.test.ts | 6 +++--- .../workspace-server/src/services/skills/skills.ts | 8 ++++++-- 14 files changed, 25 insertions(+), 36 deletions(-) diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 1eb6bab178..e73de1011c 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -2346,8 +2346,7 @@ describe("AgentServer HTTP Mode", () => { content_sha256: checksum, bundle_format: "zip", schema_version: 1, - activation: "always", - activation_order: 0, + always_on: true, }, }, ], diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 9319ed7ed9..89c071e865 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -2842,12 +2842,12 @@ export class AgentServer { .filter( (artifact) => artifact.type === "skill_bundle" && - artifact.metadata?.activation === "always", + artifact.metadata?.always_on === true, ) - .sort( - (left, right) => - (left.metadata?.activation_order ?? 0) - - (right.metadata?.activation_order ?? 0), + .sort((left, right) => + `${left.metadata?.skill_source}:${left.metadata?.skill_name}`.localeCompare( + `${right.metadata?.skill_source}:${right.metadata?.skill_name}`, + ), ) .map((artifact) => this.installedSkillBundleInfo.get( diff --git a/packages/core/src/sessions/cloudArtifactIdentifiers.ts b/packages/core/src/sessions/cloudArtifactIdentifiers.ts index 0a15d3d099..5935b4744b 100644 --- a/packages/core/src/sessions/cloudArtifactIdentifiers.ts +++ b/packages/core/src/sessions/cloudArtifactIdentifiers.ts @@ -1,5 +1,4 @@ import type { - SkillActivation, TaskRunArtifactMetadata, UploadableSkillSource, } from "@posthog/shared"; @@ -52,8 +51,7 @@ export interface CloudSkillBundleRef { name: string; source: UploadableSkillSource; path: string; - activation?: SkillActivation; - activationOrder?: number; + alwaysOn?: boolean; } export interface LocalSkillBundle { diff --git a/packages/core/src/sessions/cloudArtifactService.test.ts b/packages/core/src/sessions/cloudArtifactService.test.ts index 661a7571cf..0d10a09533 100644 --- a/packages/core/src/sessions/cloudArtifactService.test.ts +++ b/packages/core/src/sessions/cloudArtifactService.test.ts @@ -170,8 +170,7 @@ describe("CloudArtifactService", () => { name: "local-skill", source: "user", path: "/tmp/local-skill", - activation: "always", - activationOrder: 2, + alwaysOn: true, }, ], ); @@ -190,8 +189,7 @@ describe("CloudArtifactService", () => { skill_source: "user", bundle_format: "zip", schema_version: 1, - activation: "always", - activation_order: 2, + always_on: true, }), }), ], diff --git a/packages/core/src/sessions/cloudArtifactService.ts b/packages/core/src/sessions/cloudArtifactService.ts index a2fabfe27d..a2ef045ed3 100644 --- a/packages/core/src/sessions/cloudArtifactService.ts +++ b/packages/core/src/sessions/cloudArtifactService.ts @@ -266,10 +266,7 @@ export class CloudArtifactService { content_sha256: bundle.contentSha256, bundle_format: "zip", schema_version: 1, - activation: skillBundleRef.activation ?? "explicit", - ...(skillBundleRef.activationOrder !== undefined - ? { activation_order: skillBundleRef.activationOrder } - : {}), + ...(skillBundleRef.alwaysOn ? { always_on: true } : {}), }, }, }; diff --git a/packages/core/src/sessions/cloudPrompt.ts b/packages/core/src/sessions/cloudPrompt.ts index 8596b2e3f1..da0f527b34 100644 --- a/packages/core/src/sessions/cloudPrompt.ts +++ b/packages/core/src/sessions/cloudPrompt.ts @@ -77,7 +77,7 @@ function collectSkillBundleRefs(prompt: string): CloudSkillBundleRef[] { continue; } seen.add(key); - refs.push({ ...tag, activation: "explicit" }); + refs.push(tag); } return refs; diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 9d037e40ca..48ffbe975f 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -86,8 +86,7 @@ function addAlwaysOnSkills( for (const skill of input.alwaysOnSkills ?? []) { refs.set(`${skill.source}:${skill.path}`, { ...skill, - activation: "always", - activationOrder: skill.order, + alwaysOn: true, }); } return { ...transport, skillBundles: [...refs.values()] }; diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index c506fe78c5..9003c84a45 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -233,6 +233,7 @@ export interface TaskRunArtifactMetadata { content_sha256: string; bundle_format: "zip"; schema_version: number; + always_on?: boolean; } export interface TaskRunArtifact { diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index d5c48a1ab4..91f7f100f8 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -14,7 +14,6 @@ export interface AlwaysOnSkillRef { name: string; source: "user" | "repo" | "marketplace" | "codex"; path: string; - order: number; } // Host-agnostic input/output for the task-creation flow. The renderer diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 7e17b89e0d..5588d645a4 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -326,9 +326,7 @@ export function useTaskCreation({ const serializedContent = contentToXml(content).trim(); const filePaths = extractFilePaths(content); const settings = useSettingsStore.getState(); - let alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills.map( - (skill, order) => ({ ...skill, order }), - ); + let alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills; while (alwaysOnSkills.length > 0) { try { await hostClient.skills.renderAlwaysOn.query(alwaysOnSkills); diff --git a/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts b/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts index 583e0247f4..f3fcca2c09 100644 --- a/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts +++ b/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts @@ -18,7 +18,6 @@ describe("alwaysOnSkillsFailureStore", () => { name: "example", source: "user" as const, path: "/skills/example", - order: 0, }; const result = useAlwaysOnSkillsFailureStore .getState() diff --git a/packages/workspace-server/src/services/skills/schemas.ts b/packages/workspace-server/src/services/skills/schemas.ts index d8d8a1de96..23ab9da036 100644 --- a/packages/workspace-server/src/services/skills/schemas.ts +++ b/packages/workspace-server/src/services/skills/schemas.ts @@ -122,8 +122,7 @@ export const bundleLocalSkillInput = z.object({ name: z.string().min(1), source: z.enum(["user", "repo", "marketplace", "codex"]), path: z.string().min(1), - activation: z.enum(["explicit", "always", "dependency"]).optional(), - activationOrder: z.number().int().nonnegative().optional(), + alwaysOn: z.boolean().optional(), }); export const bundleLocalSkillOutput = z.object({ @@ -139,9 +138,7 @@ export const bundleLocalSkillOutput = z.object({ export const resolveSkillDependenciesInput = z.array(bundleLocalSkillInput); export const resolveSkillDependenciesOutput = z.array(bundleLocalSkillInput); -export const renderAlwaysOnSkillsInput = z.array( - bundleLocalSkillInput.extend({ order: z.number().int().nonnegative() }), -); +export const renderAlwaysOnSkillsInput = z.array(bundleLocalSkillInput); export const renderAlwaysOnSkillsOutput = z.string(); export type BundleLocalSkillInput = z.infer; diff --git a/packages/workspace-server/src/services/skills/skills.test.ts b/packages/workspace-server/src/services/skills/skills.test.ts index 5d01a00cb4..6e9c11f43a 100644 --- a/packages/workspace-server/src/services/skills/skills.test.ts +++ b/packages/workspace-server/src/services/skills/skills.test.ts @@ -913,13 +913,13 @@ describe("resolveSkillBundleDependencies", () => { }); describe("renderAlwaysOnSkillInstructions", () => { - it("renders validated skills in configured order without frontmatter", async () => { + it("renders validated skills in canonical order without frontmatter", async () => { const first = await createSkill(repoSkillsDir, "first"); const second = await createSkill(repoSkillsDir, "second"); const rendered = await makeService().renderAlwaysOnSkillInstructions([ - { name: "second", source: "repo", path: second, order: 1 }, - { name: "first", source: "repo", path: first, order: 0 }, + { name: "second", source: "repo", path: second }, + { name: "first", source: "repo", path: first }, ]); expect(rendered.indexOf("## first")).toBeLessThan( diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts index 4617f8ab56..fa5e79130d 100644 --- a/packages/workspace-server/src/services/skills/skills.ts +++ b/packages/workspace-server/src/services/skills/skills.ts @@ -510,11 +510,15 @@ export class SkillsService { } async renderAlwaysOnSkillInstructions( - refs: Array, + refs: SkillBundleRef[], ): Promise { const blocks = await Promise.all( [...refs] - .sort((left, right) => left.order - right.order) + .sort((left, right) => + `${left.source}:${left.name}`.localeCompare( + `${right.source}:${right.name}`, + ), + ) .map(async (ref) => { const skillDir = await this.resolveKnownSkillDir(ref.path); const manifest = await fs.promises.readFile( From cbce211e5f40adaae4440e8fa2693996a57c0827 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:42 -0400 Subject: [PATCH 03/12] refactor(skills): reuse preflight instructions Generated-By: PostHog Code Task-Id: 73390913-7191-4450-9fd3-31dd12879508 --- .../core/src/task-detail/taskCreationHost.ts | 8 +---- .../core/src/task-detail/taskCreationSaga.ts | 32 +++++++------------ packages/core/src/task-detail/taskInput.ts | 2 ++ packages/shared/src/task-creation-domain.ts | 1 + .../ui/src/features/settings/settingsStore.ts | 2 +- .../task-detail/hooks/useTaskCreation.ts | 6 +++- .../task-detail/taskCreationHostImpl.ts | 6 ---- 7 files changed, 22 insertions(+), 35 deletions(-) diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts index 838c147651..2d29814552 100644 --- a/packages/core/src/task-detail/taskCreationHost.ts +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -1,11 +1,6 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import type { CloudSkillBundleRef } from "@posthog/core/sessions/cloudArtifactIdentifiers"; -import type { - AlwaysOnSkillRef, - Workspace, - WorkspaceInfo, - WorkspaceMode, -} from "@posthog/shared"; +import type { Workspace, WorkspaceInfo, WorkspaceMode } from "@posthog/shared"; import type { TaskCreationApiClient } from "./taskCreationApiClient"; export interface CloudPromptTransport { @@ -108,7 +103,6 @@ export interface ITaskCreationHost { * too, or a typed `/my-skill` reaches the sandbox with no bundle attached. */ resolveLocalSkillCommandPrompt(prompt: string): Promise; - renderAlwaysOnSkillInstructions(skills: AlwaysOnSkillRef[]): Promise; /** * Return-and-clear the pre-warmed sandbox lease matching the composer * selection, if one was provisioned while the user typed. The saga uploads diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 48ffbe975f..9d6247d269 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -77,19 +77,17 @@ function addAlwaysOnSkills( transport: CloudPromptTransport, input: TaskCreationInput, ): CloudPromptTransport { - const refs = new Map( - (transport.skillBundles ?? []).map((skill) => [ - `${skill.source}:${skill.path}`, - skill, - ]), - ); - for (const skill of input.alwaysOnSkills ?? []) { - refs.set(`${skill.source}:${skill.path}`, { + const refs = [ + ...(transport.skillBundles ?? []), + ...(input.alwaysOnSkills ?? []).map((skill) => ({ ...skill, alwaysOn: true, - }); - } - return { ...transport, skillBundles: [...refs.values()] }; + })), + ]; + const deduplicated = new Map( + refs.map((skill) => [`${skill.source}:${skill.path}`, skill]), + ); + return { ...transport, skillBundles: [...deduplicated.values()] }; } export class TaskCreationSaga extends Saga< @@ -519,13 +517,6 @@ export class TaskCreationSaga extends Saga< const shouldConnect = !isCloudCreate && (!!input.taskId || !!agentCwd); if (shouldConnect) { - const alwaysOnSkillInstructions = input.alwaysOnSkills?.length - ? await this.readOnlyStep("resolve_always_on_skills", () => - this.deps.host.renderAlwaysOnSkillInstructions( - input.alwaysOnSkills ?? [], - ), - ) - : undefined; const initialPrompt = !isPiRuntime && !input.taskId && input.content ? await this.readOnlyStep("build_prompt_blocks", () => @@ -582,8 +573,9 @@ export class TaskCreationSaga extends Saga< connectParams.contextWindow = input.contextWindow; if (input.fastMode !== undefined) connectParams.fastMode = input.fastMode; - if (alwaysOnSkillInstructions) - connectParams.alwaysOnSkillInstructions = alwaysOnSkillInstructions; + if (input.alwaysOnSkillInstructions) + connectParams.alwaysOnSkillInstructions = + input.alwaysOnSkillInstructions; if (importedClaude) { connectParams.importedSessionId = importedClaude.importedSessionId; connectParams.adapter = "claude"; diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 45d2fbf9fc..7b87cf1b8f 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -36,6 +36,7 @@ export interface PrepareTaskInputOptions { channelContextId?: string; customInstructions?: string; alwaysOnSkills?: TaskCreationInput["alwaysOnSkills"]; + alwaysOnSkillInstructions?: string; autoPublishCloudRuns?: boolean; rtkEnabledCloud?: boolean; allowNoRepo?: boolean; @@ -87,6 +88,7 @@ export function prepareTaskInput( channelContextId: options.channelContextId, customInstructions: isCloud ? options.customInstructions : undefined, alwaysOnSkills: options.alwaysOnSkills, + alwaysOnSkillInstructions: options.alwaysOnSkillInstructions, allowNoRepo: options.allowNoRepo, importedMcpServers: isCloud ? options.importedMcpServers : undefined, relayedMcpServers: isCloud ? options.relayedMcpServers : undefined, diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 91f7f100f8..4632c84512 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -90,6 +90,7 @@ export interface TaskCreationInput { */ customInstructions?: string; alwaysOnSkills?: AlwaysOnSkillRef[]; + alwaysOnSkillInstructions?: string; /** * Local (~/.claude.json) MCP servers classified as importable, forwarded to * the cloud sandbox in the run-creation payload. Cloud-only; local sessions diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index c9993a3b80..97503d0cce 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -96,7 +96,7 @@ export interface SyncedCustomInstructions { truncated: boolean; } -export type AlwaysOnSkillPreference = Omit; +export type AlwaysOnSkillPreference = AlwaysOnSkillRef; // ---------- Store shape ---------- diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 5588d645a4..aae95c9858 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -327,9 +327,11 @@ export function useTaskCreation({ const filePaths = extractFilePaths(content); const settings = useSettingsStore.getState(); let alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills; + let alwaysOnSkillInstructions: string | undefined; while (alwaysOnSkills.length > 0) { try { - await hostClient.skills.renderAlwaysOn.query(alwaysOnSkills); + alwaysOnSkillInstructions = + await hostClient.skills.renderAlwaysOn.query(alwaysOnSkills); break; } catch (error) { const action = await useAlwaysOnSkillsFailureStore @@ -349,6 +351,7 @@ export function useTaskCreation({ } } alwaysOnSkills = []; + alwaysOnSkillInstructions = undefined; } } @@ -419,6 +422,7 @@ export function useTaskCreation({ channelContextId, customInstructions: getEffectiveCustomInstructions(settings), alwaysOnSkills, + alwaysOnSkillInstructions, autoPublishCloudRuns: settings.autoPublishCloudRuns, rtkEnabledCloud: settings.rtkEnabledCloud, allowNoRepo, diff --git a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts index 9ca29c07db..6862976fe6 100644 --- a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts +++ b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts @@ -156,12 +156,6 @@ export class TrpcTaskCreationHost implements ITaskCreationHost { ); } - renderAlwaysOnSkillInstructions( - skills: Parameters[0], - ): Promise { - return hostClient().skills.renderAlwaysOn.query(skills); - } - takeWarmTaskLease(args: { repository: string; branch?: string | null; From f033c60087fa441fd68f1c62e881544a07e8ed4b Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:44 -0400 Subject: [PATCH 04/12] Fix always-on skill recovery Generated-By: PostHog Code Task-Id: 73390913-7191-4450-9fd3-31dd12879508 --- .../agent/src/server/agent-server.test.ts | 7 ++- packages/agent/src/server/agent-server.ts | 10 +++- packages/core/src/sessions/sessionService.ts | 24 +++++++-- .../sessions/sessionConfigStore.test.ts | 49 +++++++++++++++++ .../features/sessions/sessionConfigStore.ts | 53 ++++++++++++++++++- .../features/sessions/sessionServiceHost.ts | 6 +++ .../task-detail/hooks/useTaskCreation.ts | 48 +++++++++-------- .../src/services/skills/schemas.ts | 10 +++- .../src/services/skills/skills.test.ts | 26 +++++++-- .../src/services/skills/skills.ts | 45 ++++++++++++---- 10 files changed, 231 insertions(+), 47 deletions(-) create mode 100644 packages/ui/src/features/sessions/sessionConfigStore.test.ts diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index e73de1011c..28f1d54ed5 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -2374,14 +2374,13 @@ describe("AgentServer HTTP Mode", () => { )?.text; expect(sentText).toBe("/local-test-skill with context"); - expect(sentMeta?.localSkillContext).toContain( - 'local skill "/local-test-skill"', - ); expect(sentMeta?.localSkillContext).toContain("LOCAL_SKILL_MARKER"); expect(sentMeta?.localSkillContext).toContain( "always-on skills apply for the entire session", ); - expect(sentMeta?.localSkillContext).toContain("with context"); + expect( + String(sentMeta?.localSkillContext).match(/LOCAL_SKILL_MARKER/g), + ).toHaveLength(1); expect(sentMeta?.localSkillName).toBe("local-test-skill"); }, 20000); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 89c071e865..a5dc420882 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -2887,6 +2887,14 @@ export class AgentServer { : null; if (invocation) { + if ( + alwaysOnSkills.some((skill) => skill.skillName === invocation.skillName) + ) { + return { + skillName: invocation.skillName, + context: alwaysOnContext ?? "", + }; + } const hasMatchingArtifact = artifacts.some( (artifact) => artifact.type === "skill_bundle" && @@ -2921,7 +2929,7 @@ export class AgentServer { .join("\n"); const attachedContext = this.buildAttachedSkillsPromptContext( runId, - artifacts, + artifacts.filter((artifact) => artifact.metadata?.always_on !== true), messageText, ); if (!alwaysOnContext) return attachedContext; diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 8ef3ee2371..f7804d8828 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -356,6 +356,14 @@ export interface SessionServiceDeps { options: SessionConfigOption[], ) => void; removePersistedConfigOptions: (taskRunId: string) => void; + getPersistedAlwaysOnSkillInstructions?: ( + taskRunId: string, + ) => string | undefined; + setPersistedAlwaysOnSkillInstructions?: ( + taskRunId: string, + instructions: string, + ) => void; + removePersistedAlwaysOnSkillInstructions?: (taskRunId: string) => void; adapterStore: { getAdapter(taskRunId: string): Adapter | undefined; setAdapter(taskRunId: string, adapter: Adapter): void; @@ -1969,6 +1977,10 @@ export class SessionService { const persistedConfigOptions = this.d.getPersistedConfigOptions(taskRunId); const previous = this.d.store.getSessions()[taskRunId]; + const resolvedAlwaysOnSkillInstructions = + alwaysOnSkillInstructions ?? + previous?.alwaysOnSkillInstructions ?? + this.d.getPersistedAlwaysOnSkillInstructions?.(taskRunId); const session = createBaseSession(taskRunId, taskId, taskTitle); // Repainting from the log must not blank a transcript we already hold: @@ -2066,7 +2078,7 @@ export class SessionService { this.d.settings; const effectiveCustomInstructions = [ customInstructions, - alwaysOnSkillInstructions ?? previous?.alwaysOnSkillInstructions, + resolvedAlwaysOnSkillInstructions, ] .filter((value): value is string => Boolean(value)) .join("\n\n"); @@ -2090,8 +2102,7 @@ export class SessionService { }); if (result) { - session.alwaysOnSkillInstructions = - alwaysOnSkillInstructions ?? previous?.alwaysOnSkillInstructions; + session.alwaysOnSkillInstructions = resolvedAlwaysOnSkillInstructions; const liveConfigOptions = result.configOptions as | SessionConfigOption[] | undefined; @@ -2216,6 +2227,7 @@ export class SessionService { // permanent disconnect (archive, delete, fresh session) may drop them. this.d.adapterStore.removeAdapter(taskRunId); this.d.removePersistedConfigOptions(taskRunId); + this.d.removePersistedAlwaysOnSkillInstructions?.(taskRunId); } } @@ -2460,6 +2472,12 @@ export class SessionService { session.contextWindow = contextWindow; session.fastMode = fastMode; session.alwaysOnSkillInstructions = alwaysOnSkillInstructions; + if (alwaysOnSkillInstructions) { + this.d.setPersistedAlwaysOnSkillInstructions?.( + taskRun.id, + alwaysOnSkillInstructions, + ); + } // An imported CLI session had its history replayed during agent.start; // the replay is already in the local run log, so load it for the UI. diff --git a/packages/ui/src/features/sessions/sessionConfigStore.test.ts b/packages/ui/src/features/sessions/sessionConfigStore.test.ts new file mode 100644 index 0000000000..edaa004f7d --- /dev/null +++ b/packages/ui/src/features/sessions/sessionConfigStore.test.ts @@ -0,0 +1,49 @@ +import { + flushRendererStateWrites, + registerRendererStateStorage, +} from "@posthog/ui/shell/rendererStorage"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getPersistedAlwaysOnSkillInstructions, + removePersistedAlwaysOnSkillInstructions, + setPersistedAlwaysOnSkillInstructions, + useSessionConfigStore, +} from "./sessionConfigStore"; + +const getItem = vi.fn(); +const setItem = vi.fn(); +const removeItem = vi.fn(); + +registerRendererStateStorage({ getItem, setItem, removeItem }); + +describe("sessionConfigStore always-on skill instructions", () => { + beforeEach(async () => { + await flushRendererStateWrites(); + getItem.mockReset(); + setItem.mockReset(); + removeItem.mockReset(); + getItem.mockResolvedValue(null); + setItem.mockResolvedValue(undefined); + removeItem.mockResolvedValue(undefined); + useSessionConfigStore.setState({ + configsByRunId: {}, + alwaysOnSkillInstructionsByRunId: {}, + }); + }); + + it("persists instructions by task run until they are removed", async () => { + setPersistedAlwaysOnSkillInstructions("run-1", "Follow this skill"); + + expect(getPersistedAlwaysOnSkillInstructions("run-1")).toBe( + "Follow this skill", + ); + await flushRendererStateWrites(); + expect(setItem).toHaveBeenCalledWith( + "session-config-storage", + expect.stringContaining("Follow this skill"), + ); + + removePersistedAlwaysOnSkillInstructions("run-1"); + expect(getPersistedAlwaysOnSkillInstructions("run-1")).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/features/sessions/sessionConfigStore.ts b/packages/ui/src/features/sessions/sessionConfigStore.ts index 45bc88d887..7df3aa1c8f 100644 --- a/packages/ui/src/features/sessions/sessionConfigStore.ts +++ b/packages/ui/src/features/sessions/sessionConfigStore.ts @@ -4,8 +4,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; interface SessionConfigState { - /** Map of taskRunId -> persisted config options */ configsByRunId: Record; + alwaysOnSkillInstructionsByRunId: Record; } interface SessionConfigActions { @@ -15,6 +15,12 @@ interface SessionConfigActions { getConfigOptions: (taskRunId: string) => SessionConfigOption[] | undefined; /** Remove config options for a task run */ removeConfigOptions: (taskRunId: string) => void; + setAlwaysOnSkillInstructions: ( + taskRunId: string, + instructions: string, + ) => void; + getAlwaysOnSkillInstructions: (taskRunId: string) => string | undefined; + removeAlwaysOnSkillInstructions: (taskRunId: string) => void; } type SessionConfigStore = SessionConfigState & SessionConfigActions; @@ -23,6 +29,7 @@ export const useSessionConfigStore = create()( persist( (set, get) => ({ configsByRunId: {}, + alwaysOnSkillInstructionsByRunId: {}, setConfigOptions: (taskRunId, options) => set((state) => ({ @@ -36,11 +43,30 @@ export const useSessionConfigStore = create()( const { [taskRunId]: _removed, ...rest } = state.configsByRunId; return { configsByRunId: rest }; }), + setAlwaysOnSkillInstructions: (taskRunId, instructions) => + set((state) => ({ + alwaysOnSkillInstructionsByRunId: { + ...state.alwaysOnSkillInstructionsByRunId, + [taskRunId]: instructions, + }, + })), + getAlwaysOnSkillInstructions: (taskRunId) => + get().alwaysOnSkillInstructionsByRunId[taskRunId], + removeAlwaysOnSkillInstructions: (taskRunId) => + set((state) => { + const { [taskRunId]: _removed, ...rest } = + state.alwaysOnSkillInstructionsByRunId; + return { alwaysOnSkillInstructionsByRunId: rest }; + }), }), { name: "session-config-storage", storage: electronStorage, - partialize: (state) => ({ configsByRunId: state.configsByRunId }), + partialize: (state) => ({ + configsByRunId: state.configsByRunId, + alwaysOnSkillInstructionsByRunId: + state.alwaysOnSkillInstructionsByRunId, + }), }, ), ); @@ -64,3 +90,26 @@ export function setPersistedConfigOptions( export function removePersistedConfigOptions(taskRunId: string): void { useSessionConfigStore.getState().removeConfigOptions(taskRunId); } + +export function getPersistedAlwaysOnSkillInstructions( + taskRunId: string, +): string | undefined { + return useSessionConfigStore + .getState() + .getAlwaysOnSkillInstructions(taskRunId); +} + +export function setPersistedAlwaysOnSkillInstructions( + taskRunId: string, + instructions: string, +): void { + useSessionConfigStore + .getState() + .setAlwaysOnSkillInstructions(taskRunId, instructions); +} + +export function removePersistedAlwaysOnSkillInstructions( + taskRunId: string, +): void { + useSessionConfigStore.getState().removeAlwaysOnSkillInstructions(taskRunId); +} diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts index 9811a82430..0d8e4e08a5 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.ts @@ -31,8 +31,11 @@ import { NotificationBus } from "@posthog/ui/features/notifications/notification import { SpeechNotifier } from "@posthog/ui/features/notifications/speechNotifier"; import { useSessionAdapterStore } from "@posthog/ui/features/sessions/sessionAdapterStore"; import { + getPersistedAlwaysOnSkillInstructions, getPersistedConfigOptions, + removePersistedAlwaysOnSkillInstructions, removePersistedConfigOptions, + setPersistedAlwaysOnSkillInstructions, setPersistedConfigOptions, } from "@posthog/ui/features/sessions/sessionConfigStore"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; @@ -117,6 +120,9 @@ function buildSessionServiceDeps(): SessionServiceDeps { getPersistedConfigOptions(taskRunId) ?? undefined, setPersistedConfigOptions, removePersistedConfigOptions, + getPersistedAlwaysOnSkillInstructions, + setPersistedAlwaysOnSkillInstructions, + removePersistedAlwaysOnSkillInstructions, adapterStore: { getAdapter: (taskRunId) => useSessionAdapterStore.getState().getAdapter(taskRunId), diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index aae95c9858..009ef2a307 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -329,30 +329,36 @@ export function useTaskCreation({ let alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills; let alwaysOnSkillInstructions: string | undefined; while (alwaysOnSkills.length > 0) { - try { - alwaysOnSkillInstructions = - await hostClient.skills.renderAlwaysOn.query(alwaysOnSkills); + const rendered = + await hostClient.skills.renderAlwaysOn.query(alwaysOnSkills); + alwaysOnSkillInstructions = rendered.instructions; + if (rendered.failures.length === 0) { break; - } catch (error) { - const action = await useAlwaysOnSkillsFailureStore - .getState() - .confirm( - error instanceof Error ? error.message : String(error), - alwaysOnSkills, - ); - if (action === "retry") continue; - if (action === "cancel") { - setIsCreatingTask(false); - return false; - } - if (action === "disable") { - for (const skill of alwaysOnSkills) { - useSettingsStore.getState().setSkillAlwaysOn(skill, false); - } + } + const failedSkills = rendered.failures.map(({ skill }) => skill); + const action = await useAlwaysOnSkillsFailureStore + .getState() + .confirm( + rendered.failures.map(({ error }) => error).join("\n"), + failedSkills, + ); + if (action === "retry") continue; + if (action === "cancel") { + setIsCreatingTask(false); + return false; + } + if (action === "disable") { + for (const skill of failedSkills) { + useSettingsStore.getState().setSkillAlwaysOn(skill, false); } - alwaysOnSkills = []; - alwaysOnSkillInstructions = undefined; } + const failedKeys = new Set( + failedSkills.map((skill) => `${skill.source}:${skill.path}`), + ); + alwaysOnSkills = alwaysOnSkills.filter( + (skill) => !failedKeys.has(`${skill.source}:${skill.path}`), + ); + break; } const shouldShowPendingView = !onTaskCreated && !!plainPromptText; diff --git a/packages/workspace-server/src/services/skills/schemas.ts b/packages/workspace-server/src/services/skills/schemas.ts index 23ab9da036..363ae24d1a 100644 --- a/packages/workspace-server/src/services/skills/schemas.ts +++ b/packages/workspace-server/src/services/skills/schemas.ts @@ -139,7 +139,15 @@ export const resolveSkillDependenciesInput = z.array(bundleLocalSkillInput); export const resolveSkillDependenciesOutput = z.array(bundleLocalSkillInput); export const renderAlwaysOnSkillsInput = z.array(bundleLocalSkillInput); -export const renderAlwaysOnSkillsOutput = z.string(); +export const renderAlwaysOnSkillsOutput = z.object({ + instructions: z.string().optional(), + failures: z.array( + z.object({ + skill: bundleLocalSkillInput, + error: z.string(), + }), + ), +}); export type BundleLocalSkillInput = z.infer; export type BundleLocalSkillOutput = z.infer; diff --git a/packages/workspace-server/src/services/skills/skills.test.ts b/packages/workspace-server/src/services/skills/skills.test.ts index 6e9c11f43a..25ebe9116d 100644 --- a/packages/workspace-server/src/services/skills/skills.test.ts +++ b/packages/workspace-server/src/services/skills/skills.test.ts @@ -922,10 +922,28 @@ describe("renderAlwaysOnSkillInstructions", () => { { name: "first", source: "repo", path: first }, ]); - expect(rendered.indexOf("## first")).toBeLessThan( - rendered.indexOf("## second"), + expect(rendered.instructions?.indexOf("## first")).toBeLessThan( + rendered.instructions?.indexOf("## second") ?? -1, ); - expect(rendered).toContain(`Installed at: ${first}`); - expect(rendered).not.toContain("description: about first"); + expect(rendered.instructions).toContain(`Installed at: ${first}`); + expect(rendered.instructions).not.toContain("description: about first"); + expect(rendered.failures).toEqual([]); + }); + + it("renders readable skills and reports unreadable skills separately", async () => { + const readable = await createSkill(repoSkillsDir, "readable"); + + const rendered = await makeService().renderAlwaysOnSkillInstructions([ + { + name: "missing", + source: "repo", + path: path.join(repoSkillsDir, "missing"), + }, + { name: "readable", source: "repo", path: readable }, + ]); + + expect(rendered.instructions).toContain("## readable"); + expect(rendered.failures).toHaveLength(1); + expect(rendered.failures[0]?.skill.name).toBe("missing"); }); }); diff --git a/packages/workspace-server/src/services/skills/skills.ts b/packages/workspace-server/src/services/skills/skills.ts index fa5e79130d..006019a9a2 100644 --- a/packages/workspace-server/src/services/skills/skills.ts +++ b/packages/workspace-server/src/services/skills/skills.ts @@ -509,10 +509,11 @@ export class SkillsService { }); } - async renderAlwaysOnSkillInstructions( - refs: SkillBundleRef[], - ): Promise { - const blocks = await Promise.all( + async renderAlwaysOnSkillInstructions(refs: SkillBundleRef[]): Promise<{ + instructions?: string; + failures: { skill: SkillBundleRef; error: string }[]; + }> { + const results = await Promise.all( [...refs] .sort((left, right) => `${left.source}:${left.name}`.localeCompare( @@ -520,15 +521,37 @@ export class SkillsService { ), ) .map(async (ref) => { - const skillDir = await this.resolveKnownSkillDir(ref.path); - const manifest = await fs.promises.readFile( - path.join(skillDir, "SKILL.md"), - "utf-8", - ); - return `## ${ref.name}\n\nInstalled at: ${skillDir}\n\n${stripFrontmatter(manifest).trim()}`; + try { + const skillDir = await this.resolveKnownSkillDir(ref.path); + const manifest = await fs.promises.readFile( + path.join(skillDir, "SKILL.md"), + "utf-8", + ); + return { + block: `## ${ref.name}\n\nInstalled at: ${skillDir}\n\n${stripFrontmatter(manifest).trim()}`, + }; + } catch (error) { + return { + failure: { + skill: ref, + error: error instanceof Error ? error.message : String(error), + }, + }; + } }), ); - return `Always-on skills apply for the entire session. Follow every skill below in the listed order. Supporting files are available at each installed path. Do not execute scripts unless the task requires them.\n\n${blocks.join("\n\n---\n\n")}`; + const blocks = results.flatMap((result) => + result.block ? [result.block] : [], + ); + return { + instructions: + blocks.length > 0 + ? `Always-on skills apply for the entire session. Follow every skill below in the listed order. Supporting files are available at each installed path. Do not execute scripts unless the task requires them.\n\n${blocks.join("\n\n---\n\n")}` + : undefined, + failures: results.flatMap((result) => + result.failure ? [result.failure] : [], + ), + }; } /** From a66f7842ddfa795ab247b14368c98d972c080ca3 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:46 -0400 Subject: [PATCH 05/12] fix(skills): update always-on integration after rebase Generated-By: PostHog Code Task-Id: b0e948e2-b733-4d51-be6a-efd68d54216e --- packages/shared/src/index.ts | 10 +--------- .../sessionServiceHost.recovery.integration.test.ts | 3 +++ .../src/features/sessions/sessionServiceHost.test.ts | 3 +++ packages/ui/src/features/skills/SkillDetailPanel.tsx | 1 - 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 83bcc410be..45e7544815 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -340,15 +340,7 @@ export { serializeSkillMarkdown, stripFrontmatter, } from "./skills"; -export type { - ArtifactType, - PostHogAPIConfig, - TaskRun, - TaskRunArtifact, - TaskRunArtifactMetadata, - TaskRunEnvironment, - TaskRunStatus, -} from "./task"; +export type { PostHogAPIConfig } from "./task"; export { type CreateTaskAutomationOptions, createTaskAutomationSchema, diff --git a/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts index 422203c6b1..737564bfc4 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts @@ -127,6 +127,9 @@ const mockSessionConfigStore = vi.hoisted(() => ({ getPersistedConfigOptions: vi.fn(() => undefined), setPersistedConfigOptions: vi.fn(), removePersistedConfigOptions: vi.fn(), + getPersistedAlwaysOnSkillInstructions: vi.fn(() => undefined), + setPersistedAlwaysOnSkillInstructions: vi.fn(), + removePersistedAlwaysOnSkillInstructions: vi.fn(), })); vi.mock( diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index dad3186d0a..cafbceaa05 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -196,6 +196,9 @@ const mockSessionConfigStore = vi.hoisted(() => ({ >(() => undefined), setPersistedConfigOptions: vi.fn(), removePersistedConfigOptions: vi.fn(), + getPersistedAlwaysOnSkillInstructions: vi.fn(() => undefined), + setPersistedAlwaysOnSkillInstructions: vi.fn(), + removePersistedAlwaysOnSkillInstructions: vi.fn(), })); vi.mock( diff --git a/packages/ui/src/features/skills/SkillDetailPanel.tsx b/packages/ui/src/features/skills/SkillDetailPanel.tsx index 30ca95a44b..3e4c422a67 100644 --- a/packages/ui/src/features/skills/SkillDetailPanel.tsx +++ b/packages/ui/src/features/skills/SkillDetailPanel.tsx @@ -15,7 +15,6 @@ import { stripFrontmatter } from "@posthog/shared"; import { CodeMirrorEditor } from "@posthog/ui/features/code-editor/components/CodeMirrorEditor"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; -import { ExternalAppsOpener } from "@posthog/ui/features/task-detail/components/ExternalAppsOpener"; import { toast } from "@posthog/ui/primitives/toast"; import { AlertDialog, From 862cdf154a98f194a7a369c3c3b044ca68fd46d8 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:48 -0400 Subject: [PATCH 06/12] Fix always-on switch state styling Generated-By: PostHog Code Task-Id: b0e948e2-b733-4d51-be6a-efd68d54216e --- .../src/features/skills/SkillDetailPanel.tsx | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/features/skills/SkillDetailPanel.tsx b/packages/ui/src/features/skills/SkillDetailPanel.tsx index 3e4c422a67..448a643860 100644 --- a/packages/ui/src/features/skills/SkillDetailPanel.tsx +++ b/packages/ui/src/features/skills/SkillDetailPanel.tsx @@ -322,23 +322,25 @@ export function SkillDetailPanel({ : "Supporting files are available, but scripts never run automatically" } > - - setSkillAlwaysOn( - { - name: skill.name, - source: skill.source as Exclude< - typeof skill.source, - "bundled" - >, - path: skill.path, - }, - checked, - ) - } - /> + + + setSkillAlwaysOn( + { + name: skill.name, + source: skill.source as Exclude< + typeof skill.source, + "bundled" + >, + path: skill.path, + }, + checked, + ) + } + /> + From b7c3ec16ee0e8340cede534fea137ba15eb4a928 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:50 -0400 Subject: [PATCH 07/12] Show always-on skills in task composers Generated-By: PostHog Code Task-Id: b0e948e2-b733-4d51-be6a-efd68d54216e --- .../task-detail/components/TaskInput.tsx | 134 ++++++++++++++---- .../task-detail/hooks/useTaskCreation.ts | 8 +- 2 files changed, 112 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 2c0a9a8dc8..4ccb30a8e7 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -1,4 +1,4 @@ -import { FileText, X } from "@phosphor-icons/react"; +import { FileText, Lightbulb, X } from "@phosphor-icons/react"; import type { AutoresearchService } from "@posthog/core/autoresearch/autoresearch"; import { AUTORESEARCH_SERVICE } from "@posthog/core/autoresearch/identifiers"; import { buildKickoffPreamble } from "@posthog/core/autoresearch/prompts"; @@ -16,6 +16,7 @@ import { ButtonGroup } from "@posthog/quill"; import { type AgentRuntime, ANALYTICS_EVENTS } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useSkillsSelectionActions } from "@posthog/ui/features/skills/skillsSelectionStore"; import type { TaskInputReportAssociation } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; import { useTaskInputPrefillStore } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; import { navigateToInbox } from "@posthog/ui/router/navigationBridge"; @@ -223,8 +224,10 @@ export function TaskInput({ lastUsedPiModel, setLastUsedPiModel, _hasHydrated: settingsHydrated, + alwaysOnSkills, } = useSettingsStore(); const { data: skills } = useSkills(); + const { requestSkill } = useSkillsSelectionActions(); const editorRef = useRef(null); const handleAddSelectionToPrompt = useCallback( @@ -280,6 +283,9 @@ export function TaskInput({ // from this task's prompt. Re-include whenever the source context changes // (e.g. switching channels) so a dismissal doesn't stick across channels. const [channelContextDismissed, setChannelContextDismissed] = useState(false); + const [excludedAlwaysOnSkillKeys, setExcludedAlwaysOnSkillKeys] = useState( + () => new Set(), + ); const lastChannelContextRef = useRef(channelContext); useEffect(() => { if (lastChannelContextRef.current !== channelContext) { @@ -288,6 +294,28 @@ export function TaskInput({ } }, [channelContext]); const includeChannelContext = !!channelContext && !channelContextDismissed; + const includedAlwaysOnSkills = alwaysOnSkills.filter( + (skill) => !excludedAlwaysOnSkillKeys.has(`${skill.source}:${skill.path}`), + ); + + const handleOpenAlwaysOnSkill = useCallback( + (name: string) => { + requestSkill(name); + openSettings("skills"); + }, + [requestSkill], + ); + + const handleExcludeAlwaysOnSkill = useCallback( + (source: string, path: string) => { + setExcludedAlwaysOnSkillKeys((current) => { + const next = new Set(current); + next.add(`${source}:${path}`); + return next; + }); + }, + [], + ); const adapter = lastUsedAdapter; const prefillRequestKey = initialPromptKey ?? initialPrompt; @@ -918,7 +946,7 @@ export function TaskInput({ const { isCreatingTask, canSubmit, - handleSubmit, + handleSubmit: createTask, additionalDirectories, setAdditionalDirectories, } = useTaskCreation({ @@ -953,9 +981,19 @@ export function TaskInput({ channelName, channelId, channelContextId, + excludedAlwaysOnSkillKeys, allowNoRepo, }); + const handleSubmit = useCallback( + async (contentOverride?: EditorContent) => { + const submitted = await createTask(contentOverride); + if (submitted) setExcludedAlwaysOnSkillKeys(new Set()); + return submitted; + }, + [createTask], + ); + // Wraps the prompt in the autoresearch kickoff: protocol preamble first, // the user's composer content (chips intact) as the optimization brief. const handleAutoresearchSubmit = useCallback(async (): Promise => { @@ -1477,42 +1515,80 @@ export function TaskInput({ )} - {includeChannelContext && ( + {(includeChannelContext || + includedAlwaysOnSkills.length > 0) && (
Using: - - {onContextChipClick ? ( - - + + ) : ( + <> {channelName ? `#${channelName} ` : ""}CONTEXT.md + + )} + + + + + )} + {includedAlwaysOnSkills.map((skill) => ( + + + - ) : ( - <> - - - {channelName ? `#${channelName} ` : ""}CONTEXT.md - - - )} - - - - + + + + ))}
)} {effectiveWorkspaceMode === "cloud" && diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index 009ef2a307..a14e8c78b2 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -98,6 +98,7 @@ interface UseTaskCreationOptions { * injected context address CONTEXT.md upkeep writes by a stable id. */ channelContextId?: string; + excludedAlwaysOnSkillKeys?: ReadonlySet; /** * Channels "generic chat box" mode: drop the repo/branch requirement so a * task can be submitted without picking a repo. The agent decides at runtime @@ -196,6 +197,7 @@ export function useTaskCreation({ channelName, channelId, channelContextId, + excludedAlwaysOnSkillKeys, allowNoRepo, onTaskCreated, onTaskCreatedEffect, @@ -326,7 +328,10 @@ export function useTaskCreation({ const serializedContent = contentToXml(content).trim(); const filePaths = extractFilePaths(content); const settings = useSettingsStore.getState(); - let alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills; + let alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills.filter( + (skill) => + !excludedAlwaysOnSkillKeys?.has(`${skill.source}:${skill.path}`), + ); let alwaysOnSkillInstructions: string | undefined; while (alwaysOnSkills.length > 0) { const rendered = @@ -605,6 +610,7 @@ export function useTaskCreation({ channelName, channelId, channelContextId, + excludedAlwaysOnSkillKeys, allowNoRepo, bluebirdEnabled, personalChannel?.id, From 5ec13f867f3f16c8d951630413c17838f9c2c715 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:52 -0400 Subject: [PATCH 08/12] Simplify local always-on skill loading Generated-By: PostHog Code Task-Id: b0e948e2-b733-4d51-be6a-efd68d54216e --- packages/core/src/sessions/sessionService.ts | 59 +++++-------- .../core/src/task-detail/taskCreationSaga.ts | 5 +- packages/core/src/task-detail/taskInput.ts | 2 - .../host-router/src/routers/skills.router.ts | 10 --- packages/shared/src/sessions.ts | 1 - packages/shared/src/task-creation-domain.ts | 1 - .../canvas/components/ChannelHomeComposer.tsx | 29 ++++++- .../sessions/sessionConfigStore.test.ts | 25 +++--- .../features/sessions/sessionConfigStore.ts | 56 ++++++------- ...onServiceHost.recovery.integration.test.ts | 6 +- .../sessions/sessionServiceHost.test.ts | 6 +- .../features/sessions/sessionServiceHost.ts | 12 +-- .../components/AlwaysOnSkillChips.tsx | 70 ++++++++++++++++ .../AlwaysOnSkillsFailureDialog.tsx | 63 -------------- .../task-detail/components/TaskInput.tsx | 82 ++++--------------- .../task-detail/hooks/useTaskCreation.ts | 37 +-------- .../stores/alwaysOnSkillsFailureStore.test.ts | 32 -------- .../stores/alwaysOnSkillsFailureStore.ts | 37 --------- packages/ui/src/router/routes/__root.tsx | 3 - .../src/services/agent/agent.test.ts | 39 +++++++++ .../src/services/agent/agent.ts | 26 ++++++ .../src/services/agent/schemas.ts | 3 + .../src/services/skills/schemas.ts | 11 --- 23 files changed, 254 insertions(+), 361 deletions(-) create mode 100644 packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx delete mode 100644 packages/ui/src/features/task-detail/components/AlwaysOnSkillsFailureDialog.tsx delete mode 100644 packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts delete mode 100644 packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.ts diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index f7804d8828..5e7190df13 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -13,6 +13,7 @@ import { type AcpMessage, type Adapter, type AgentSession, + type AlwaysOnSkillRef, type CloudRegion, classifyGatewayLimitError, type ExecutionMode, @@ -356,14 +357,14 @@ export interface SessionServiceDeps { options: SessionConfigOption[], ) => void; removePersistedConfigOptions: (taskRunId: string) => void; - getPersistedAlwaysOnSkillInstructions?: ( + getPersistedAlwaysOnSkills?: ( taskRunId: string, - ) => string | undefined; - setPersistedAlwaysOnSkillInstructions?: ( + ) => AlwaysOnSkillRef[] | undefined; + setPersistedAlwaysOnSkills?: ( taskRunId: string, - instructions: string, + skills: AlwaysOnSkillRef[], ) => void; - removePersistedAlwaysOnSkillInstructions?: (taskRunId: string) => void; + removePersistedAlwaysOnSkills?: (taskRunId: string) => void; adapterStore: { getAdapter(taskRunId: string): Adapter | undefined; setAdapter(taskRunId: string, adapter: Adapter): void; @@ -412,7 +413,7 @@ export interface ConnectParams { reasoningLevel?: string; contextWindow?: "200k" | "1m"; fastMode?: boolean; - alwaysOnSkillInstructions?: string; + alwaysOnSkills?: AlwaysOnSkillRef[]; /** * Session ID of an imported Claude Code CLI transcript already copied into * the app's Claude config dir. The agent loads it and replays its history. @@ -1724,7 +1725,6 @@ export class SessionService { session.reasoningLevel = params.reasoningLevel; session.contextWindow = params.contextWindow; session.fastMode = params.fastMode; - session.alwaysOnSkillInstructions = params.alwaysOnSkillInstructions; if (params.initialPrompt?.length) { session.initialPrompt = params.initialPrompt; } @@ -1742,7 +1742,7 @@ export class SessionService { contextWindow, fastMode, importedSessionId, - alwaysOnSkillInstructions, + alwaysOnSkills, } = params; const { id: taskId, latest_run: latestRun } = task; const taskTitle = task.title || task.description || "Task"; @@ -1831,7 +1831,7 @@ export class SessionService { repoPath, auth, logResult, - alwaysOnSkillInstructions, + alwaysOnSkills, ); } else { if (!this.d.getIsOnline()) { @@ -1858,7 +1858,7 @@ export class SessionService { importedSessionId, contextWindow, fastMode, - alwaysOnSkillInstructions, + alwaysOnSkills, ); } } catch (error) { @@ -1966,7 +1966,7 @@ export class SessionService { sessionId?: string; adapter?: Adapter; }, - alwaysOnSkillInstructions?: string, + alwaysOnSkills?: AlwaysOnSkillRef[], ): Promise { const { rawEntries, sessionId, adapter } = prefetchedLogs ?? (await this.fetchSessionLogs(logUrl, taskRunId)); @@ -1977,10 +1977,8 @@ export class SessionService { const persistedConfigOptions = this.d.getPersistedConfigOptions(taskRunId); const previous = this.d.store.getSessions()[taskRunId]; - const resolvedAlwaysOnSkillInstructions = - alwaysOnSkillInstructions ?? - previous?.alwaysOnSkillInstructions ?? - this.d.getPersistedAlwaysOnSkillInstructions?.(taskRunId); + const resolvedAlwaysOnSkills = + alwaysOnSkills ?? this.d.getPersistedAlwaysOnSkills?.(taskRunId); const session = createBaseSession(taskRunId, taskId, taskTitle); // Repainting from the log must not blank a transcript we already hold: @@ -2076,12 +2074,6 @@ export class SessionService { const { customInstructions, rtkEnabledLocal, spokenNarrationEnabled } = this.d.settings; - const effectiveCustomInstructions = [ - customInstructions, - resolvedAlwaysOnSkillInstructions, - ] - .filter((value): value is string => Boolean(value)) - .join("\n\n"); const result = await this.d.trpc.agent.reconnect.mutate({ taskId, taskRunId, @@ -2098,11 +2090,11 @@ export class SessionService { effort: persistedEffort, contextWindow: persistedContextWindow, fastMode: persistedFastMode, - customInstructions: effectiveCustomInstructions || undefined, + customInstructions: customInstructions || undefined, + alwaysOnSkills: resolvedAlwaysOnSkills, }); if (result) { - session.alwaysOnSkillInstructions = resolvedAlwaysOnSkillInstructions; const liveConfigOptions = result.configOptions as | SessionConfigOption[] | undefined; @@ -2227,7 +2219,7 @@ export class SessionService { // permanent disconnect (archive, delete, fresh session) may drop them. this.d.adapterStore.removeAdapter(taskRunId); this.d.removePersistedConfigOptions(taskRunId); - this.d.removePersistedAlwaysOnSkillInstructions?.(taskRunId); + this.d.removePersistedAlwaysOnSkills?.(taskRunId); } } @@ -2418,7 +2410,7 @@ export class SessionService { importedSessionId?: string, contextWindow?: "200k" | "1m", fastMode?: boolean, - alwaysOnSkillInstructions?: string, + alwaysOnSkills?: AlwaysOnSkillRef[], ): Promise { const { client } = auth; if (!client) { @@ -2436,12 +2428,6 @@ export class SessionService { spokenNarrationEnabled, } = this.d.settings; const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL; - const effectiveCustomInstructions = [ - startCustomInstructions, - alwaysOnSkillInstructions, - ] - .filter((value): value is string => Boolean(value)) - .join("\n\n"); const result = await this.d.trpc.agent.start.mutate({ taskId, taskRunId: taskRun.id, @@ -2450,7 +2436,8 @@ export class SessionService { projectId: auth.projectId, permissionMode: executionMode, adapter, - customInstructions: effectiveCustomInstructions || undefined, + customInstructions: startCustomInstructions || undefined, + alwaysOnSkills, rtkEnabled: rtkEnabledLocal, spokenNarration: spokenNarrationEnabled === true, effort: effortLevelSchema.safeParse(reasoningLevel).success @@ -2471,12 +2458,8 @@ export class SessionService { session.reasoningLevel = reasoningLevel; session.contextWindow = contextWindow; session.fastMode = fastMode; - session.alwaysOnSkillInstructions = alwaysOnSkillInstructions; - if (alwaysOnSkillInstructions) { - this.d.setPersistedAlwaysOnSkillInstructions?.( - taskRun.id, - alwaysOnSkillInstructions, - ); + if (alwaysOnSkills?.length) { + this.d.setPersistedAlwaysOnSkills?.(taskRun.id, alwaysOnSkills); } // An imported CLI session had its history replayed during agent.start; diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 9d6247d269..df2acc8620 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -573,9 +573,8 @@ export class TaskCreationSaga extends Saga< connectParams.contextWindow = input.contextWindow; if (input.fastMode !== undefined) connectParams.fastMode = input.fastMode; - if (input.alwaysOnSkillInstructions) - connectParams.alwaysOnSkillInstructions = - input.alwaysOnSkillInstructions; + if (input.alwaysOnSkills?.length) + connectParams.alwaysOnSkills = input.alwaysOnSkills; if (importedClaude) { connectParams.importedSessionId = importedClaude.importedSessionId; connectParams.adapter = "claude"; diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 7b87cf1b8f..45d2fbf9fc 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -36,7 +36,6 @@ export interface PrepareTaskInputOptions { channelContextId?: string; customInstructions?: string; alwaysOnSkills?: TaskCreationInput["alwaysOnSkills"]; - alwaysOnSkillInstructions?: string; autoPublishCloudRuns?: boolean; rtkEnabledCloud?: boolean; allowNoRepo?: boolean; @@ -88,7 +87,6 @@ export function prepareTaskInput( channelContextId: options.channelContextId, customInstructions: isCloud ? options.customInstructions : undefined, alwaysOnSkills: options.alwaysOnSkills, - alwaysOnSkillInstructions: options.alwaysOnSkillInstructions, allowNoRepo: options.allowNoRepo, importedMcpServers: isCloud ? options.importedMcpServers : undefined, relayedMcpServers: isCloud ? options.relayedMcpServers : undefined, diff --git a/packages/host-router/src/routers/skills.router.ts b/packages/host-router/src/routers/skills.router.ts index b2de74d8df..75e9a94e8e 100644 --- a/packages/host-router/src/routers/skills.router.ts +++ b/packages/host-router/src/routers/skills.router.ts @@ -14,8 +14,6 @@ import { readSkillFileInput, readSkillFileOutput, renameSkillFileInput, - renderAlwaysOnSkillsInput, - renderAlwaysOnSkillsOutput, resolveSkillDependenciesInput, resolveSkillDependenciesOutput, saveSkillFileInput, @@ -56,14 +54,6 @@ export const skillsRouter = router({ .get(SKILLS_SERVICE) .resolveSkillBundleDependencies(input), ), - renderAlwaysOn: publicProcedure - .input(renderAlwaysOnSkillsInput) - .output(renderAlwaysOnSkillsOutput) - .query(({ ctx, input }) => - ctx.container - .get(SKILLS_SERVICE) - .renderAlwaysOnSkillInstructions(input), - ), contents: publicProcedure .input(skillContentsInput) .output(skillContentsOutput) diff --git a/packages/shared/src/sessions.ts b/packages/shared/src/sessions.ts index ccf15713ef..c682edb694 100644 --- a/packages/shared/src/sessions.ts +++ b/packages/shared/src/sessions.ts @@ -107,7 +107,6 @@ export interface AgentSession { contextUsed?: number; contextSize?: number; conversationSummary?: string; - alwaysOnSkillInstructions?: string; idleKilled?: boolean; agentVersion?: string; agentIdleForRunId?: string; diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 4632c84512..91f7f100f8 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -90,7 +90,6 @@ export interface TaskCreationInput { */ customInstructions?: string; alwaysOnSkills?: AlwaysOnSkillRef[]; - alwaysOnSkillInstructions?: string; /** * Local (~/.claude.json) MCP servers classified as importable, forwarded to * the cloud sandbox in the run-creation payload. Cloud-only; local sessions diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index 1d11900152..c1d7b5992c 100644 --- a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -25,6 +25,10 @@ import { type AgentAdapter, useSettingsStore, } from "../../settings/settingsStore"; +import { + AlwaysOnSkillChips, + useAlwaysOnSkillSelection, +} from "../../task-detail/components/AlwaysOnSkillChips"; import { type WorkspaceMode, WorkspaceModeSelect, @@ -277,6 +281,12 @@ export const ChannelHomeComposer = forwardRef< // task-ready callback matches create order and keeps adds/removes balanced — // no row is ever orphaned, even if two creates briefly overlap. const pendingIdsRef = useRef([]); + const { + includedSkills: includedAlwaysOnSkills, + excludedKeys: excludedAlwaysOnSkillKeys, + exclude: excludeAlwaysOnSkill, + reset: resetAlwaysOnSkillSelection, + } = useAlwaysOnSkillSelection(); const handleTaskCreated = useCallback( (task: Task) => { @@ -310,6 +320,7 @@ export const ChannelHomeComposer = forwardRef< channelName, channelId: backendChannelId, channelContextId: channelId, + excludedAlwaysOnSkillKeys, onTaskCreated: handleTaskCreated, }); @@ -332,6 +343,7 @@ export const ChannelHomeComposer = forwardRef< onPendingStart({ id, prompt }); const created = await handleSubmit(content); + if (created) resetAlwaysOnSkillSelection(); if (!created) { // Creation failed — onTaskCreated never fired, so this id is still // queued. Pull its row and give the full structured prompt (chips and @@ -340,7 +352,13 @@ export const ChannelHomeComposer = forwardRef< onPendingEnd(id); editor.insertEditorContent(content); } - }, [canSubmit, handleSubmit, onPendingStart, onPendingEnd]); + }, [ + canSubmit, + handleSubmit, + onPendingStart, + onPendingEnd, + resetAlwaysOnSkillSelection, + ]); const handleModeChange = useCallback( (value: string) => { @@ -455,6 +473,15 @@ export const ChannelHomeComposer = forwardRef< if (canvasArmed || canSubmit) void submitComposer(); }} /> + {!canvasArmed && includedAlwaysOnSkills.length > 0 && ( +
+ Using: + +
+ )} ); }); diff --git a/packages/ui/src/features/sessions/sessionConfigStore.test.ts b/packages/ui/src/features/sessions/sessionConfigStore.test.ts index edaa004f7d..5392548434 100644 --- a/packages/ui/src/features/sessions/sessionConfigStore.test.ts +++ b/packages/ui/src/features/sessions/sessionConfigStore.test.ts @@ -4,9 +4,9 @@ import { } from "@posthog/ui/shell/rendererStorage"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { - getPersistedAlwaysOnSkillInstructions, - removePersistedAlwaysOnSkillInstructions, - setPersistedAlwaysOnSkillInstructions, + getPersistedAlwaysOnSkills, + removePersistedAlwaysOnSkills, + setPersistedAlwaysOnSkills, useSessionConfigStore, } from "./sessionConfigStore"; @@ -16,7 +16,7 @@ const removeItem = vi.fn(); registerRendererStateStorage({ getItem, setItem, removeItem }); -describe("sessionConfigStore always-on skill instructions", () => { +describe("sessionConfigStore always-on skills", () => { beforeEach(async () => { await flushRendererStateWrites(); getItem.mockReset(); @@ -27,23 +27,22 @@ describe("sessionConfigStore always-on skill instructions", () => { removeItem.mockResolvedValue(undefined); useSessionConfigStore.setState({ configsByRunId: {}, - alwaysOnSkillInstructionsByRunId: {}, + alwaysOnSkillsByRunId: {}, }); }); - it("persists instructions by task run until they are removed", async () => { - setPersistedAlwaysOnSkillInstructions("run-1", "Follow this skill"); + it("persists skill references by task run until they are removed", async () => { + const skills = [{ name: "test", source: "user" as const, path: "/test" }]; + setPersistedAlwaysOnSkills("run-1", skills); - expect(getPersistedAlwaysOnSkillInstructions("run-1")).toBe( - "Follow this skill", - ); + expect(getPersistedAlwaysOnSkills("run-1")).toEqual(skills); await flushRendererStateWrites(); expect(setItem).toHaveBeenCalledWith( "session-config-storage", - expect.stringContaining("Follow this skill"), + expect.stringContaining('"name":"test"'), ); - removePersistedAlwaysOnSkillInstructions("run-1"); - expect(getPersistedAlwaysOnSkillInstructions("run-1")).toBeUndefined(); + removePersistedAlwaysOnSkills("run-1"); + expect(getPersistedAlwaysOnSkills("run-1")).toBeUndefined(); }); }); diff --git a/packages/ui/src/features/sessions/sessionConfigStore.ts b/packages/ui/src/features/sessions/sessionConfigStore.ts index 7df3aa1c8f..9a01eee124 100644 --- a/packages/ui/src/features/sessions/sessionConfigStore.ts +++ b/packages/ui/src/features/sessions/sessionConfigStore.ts @@ -1,11 +1,12 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { AlwaysOnSkillRef } from "@posthog/shared"; import { electronStorage } from "@posthog/ui/shell/rendererStorage"; import { create } from "zustand"; import { persist } from "zustand/middleware"; interface SessionConfigState { configsByRunId: Record; - alwaysOnSkillInstructionsByRunId: Record; + alwaysOnSkillsByRunId: Record; } interface SessionConfigActions { @@ -15,12 +16,9 @@ interface SessionConfigActions { getConfigOptions: (taskRunId: string) => SessionConfigOption[] | undefined; /** Remove config options for a task run */ removeConfigOptions: (taskRunId: string) => void; - setAlwaysOnSkillInstructions: ( - taskRunId: string, - instructions: string, - ) => void; - getAlwaysOnSkillInstructions: (taskRunId: string) => string | undefined; - removeAlwaysOnSkillInstructions: (taskRunId: string) => void; + setAlwaysOnSkills: (taskRunId: string, skills: AlwaysOnSkillRef[]) => void; + getAlwaysOnSkills: (taskRunId: string) => AlwaysOnSkillRef[] | undefined; + removeAlwaysOnSkills: (taskRunId: string) => void; } type SessionConfigStore = SessionConfigState & SessionConfigActions; @@ -29,7 +27,7 @@ export const useSessionConfigStore = create()( persist( (set, get) => ({ configsByRunId: {}, - alwaysOnSkillInstructionsByRunId: {}, + alwaysOnSkillsByRunId: {}, setConfigOptions: (taskRunId, options) => set((state) => ({ @@ -43,20 +41,19 @@ export const useSessionConfigStore = create()( const { [taskRunId]: _removed, ...rest } = state.configsByRunId; return { configsByRunId: rest }; }), - setAlwaysOnSkillInstructions: (taskRunId, instructions) => + setAlwaysOnSkills: (taskRunId, skills) => set((state) => ({ - alwaysOnSkillInstructionsByRunId: { - ...state.alwaysOnSkillInstructionsByRunId, - [taskRunId]: instructions, + alwaysOnSkillsByRunId: { + ...state.alwaysOnSkillsByRunId, + [taskRunId]: skills, }, })), - getAlwaysOnSkillInstructions: (taskRunId) => - get().alwaysOnSkillInstructionsByRunId[taskRunId], - removeAlwaysOnSkillInstructions: (taskRunId) => + getAlwaysOnSkills: (taskRunId) => get().alwaysOnSkillsByRunId[taskRunId], + removeAlwaysOnSkills: (taskRunId) => set((state) => { const { [taskRunId]: _removed, ...rest } = - state.alwaysOnSkillInstructionsByRunId; - return { alwaysOnSkillInstructionsByRunId: rest }; + state.alwaysOnSkillsByRunId; + return { alwaysOnSkillsByRunId: rest }; }), }), { @@ -64,8 +61,7 @@ export const useSessionConfigStore = create()( storage: electronStorage, partialize: (state) => ({ configsByRunId: state.configsByRunId, - alwaysOnSkillInstructionsByRunId: - state.alwaysOnSkillInstructionsByRunId, + alwaysOnSkillsByRunId: state.alwaysOnSkillsByRunId, }), }, ), @@ -91,25 +87,19 @@ export function removePersistedConfigOptions(taskRunId: string): void { useSessionConfigStore.getState().removeConfigOptions(taskRunId); } -export function getPersistedAlwaysOnSkillInstructions( +export function getPersistedAlwaysOnSkills( taskRunId: string, -): string | undefined { - return useSessionConfigStore - .getState() - .getAlwaysOnSkillInstructions(taskRunId); +): AlwaysOnSkillRef[] | undefined { + return useSessionConfigStore.getState().getAlwaysOnSkills(taskRunId); } -export function setPersistedAlwaysOnSkillInstructions( +export function setPersistedAlwaysOnSkills( taskRunId: string, - instructions: string, + skills: AlwaysOnSkillRef[], ): void { - useSessionConfigStore - .getState() - .setAlwaysOnSkillInstructions(taskRunId, instructions); + useSessionConfigStore.getState().setAlwaysOnSkills(taskRunId, skills); } -export function removePersistedAlwaysOnSkillInstructions( - taskRunId: string, -): void { - useSessionConfigStore.getState().removeAlwaysOnSkillInstructions(taskRunId); +export function removePersistedAlwaysOnSkills(taskRunId: string): void { + useSessionConfigStore.getState().removeAlwaysOnSkills(taskRunId); } diff --git a/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts index 737564bfc4..98e74c96d7 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.recovery.integration.test.ts @@ -127,9 +127,9 @@ const mockSessionConfigStore = vi.hoisted(() => ({ getPersistedConfigOptions: vi.fn(() => undefined), setPersistedConfigOptions: vi.fn(), removePersistedConfigOptions: vi.fn(), - getPersistedAlwaysOnSkillInstructions: vi.fn(() => undefined), - setPersistedAlwaysOnSkillInstructions: vi.fn(), - removePersistedAlwaysOnSkillInstructions: vi.fn(), + getPersistedAlwaysOnSkills: vi.fn(() => undefined), + setPersistedAlwaysOnSkills: vi.fn(), + removePersistedAlwaysOnSkills: vi.fn(), })); vi.mock( diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index cafbceaa05..8afebb5970 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -196,9 +196,9 @@ const mockSessionConfigStore = vi.hoisted(() => ({ >(() => undefined), setPersistedConfigOptions: vi.fn(), removePersistedConfigOptions: vi.fn(), - getPersistedAlwaysOnSkillInstructions: vi.fn(() => undefined), - setPersistedAlwaysOnSkillInstructions: vi.fn(), - removePersistedAlwaysOnSkillInstructions: vi.fn(), + getPersistedAlwaysOnSkills: vi.fn(() => undefined), + setPersistedAlwaysOnSkills: vi.fn(), + removePersistedAlwaysOnSkills: vi.fn(), })); vi.mock( diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts index 0d8e4e08a5..612c923be0 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.ts @@ -31,11 +31,11 @@ import { NotificationBus } from "@posthog/ui/features/notifications/notification import { SpeechNotifier } from "@posthog/ui/features/notifications/speechNotifier"; import { useSessionAdapterStore } from "@posthog/ui/features/sessions/sessionAdapterStore"; import { - getPersistedAlwaysOnSkillInstructions, + getPersistedAlwaysOnSkills, getPersistedConfigOptions, - removePersistedAlwaysOnSkillInstructions, + removePersistedAlwaysOnSkills, removePersistedConfigOptions, - setPersistedAlwaysOnSkillInstructions, + setPersistedAlwaysOnSkills, setPersistedConfigOptions, } from "@posthog/ui/features/sessions/sessionConfigStore"; import { sessionStoreSetters } from "@posthog/ui/features/sessions/sessionStore"; @@ -120,9 +120,9 @@ function buildSessionServiceDeps(): SessionServiceDeps { getPersistedConfigOptions(taskRunId) ?? undefined, setPersistedConfigOptions, removePersistedConfigOptions, - getPersistedAlwaysOnSkillInstructions, - setPersistedAlwaysOnSkillInstructions, - removePersistedAlwaysOnSkillInstructions, + getPersistedAlwaysOnSkills, + setPersistedAlwaysOnSkills, + removePersistedAlwaysOnSkills, adapterStore: { getAdapter: (taskRunId) => useSessionAdapterStore.getState().getAdapter(taskRunId), diff --git a/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx b/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx new file mode 100644 index 0000000000..61ccd68466 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx @@ -0,0 +1,70 @@ +import { Lightbulb, X } from "@phosphor-icons/react"; +import type { AlwaysOnSkillRef } from "@posthog/shared"; +import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useSkillsSelectionActions } from "@posthog/ui/features/skills/skillsSelectionStore"; +import { Tooltip } from "@radix-ui/themes"; +import { useCallback, useState } from "react"; + +export function useAlwaysOnSkillSelection() { + const alwaysOnSkills = useSettingsStore((state) => state.alwaysOnSkills); + const [excludedKeys, setExcludedKeys] = useState(() => new Set()); + const includedSkills = alwaysOnSkills.filter( + (skill) => !excludedKeys.has(`${skill.source}:${skill.path}`), + ); + const exclude = useCallback((skill: AlwaysOnSkillRef) => { + setExcludedKeys((current) => { + const next = new Set(current); + next.add(`${skill.source}:${skill.path}`); + return next; + }); + }, []); + const reset = useCallback(() => setExcludedKeys(new Set()), []); + + return { includedSkills, excludedKeys, exclude, reset }; +} + +export function AlwaysOnSkillChips({ + skills, + onExclude, +}: { + skills: AlwaysOnSkillRef[]; + onExclude: (skill: AlwaysOnSkillRef) => void; +}) { + const { requestSkill } = useSkillsSelectionActions(); + const openSkill = useCallback( + (name: string) => { + requestSkill(name); + openSettings("skills"); + }, + [requestSkill], + ); + + return skills.map((skill) => ( + + + + + + + + + )); +} diff --git a/packages/ui/src/features/task-detail/components/AlwaysOnSkillsFailureDialog.tsx b/packages/ui/src/features/task-detail/components/AlwaysOnSkillsFailureDialog.tsx deleted file mode 100644 index c0d150ce98..0000000000 --- a/packages/ui/src/features/task-detail/components/AlwaysOnSkillsFailureDialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Warning } from "@phosphor-icons/react"; -import { AlertDialog, Button, Flex, Text } from "@radix-ui/themes"; -import { useAlwaysOnSkillsFailureStore } from "../stores/alwaysOnSkillsFailureStore"; - -export function AlwaysOnSkillsFailureDialog() { - const isOpen = useAlwaysOnSkillsFailureStore((state) => state.isOpen); - const error = useAlwaysOnSkillsFailureStore((state) => state.error); - const skills = useAlwaysOnSkillsFailureStore((state) => state.skills); - const choose = useAlwaysOnSkillsFailureStore((state) => state.choose); - - return ( - { - if (!open) choose("cancel"); - }} - > - - - - - Always-on skills could not be loaded - - - - {skills.map((skill) => skill.name).join(", ")} - - - {error} - - - - - - - - - - ); -} diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 4ccb30a8e7..9e63fe07c6 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -1,4 +1,4 @@ -import { FileText, Lightbulb, X } from "@phosphor-icons/react"; +import { FileText, X } from "@phosphor-icons/react"; import type { AutoresearchService } from "@posthog/core/autoresearch/autoresearch"; import { AUTORESEARCH_SERVICE } from "@posthog/core/autoresearch/identifiers"; import { buildKickoffPreamble } from "@posthog/core/autoresearch/prompts"; @@ -16,7 +16,6 @@ import { ButtonGroup } from "@posthog/quill"; import { type AgentRuntime, ANALYTICS_EVENTS } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; -import { useSkillsSelectionActions } from "@posthog/ui/features/skills/skillsSelectionStore"; import type { TaskInputReportAssociation } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; import { useTaskInputPrefillStore } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; import { navigateToInbox } from "@posthog/ui/router/navigationBridge"; @@ -98,6 +97,10 @@ import { useTaskCreation } from "../hooks/useTaskCreation"; import { useWarmTask } from "../hooks/useWarmTask"; import { resolveWorkspaceModePreference } from "../hooks/workspaceModePreference"; import { AgentRuntimeSelect } from "./AgentRuntimeSelect"; +import { + AlwaysOnSkillChips, + useAlwaysOnSkillSelection, +} from "./AlwaysOnSkillChips"; import { CloudGithubMissingNotice } from "./CloudGithubMissingNotice"; import { NewTaskSuggestions } from "./ContinueCliSessions"; import { @@ -224,10 +227,8 @@ export function TaskInput({ lastUsedPiModel, setLastUsedPiModel, _hasHydrated: settingsHydrated, - alwaysOnSkills, } = useSettingsStore(); const { data: skills } = useSkills(); - const { requestSkill } = useSkillsSelectionActions(); const editorRef = useRef(null); const handleAddSelectionToPrompt = useCallback( @@ -283,9 +284,12 @@ export function TaskInput({ // from this task's prompt. Re-include whenever the source context changes // (e.g. switching channels) so a dismissal doesn't stick across channels. const [channelContextDismissed, setChannelContextDismissed] = useState(false); - const [excludedAlwaysOnSkillKeys, setExcludedAlwaysOnSkillKeys] = useState( - () => new Set(), - ); + const { + includedSkills: includedAlwaysOnSkills, + excludedKeys: excludedAlwaysOnSkillKeys, + exclude: excludeAlwaysOnSkill, + reset: resetAlwaysOnSkillSelection, + } = useAlwaysOnSkillSelection(); const lastChannelContextRef = useRef(channelContext); useEffect(() => { if (lastChannelContextRef.current !== channelContext) { @@ -294,28 +298,6 @@ export function TaskInput({ } }, [channelContext]); const includeChannelContext = !!channelContext && !channelContextDismissed; - const includedAlwaysOnSkills = alwaysOnSkills.filter( - (skill) => !excludedAlwaysOnSkillKeys.has(`${skill.source}:${skill.path}`), - ); - - const handleOpenAlwaysOnSkill = useCallback( - (name: string) => { - requestSkill(name); - openSettings("skills"); - }, - [requestSkill], - ); - - const handleExcludeAlwaysOnSkill = useCallback( - (source: string, path: string) => { - setExcludedAlwaysOnSkillKeys((current) => { - const next = new Set(current); - next.add(`${source}:${path}`); - return next; - }); - }, - [], - ); const adapter = lastUsedAdapter; const prefillRequestKey = initialPromptKey ?? initialPrompt; @@ -988,10 +970,10 @@ export function TaskInput({ const handleSubmit = useCallback( async (contentOverride?: EditorContent) => { const submitted = await createTask(contentOverride); - if (submitted) setExcludedAlwaysOnSkillKeys(new Set()); + if (submitted) resetAlwaysOnSkillSelection(); return submitted; }, - [createTask], + [createTask, resetAlwaysOnSkillSelection], ); // Wraps the prompt in the autoresearch kickoff: protocol preamble first, @@ -1555,40 +1537,10 @@ export function TaskInput({ )} - {includedAlwaysOnSkills.map((skill) => ( - - - - - - - - - ))} + )} {effectiveWorkspaceMode === "cloud" && diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index a14e8c78b2..874d7b3feb 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -60,7 +60,6 @@ import { useCreateTask } from "../../tasks/useTaskCrudMutations"; import { useTasks } from "../../tasks/useTasks"; import { useTourStore } from "../../tour/tourStore"; import { createFirstTaskTour } from "../../tour/tours/createFirstTaskTour"; -import { useAlwaysOnSkillsFailureStore } from "../stores/alwaysOnSkillsFailureStore"; import { useExistingWorktreeConfirmStore } from "../stores/existingWorktreeConfirmStore"; import { useRemoteBranchConfirmStore } from "../stores/remoteBranchConfirmStore"; @@ -328,43 +327,10 @@ export function useTaskCreation({ const serializedContent = contentToXml(content).trim(); const filePaths = extractFilePaths(content); const settings = useSettingsStore.getState(); - let alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills.filter( + const alwaysOnSkills: AlwaysOnSkillRef[] = settings.alwaysOnSkills.filter( (skill) => !excludedAlwaysOnSkillKeys?.has(`${skill.source}:${skill.path}`), ); - let alwaysOnSkillInstructions: string | undefined; - while (alwaysOnSkills.length > 0) { - const rendered = - await hostClient.skills.renderAlwaysOn.query(alwaysOnSkills); - alwaysOnSkillInstructions = rendered.instructions; - if (rendered.failures.length === 0) { - break; - } - const failedSkills = rendered.failures.map(({ skill }) => skill); - const action = await useAlwaysOnSkillsFailureStore - .getState() - .confirm( - rendered.failures.map(({ error }) => error).join("\n"), - failedSkills, - ); - if (action === "retry") continue; - if (action === "cancel") { - setIsCreatingTask(false); - return false; - } - if (action === "disable") { - for (const skill of failedSkills) { - useSettingsStore.getState().setSkillAlwaysOn(skill, false); - } - } - const failedKeys = new Set( - failedSkills.map((skill) => `${skill.source}:${skill.path}`), - ); - alwaysOnSkills = alwaysOnSkills.filter( - (skill) => !failedKeys.has(`${skill.source}:${skill.path}`), - ); - break; - } const shouldShowPendingView = !onTaskCreated && !!plainPromptText; const pendingTaskKey = shouldShowPendingView @@ -433,7 +399,6 @@ export function useTaskCreation({ channelContextId, customInstructions: getEffectiveCustomInstructions(settings), alwaysOnSkills, - alwaysOnSkillInstructions, autoPublishCloudRuns: settings.autoPublishCloudRuns, rtkEnabledCloud: settings.rtkEnabledCloud, allowNoRepo, diff --git a/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts b/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts deleted file mode 100644 index f3fcca2c09..0000000000 --- a/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { useAlwaysOnSkillsFailureStore } from "./alwaysOnSkillsFailureStore"; - -describe("alwaysOnSkillsFailureStore", () => { - beforeEach(() => { - useAlwaysOnSkillsFailureStore.setState({ - isOpen: false, - error: null, - skills: [], - resolve: null, - }); - }); - - it.each(["retry", "continue", "disable", "cancel"] as const)( - "resolves the %s recovery action", - async (action) => { - const skill = { - name: "example", - source: "user" as const, - path: "/skills/example", - }; - const result = useAlwaysOnSkillsFailureStore - .getState() - .confirm("missing", [skill]); - - useAlwaysOnSkillsFailureStore.getState().choose(action); - - await expect(result).resolves.toBe(action); - expect(useAlwaysOnSkillsFailureStore.getState().isOpen).toBe(false); - }, - ); -}); diff --git a/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.ts b/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.ts deleted file mode 100644 index aba4821e79..0000000000 --- a/packages/ui/src/features/task-detail/stores/alwaysOnSkillsFailureStore.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { AlwaysOnSkillRef } from "@posthog/shared"; -import { create } from "zustand"; - -export type AlwaysOnSkillsFailureAction = - | "retry" - | "continue" - | "disable" - | "cancel"; - -interface AlwaysOnSkillsFailureState { - isOpen: boolean; - error: string | null; - skills: AlwaysOnSkillRef[]; - resolve: ((action: AlwaysOnSkillsFailureAction) => void) | null; - confirm: ( - error: string, - skills: AlwaysOnSkillRef[], - ) => Promise; - choose: (action: AlwaysOnSkillsFailureAction) => void; -} - -export const useAlwaysOnSkillsFailureStore = - create()((set, get) => ({ - isOpen: false, - error: null, - skills: [], - resolve: null, - confirm: (error, skills) => - new Promise((resolve) => { - get().resolve?.("cancel"); - set({ isOpen: true, error, skills, resolve }); - }), - choose: (action) => { - get().resolve?.(action); - set({ isOpen: false, error: null, skills: [], resolve: null }); - }, - })); diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 97e63dacb3..fe2122fdad 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -54,7 +54,6 @@ import { import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useSidebarData } from "@posthog/ui/features/sidebar/useSidebarData"; import { useVisualTaskOrder } from "@posthog/ui/features/sidebar/useVisualTaskOrder"; -import { AlwaysOnSkillsFailureDialog } from "@posthog/ui/features/task-detail/components/AlwaysOnSkillsFailureDialog"; import { ExistingWorktreeDialog } from "@posthog/ui/features/task-detail/components/ExistingWorktreeDialog"; import { RemoteBranchCheckoutDialog } from "@posthog/ui/features/task-detail/components/RemoteBranchCheckoutDialog"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; @@ -333,7 +332,6 @@ function RootLayout() { - ); @@ -518,7 +516,6 @@ function RootLayout() { - ({ info: vi.fn(), @@ -294,6 +300,7 @@ describe("AgentService", () => { deps.workspaceRepository as never, deps.workspaceSettings as never, deps.foldersService as never, + deps.skillsService as never, deps.loggerFactory as never, ); vi.spyOn(service, "emit"); @@ -645,6 +652,36 @@ describe("AgentService", () => { }), ); }); + + it("loads always-on skills into the local agent system prompt", async () => { + deps.skillsService.renderAlwaysOnSkillInstructions.mockResolvedValue({ + instructions: "ALWAYS_ON_SKILL_BODY", + failures: [], + }); + + await service.startSession({ + ...baseSessionParams, + adapter: "codex", + alwaysOnSkills: [ + { name: "concise", source: "user", path: "/skills/concise" }, + ], + }); + + expect( + deps.skillsService.renderAlwaysOnSkillInstructions, + ).toHaveBeenCalledWith([ + { name: "concise", source: "user", path: "/skills/concise" }, + ]); + expect(mockAgentRun).toHaveBeenCalledWith( + "task-1", + "run-1", + expect.objectContaining({ + developerInstructions: expect.stringContaining( + "ALWAYS_ON_SKILL_BODY", + ), + }), + ); + }); }); describe("session meta", () => { @@ -945,6 +982,7 @@ describe("AgentService", () => { credentials: { apiHost: string; projectId: number }, taskId: string, customInstructions?: string, + alwaysOnSkillInstructions?: string, additionalDirectories?: string[], systemPromptOverride?: string, channelMode?: boolean, @@ -957,6 +995,7 @@ describe("AgentService", () => { undefined, undefined, undefined, + undefined, true, folders, ).append; diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index d7ae5cfa94..bc3efcce86 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -84,6 +84,9 @@ import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; import type { ProcessTrackingService } from "../process-tracking/process-tracking"; import { loadSessionEnvOverrides } from "../session-env/loader"; +import { SKILLS_SERVICE } from "../skills/identifiers"; +import type { SkillBundleRef } from "../skills/schemas"; +import type { SkillsService } from "../skills/skills"; import { isScratchPath } from "../workspace/scratch"; import type { AgentAuthAdapter, McpToolInstallations } from "./auth-adapter"; import { cleanupCodexHome, prepareCodexHome } from "./codex-home"; @@ -272,6 +275,7 @@ interface SessionConfig { permissionMode?: string; /** Custom instructions injected into the system prompt */ customInstructions?: string; + alwaysOnSkills?: SkillBundleRef[]; /** Replaces the PostHog system prompt entirely (constrained surfaces). */ systemPromptOverride?: string; /** Tool names denied for this session (passed to the Claude SDK). */ @@ -434,6 +438,8 @@ export class AgentService extends TypedEventEmitter { private readonly workspaceSettings: IWorkspaceSettings, @inject(FOLDERS_SERVICE) private readonly foldersService: FoldersService, + @inject(SKILLS_SERVICE) + private readonly skillsService: SkillsService, @inject(AGENT_LOGGER) loggerFactory: AgentLogger, ) { @@ -614,6 +620,7 @@ export class AgentService extends TypedEventEmitter { credentials: Credentials, taskId: string, customInstructions?: string, + alwaysOnSkillInstructions?: string, additionalDirectories?: string[], systemPromptOverride?: string, channelMode?: boolean, @@ -700,6 +707,10 @@ If a repository IS genuinely required, attach one in this priority order: prompt += `\n\nUser custom instructions:\n${customInstructions}`; } + if (alwaysOnSkillInstructions) { + prompt += `\n\n${alwaysOnSkillInstructions}`; + } + if (additionalDirectories?.length) { const escapeXml = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); @@ -780,6 +791,7 @@ If a repository IS genuinely required, attach one in this priority order: adapter, permissionMode, customInstructions, + alwaysOnSkills, systemPromptOverride, disallowedTools, settingSources, @@ -859,10 +871,22 @@ If a repository IS genuinely required, attach one in this priority order: let hydratedResumeContext: string | undefined; try { + const renderedAlwaysOnSkills = alwaysOnSkills?.length + ? await this.skillsService.renderAlwaysOnSkillInstructions( + alwaysOnSkills, + ) + : undefined; + for (const failure of renderedAlwaysOnSkills?.failures ?? []) { + this.log.warn("Failed to load always-on skill", { + skill: failure.skill.name, + error: failure.error, + }); + } const systemPrompt = this.buildSystemPrompt( credentials, taskId, customInstructions, + renderedAlwaysOnSkills?.instructions, additionalDirectories, systemPromptOverride, channelMode, @@ -2138,6 +2162,8 @@ For git operations while detached: "permissionMode" in params ? params.permissionMode : undefined, customInstructions: "customInstructions" in params ? params.customInstructions : undefined, + alwaysOnSkills: + "alwaysOnSkills" in params ? params.alwaysOnSkills : undefined, systemPromptOverride: "systemPromptOverride" in params ? params.systemPromptOverride diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 86f8bfc84f..41f4c62b34 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -5,6 +5,7 @@ import type { import { effortLevelSchema } from "@posthog/shared/domain-types"; import { z } from "zod"; import { USER_AGENT_INSTRUCTIONS_MAX_LENGTH } from "../os/schemas"; +import { bundleLocalSkillInput } from "../skills/schemas"; export { effortLevelSchema }; export type { EffortLevel } from "@posthog/shared/domain-types"; @@ -65,6 +66,7 @@ export const startSessionInput = z.object({ adapter: z.enum(["claude", "codex"]).optional(), additionalDirectories: z.array(z.string()).optional(), customInstructions: customInstructionsField, + alwaysOnSkills: z.array(bundleLocalSkillInput).optional(), /** * Replaces the PostHog system prompt entirely for this session. Used by * constrained, single-purpose surfaces (e.g. the canvas generator) that drive @@ -233,6 +235,7 @@ export const reconnectSessionInput = z.object({ permissionMode: z.string().optional(), model: z.string().optional(), customInstructions: customInstructionsField, + alwaysOnSkills: z.array(bundleLocalSkillInput).optional(), effort: effortLevelSchema.optional(), contextWindow: z.enum(["200k", "1m"]).optional(), fastMode: z.boolean().optional(), diff --git a/packages/workspace-server/src/services/skills/schemas.ts b/packages/workspace-server/src/services/skills/schemas.ts index 363ae24d1a..671f2eb445 100644 --- a/packages/workspace-server/src/services/skills/schemas.ts +++ b/packages/workspace-server/src/services/skills/schemas.ts @@ -138,17 +138,6 @@ export const bundleLocalSkillOutput = z.object({ export const resolveSkillDependenciesInput = z.array(bundleLocalSkillInput); export const resolveSkillDependenciesOutput = z.array(bundleLocalSkillInput); -export const renderAlwaysOnSkillsInput = z.array(bundleLocalSkillInput); -export const renderAlwaysOnSkillsOutput = z.object({ - instructions: z.string().optional(), - failures: z.array( - z.object({ - skill: bundleLocalSkillInput, - error: z.string(), - }), - ), -}); - export type BundleLocalSkillInput = z.infer; export type BundleLocalSkillOutput = z.infer; export type SkillBundleRef = z.infer; From 066a0281de3ab82b0a00b5ce601d2b52ac612162 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:54 -0400 Subject: [PATCH 09/12] Move feed skill chips above composer Generated-By: PostHog Code Task-Id: b0e948e2-b733-4d51-be6a-efd68d54216e --- .../canvas/components/ChannelHomeComposer.tsx | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index c1d7b5992c..21c3cc1a72 100644 --- a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -413,16 +413,27 @@ export const ChannelHomeComposer = forwardRef< and the trigger's own fill is translucent, so it carries an opaque backdrop at the button's radius to stop messages showing through. */} {!canvasArmed && ( -
- +
+
+ +
+ {includedAlwaysOnSkills.length > 0 && ( +
+ Using: + +
+ )}
)} @@ -473,15 +484,6 @@ export const ChannelHomeComposer = forwardRef< if (canvasArmed || canSubmit) void submitComposer(); }} /> - {!canvasArmed && includedAlwaysOnSkills.length > 0 && ( -
- Using: - -
- )}
); }); From c3add49f2e8f17c9f294354f05947af9833a95b9 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 31 Jul 2026 10:28:56 -0400 Subject: [PATCH 10/12] Scope and harden always-on skills Generated-By: PostHog Code Task-Id: b0e948e2-b733-4d51-be6a-efd68d54216e --- .../agent/src/server/agent-server.test.ts | 3 + packages/agent/src/server/agent-server.ts | 30 +++++-- .../src/sessions/cloudArtifactService.test.ts | 43 ++++++++++ .../core/src/sessions/cloudArtifactService.ts | 29 ++++++- packages/shared/src/index.ts | 2 + packages/shared/src/skills.test.ts | 79 +++++++++++++++++++ packages/shared/src/skills.ts | 59 ++++++++++++++ packages/shared/src/task-creation-domain.ts | 2 + .../canvas/components/ChannelHomeComposer.tsx | 18 ++++- .../components/ScoutHelperSkillLinks.tsx | 4 +- .../src/features/skills/SkillDetailPanel.tsx | 4 + .../ui/src/features/skills/SkillsView.tsx | 14 +++- .../features/skills/skillsSelectionStore.ts | 12 ++- .../components/AlwaysOnSkillChips.tsx | 69 +++++++++++++--- .../task-detail/components/TaskInput.tsx | 29 +++++-- .../task-detail/hooks/useTaskCreation.ts | 11 +-- .../src/services/skills/schemas.ts | 2 + .../src/services/skills/skill-discovery.ts | 12 ++- .../src/services/skills/skills.ts | 16 +++- 19 files changed, 382 insertions(+), 56 deletions(-) create mode 100644 packages/shared/src/skills.test.ts diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 28f1d54ed5..9e404f7aa8 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -2378,6 +2378,9 @@ describe("AgentServer HTTP Mode", () => { expect(sentMeta?.localSkillContext).toContain( "always-on skills apply for the entire session", ); + expect(sentMeta?.localSkillContext).toContain( + "User request:\nwith context", + ); expect( String(sentMeta?.localSkillContext).match(/LOCAL_SKILL_MARKER/g), ).toHaveLength(1); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index a5dc420882..fe74fedcb2 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -2887,12 +2887,21 @@ export class AgentServer { : null; if (invocation) { - if ( - alwaysOnSkills.some((skill) => skill.skillName === invocation.skillName) - ) { + const invokedAlwaysOnSkill = alwaysOnSkills.find( + (skill) => skill.skillName === invocation.skillName, + ); + if (invokedAlwaysOnSkill) { + const invokedContext = this.buildInstalledSkillPrompt( + invokedAlwaysOnSkill, + invocation.args, + this.getCoInstalledSkillBundles(runId, invocation.skillName), + false, + ); return { skillName: invocation.skillName, - context: alwaysOnContext ?? "", + context: alwaysOnContext + ? `${alwaysOnContext}\n\n${invokedContext}` + : invokedContext, }; } const hasMatchingArtifact = artifacts.some( @@ -3036,14 +3045,19 @@ export class AgentServer { skill: InstalledSkillBundle, args: string | undefined, coInstalledSkills: InstalledSkillBundle[] = [], + includeDefinition = true, ): string { return [ `The user invoked the local skill "/${skill.skillName}". Apply these skill instructions for this turn.`, "", - `--- BEGIN LOCAL SKILL ${skill.skillName} ---`, - skill.skillDefinition.trim(), - `--- END LOCAL SKILL ${skill.skillName} ---`, - "", + ...(includeDefinition + ? [ + `--- BEGIN LOCAL SKILL ${skill.skillName} ---`, + skill.skillDefinition.trim(), + `--- END LOCAL SKILL ${skill.skillName} ---`, + "", + ] + : []), `Installed skill path: ${skill.skillRoot}`, ...(coInstalledSkills.length > 0 ? [ diff --git a/packages/core/src/sessions/cloudArtifactService.test.ts b/packages/core/src/sessions/cloudArtifactService.test.ts index 0d10a09533..1bf8d1ec0a 100644 --- a/packages/core/src/sessions/cloudArtifactService.test.ts +++ b/packages/core/src/sessions/cloudArtifactService.test.ts @@ -197,6 +197,49 @@ describe("CloudArtifactService", () => { fetchMock.mockRestore(); }); + it("skips an unavailable always-on skill without failing the upload", async () => { + const service = new CloudArtifactService( + vi.fn(), + vi.fn().mockRejectedValue(new Error("missing skill")), + passthroughDeps, + ); + + await expect( + service.uploadRunAttachments( + makeClient(), + "task-1", + "run-1", + [], + [ + { + name: "missing", + source: "user", + path: "/tmp/missing", + alwaysOn: true, + }, + ], + ), + ).resolves.toEqual([]); + }); + + it("fails when an explicitly requested skill is unavailable", async () => { + const service = new CloudArtifactService( + vi.fn(), + vi.fn().mockRejectedValue(new Error("missing skill")), + passthroughDeps, + ); + + await expect( + service.uploadRunAttachments( + makeClient(), + "task-1", + "run-1", + [], + [{ name: "missing", source: "user", path: "/tmp/missing" }], + ), + ).rejects.toThrow("missing skill"); + }); + it("uploads dependency skills the resolver adds to a tagged skill", async () => { const fetchMock = vi .spyOn(globalThis, "fetch") diff --git a/packages/core/src/sessions/cloudArtifactService.ts b/packages/core/src/sessions/cloudArtifactService.ts index a2ef045ed3..5c388da44a 100644 --- a/packages/core/src/sessions/cloudArtifactService.ts +++ b/packages/core/src/sessions/cloudArtifactService.ts @@ -173,6 +173,7 @@ export class CloudArtifactService { ...(await this.loadCloudAttachments(filePaths)), ...(await this.loadCloudSkillBundles(skillBundles)), ]; + if (attachments.length === 0) return []; const preparedArtifacts = await client.prepareTaskRunArtifactUploads( taskId, runId, @@ -232,10 +233,30 @@ export class CloudArtifactService { if (skillBundleRefs.length === 0) { return []; } - // Pull in dependency skills the tagged ones declare, so a skill that needs - // another arrives in the sandbox together with it. - const expandedRefs = - await this.resolveSkillBundleDependencies(skillBundleRefs); + const explicitRefs = skillBundleRefs.filter((ref) => !ref.alwaysOn); + const alwaysOnRefs = skillBundleRefs.filter((ref) => ref.alwaysOn); + const explicit = await this.loadCloudSkillBundleRefs(explicitRefs); + const alwaysOn = ( + await Promise.all( + alwaysOnRefs.map((ref) => + this.loadCloudSkillBundleRefs([ref]).catch(() => []), + ), + ) + ).flat(); + const deduplicated = new Map( + [...explicit, ...alwaysOn].map((attachment) => [ + attachment.filePath, + attachment, + ]), + ); + return [...deduplicated.values()]; + } + + private async loadCloudSkillBundleRefs( + refs: CloudSkillBundleRef[], + ): Promise { + if (refs.length === 0) return []; + const expandedRefs = await this.resolveSkillBundleDependencies(refs); return Promise.all( expandedRefs.map(async (skillBundleRef) => { const bundle = await this.bundleLocalSkill(skillBundleRef); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 45e7544815..4e11bb6767 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -328,6 +328,7 @@ export type { SignalReportStatus, } from "./signal-types"; export type { + AlwaysOnSkillTarget, ExportedSkill, ExportedSkillFile, SkillFileEntry, @@ -336,6 +337,7 @@ export type { UploadableSkillSource, } from "./skills"; export { + getApplicableAlwaysOnSkills, SKILL_EXISTS_MARKER, serializeSkillMarkdown, stripFrontmatter, diff --git a/packages/shared/src/skills.test.ts b/packages/shared/src/skills.test.ts new file mode 100644 index 0000000000..5578a2a9e0 --- /dev/null +++ b/packages/shared/src/skills.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import type { SkillInfo } from "./skills"; +import { getApplicableAlwaysOnSkills } from "./skills"; + +const globalSkill: SkillInfo = { + name: "global", + description: "", + source: "user", + path: "/home/user/.claude/skills/global", + editable: true, + skillMdBytes: 10, +}; + +const repoSkill: SkillInfo = { + name: "repo", + description: "", + source: "repo", + path: "/repos/code/.claude/skills/repo", + repoName: "code", + repoPath: "/repos/code", + repository: "PostHog/code", + editable: true, + skillMdBytes: 10, +}; + +const preferences = [globalSkill, repoSkill].map( + ({ name, source, path, repoPath, repository }) => ({ + name, + source, + path, + repoPath, + repository, + }), +); + +describe("getApplicableAlwaysOnSkills", () => { + it("applies repo skills only to their local or cloud repository", () => { + expect( + getApplicableAlwaysOnSkills(preferences, [globalSkill, repoSkill], { + repoPath: "/repos/code", + }).applicable, + ).toEqual(preferences); + expect( + getApplicableAlwaysOnSkills(preferences, [globalSkill, repoSkill], { + repository: "posthog/code", + }).applicable, + ).toEqual(preferences); + expect( + getApplicableAlwaysOnSkills(preferences, [globalSkill, repoSkill], {}) + .applicable, + ).toEqual([preferences[0]]); + }); + + it("reports missing applicable skills without treating other repo skills as unavailable", () => { + const result = getApplicableAlwaysOnSkills(preferences, [], { + repoPath: "/repos/other", + }); + expect(result.applicable).toEqual([]); + expect(result.unavailable).toEqual([preferences[0]]); + }); + + it("honors per-task exclusions", () => { + const result = getApplicableAlwaysOnSkills( + preferences, + [globalSkill, repoSkill], + { repoPath: "/repos/code" }, + new Set([`user:${globalSkill.path}`]), + ); + expect(result.applicable).toEqual([preferences[1]]); + }); + + it("does not drop applicable skills while discovery is loading", () => { + expect( + getApplicableAlwaysOnSkills(preferences, undefined, { + repository: "posthog/code", + }).applicable, + ).toEqual(preferences); + }); +}); diff --git a/packages/shared/src/skills.ts b/packages/shared/src/skills.ts index 540b92eba5..993c8b7ce7 100644 --- a/packages/shared/src/skills.ts +++ b/packages/shared/src/skills.ts @@ -7,12 +7,71 @@ export interface SkillInfo { source: SkillSource; path: string; repoName?: string; + repoPath?: string; + repository?: string; /** Whether the skill lives in a directory we own on the user's behalf. */ editable: boolean; /** Size of SKILL.md in bytes (context-cost signal). */ skillMdBytes: number; } +export interface AlwaysOnSkillTarget { + repoPath?: string; + repository?: string | null; +} + +export function getApplicableAlwaysOnSkills< + T extends { + source: string; + path: string; + repoPath?: string; + repository?: string; + }, +>( + preferences: T[], + discoveredSkills: SkillInfo[] | undefined, + target: AlwaysOnSkillTarget, + excludedKeys: ReadonlySet = new Set(), +): { applicable: T[]; unavailable: T[] } { + const discoveredByKey = new Map( + discoveredSkills?.map((skill) => [ + `${skill.source}:${skill.path}`, + skill, + ]) ?? [], + ); + const normalizedRepoPath = target.repoPath?.replace(/[\\/]+$/, ""); + const normalizedRepository = target.repository?.toLowerCase(); + const applicable: T[] = []; + const unavailable: T[] = []; + + for (const preference of preferences) { + const key = `${preference.source}:${preference.path}`; + if (excludedKeys.has(key)) continue; + const discovered = discoveredByKey.get(key); + + if (preference.source === "repo") { + const belongsToLocalRepo = + !!normalizedRepoPath && + ((discovered?.repoPath ?? preference.repoPath) === normalizedRepoPath || + preference.path.startsWith(`${normalizedRepoPath}/.claude/skills/`) || + preference.path.startsWith( + `${normalizedRepoPath}\\.claude\\skills\\`, + )); + const belongsToCloudRepo = + !!normalizedRepository && + (discovered?.repository ?? preference.repository)?.toLowerCase() === + normalizedRepository; + if (!belongsToLocalRepo && !belongsToCloudRepo) continue; + } + + if (discovered || discoveredSkills === undefined) + applicable.push(preference); + else unavailable.push(preference); + } + + return { applicable, unavailable }; +} + export interface SkillFileEntry { /** Path relative to the skill directory, using "/" separators. */ path: string; diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 91f7f100f8..ce68db8472 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -14,6 +14,8 @@ export interface AlwaysOnSkillRef { name: string; source: "user" | "repo" | "marketplace" | "codex"; path: string; + repoPath?: string; + repository?: string; } // Host-agnostic input/output for the task-creation flow. The renderer diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index 21c3cc1a72..2948c8adf6 100644 --- a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -25,8 +25,10 @@ import { type AgentAdapter, useSettingsStore, } from "../../settings/settingsStore"; +import { useSkills } from "../../skills/useSkills"; import { AlwaysOnSkillChips, + UnavailableAlwaysOnSkills, useAlwaysOnSkillSelection, } from "../../task-detail/components/AlwaysOnSkillChips"; import { @@ -281,12 +283,17 @@ export const ChannelHomeComposer = forwardRef< // task-ready callback matches create order and keeps adds/removes balanced — // no row is ever orphaned, even if two creates briefly overlap. const pendingIdsRef = useRef([]); + const { data: skills } = useSkills(); const { includedSkills: includedAlwaysOnSkills, - excludedKeys: excludedAlwaysOnSkillKeys, + unavailable: unavailableAlwaysOnSkills, exclude: excludeAlwaysOnSkill, reset: resetAlwaysOnSkillSelection, - } = useAlwaysOnSkillSelection(); + } = useAlwaysOnSkillSelection({ + discoveredSkills: skills, + target: {}, + draftKey: `${sessionId}:${backendChannelId ?? channelId}`, + }); const handleTaskCreated = useCallback( (task: Task) => { @@ -320,7 +327,7 @@ export const ChannelHomeComposer = forwardRef< channelName, channelId: backendChannelId, channelContextId: channelId, - excludedAlwaysOnSkillKeys, + alwaysOnSkills: includedAlwaysOnSkills, onTaskCreated: handleTaskCreated, }); @@ -425,13 +432,16 @@ export const ChannelHomeComposer = forwardRef< disabled={isBusy} /> - {includedAlwaysOnSkills.length > 0 && ( + {(includedAlwaysOnSkills.length > 0 || + unavailableAlwaysOnSkills.length > 0) && (
Using: +
)} diff --git a/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx b/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx index b7fe4d6f49..a64b3bae1c 100644 --- a/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx +++ b/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx @@ -15,7 +15,7 @@ const HELPER_SKILLS = [ /** One-line pointer to the two official scout helper skills, opened in-app. */ export function ScoutHelperSkillLinks({ surface }: { surface: ScoutSurface }) { - const { requestSkill } = useSkillsSelectionActions(); + const { requestSkillByName } = useSkillsSelectionActions(); return ( Helper skills:{" "} @@ -31,7 +31,7 @@ export function ScoutHelperSkillLinks({ surface }: { surface: ScoutSurface }) { surface, helper_skill: skill.label, }); - requestSkill(skill.name); + requestSkillByName(skill.name); }} className="text-accent-11 no-underline hover:text-accent-12" > diff --git a/packages/ui/src/features/skills/SkillDetailPanel.tsx b/packages/ui/src/features/skills/SkillDetailPanel.tsx index 448a643860..f5f3f70cc2 100644 --- a/packages/ui/src/features/skills/SkillDetailPanel.tsx +++ b/packages/ui/src/features/skills/SkillDetailPanel.tsx @@ -335,6 +335,10 @@ export function SkillDetailPanel({ "bundled" >, path: skill.path, + ...(skill.repoPath ? { repoPath: skill.repoPath } : {}), + ...(skill.repository + ? { repository: skill.repository } + : {}), }, checked, ) diff --git a/packages/ui/src/features/skills/SkillsView.tsx b/packages/ui/src/features/skills/SkillsView.tsx index 371dce5002..11b4d9a007 100644 --- a/packages/ui/src/features/skills/SkillsView.tsx +++ b/packages/ui/src/features/skills/SkillsView.tsx @@ -17,6 +17,7 @@ import { NewSkillDialog } from "./NewSkillDialog"; import { SkillSection, SOURCE_CONFIG } from "./SkillCard"; import { SkillDetailPanel } from "./SkillDetailPanel"; import { + useRequestedSkill, useRequestedSkillName, useSkillsSelectionActions, } from "./skillsSelectionStore"; @@ -73,16 +74,23 @@ export function SkillsView() { // Another surface (e.g. the scout helper links) can ask to open a specific // skill by name; honor it once the skill list has loaded, then clear it. const requestedSkillName = useRequestedSkillName(); + const requestedSkill = useRequestedSkill(); const { clearRequestedSkill } = useSkillsSelectionActions(); useEffect(() => { - if (!requestedSkillName || skills.length === 0) return; - const match = skills.find((s) => s.name === requestedSkillName); + if ((!requestedSkill && !requestedSkillName) || skills.length === 0) return; + const match = requestedSkill + ? skills.find( + (skill) => + skill.source === requestedSkill.source && + skill.path === requestedSkill.path, + ) + : skills.find((skill) => skill.name === requestedSkillName); if (match) { setSelectedPath(match.path); setScrollToPath(match.path); } clearRequestedSkill(); - }, [requestedSkillName, skills, clearRequestedSkill]); + }, [requestedSkill, requestedSkillName, skills, clearRequestedSkill]); const handleScrolledIntoView = useCallback(() => setScrollToPath(null), []); diff --git a/packages/ui/src/features/skills/skillsSelectionStore.ts b/packages/ui/src/features/skills/skillsSelectionStore.ts index 5a6c7c792b..cac4ea0970 100644 --- a/packages/ui/src/features/skills/skillsSelectionStore.ts +++ b/packages/ui/src/features/skills/skillsSelectionStore.ts @@ -7,10 +7,12 @@ interface SkillsSelectionState { * clears it so a later plain visit to /skills opens nothing. */ requestedSkillName: string | null; + requestedSkill: { source: string; path: string } | null; } interface SkillsSelectionActions { - requestSkill: (name: string) => void; + requestSkill: (skill: { source: string; path: string }) => void; + requestSkillByName: (name: string) => void; clearRequestedSkill: () => void; } @@ -20,12 +22,16 @@ type SkillsSelectionStore = SkillsSelectionState & { const useStore = create((set) => ({ requestedSkillName: null, + requestedSkill: null, actions: { - requestSkill: (name) => set({ requestedSkillName: name }), - clearRequestedSkill: () => set({ requestedSkillName: null }), + requestSkill: (skill) => set({ requestedSkill: skill }), + requestSkillByName: (name) => set({ requestedSkillName: name }), + clearRequestedSkill: () => + set({ requestedSkill: null, requestedSkillName: null }), }, })); export const useRequestedSkillName = () => useStore((s) => s.requestedSkillName); +export const useRequestedSkill = () => useStore((s) => s.requestedSkill); export const useSkillsSelectionActions = () => useStore((s) => s.actions); diff --git a/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx b/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx index 61ccd68466..6a011ac52e 100644 --- a/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx +++ b/packages/ui/src/features/task-detail/components/AlwaysOnSkillChips.tsx @@ -1,16 +1,36 @@ -import { Lightbulb, X } from "@phosphor-icons/react"; -import type { AlwaysOnSkillRef } from "@posthog/shared"; +import { Lightbulb, Warning, X } from "@phosphor-icons/react"; +import { + type AlwaysOnSkillRef, + type AlwaysOnSkillTarget, + getApplicableAlwaysOnSkills, + type SkillInfo, +} from "@posthog/shared"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useSkillsSelectionActions } from "@posthog/ui/features/skills/skillsSelectionStore"; import { Tooltip } from "@radix-ui/themes"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -export function useAlwaysOnSkillSelection() { +export function useAlwaysOnSkillSelection({ + discoveredSkills, + target, + draftKey, +}: { + discoveredSkills: SkillInfo[] | undefined; + target: AlwaysOnSkillTarget; + draftKey: string; +}) { const alwaysOnSkills = useSettingsStore((state) => state.alwaysOnSkills); const [excludedKeys, setExcludedKeys] = useState(() => new Set()); - const includedSkills = alwaysOnSkills.filter( - (skill) => !excludedKeys.has(`${skill.source}:${skill.path}`), + const { applicable: includedSkills, unavailable } = useMemo( + () => + getApplicableAlwaysOnSkills( + alwaysOnSkills, + discoveredSkills, + target, + excludedKeys, + ), + [alwaysOnSkills, discoveredSkills, target, excludedKeys], ); const exclude = useCallback((skill: AlwaysOnSkillRef) => { setExcludedKeys((current) => { @@ -20,21 +40,29 @@ export function useAlwaysOnSkillSelection() { }); }, []); const reset = useCallback(() => setExcludedKeys(new Set()), []); + const previousDraftKey = useRef(draftKey); + useEffect(() => { + if (previousDraftKey.current === draftKey) return; + previousDraftKey.current = draftKey; + reset(); + }, [draftKey, reset]); - return { includedSkills, excludedKeys, exclude, reset }; + return { includedSkills, unavailable, excludedKeys, exclude, reset }; } export function AlwaysOnSkillChips({ skills, onExclude, + disabled = false, }: { skills: AlwaysOnSkillRef[]; onExclude: (skill: AlwaysOnSkillRef) => void; + disabled?: boolean; }) { const { requestSkill } = useSkillsSelectionActions(); const openSkill = useCallback( - (name: string) => { - requestSkill(name); + (skill: AlwaysOnSkillRef) => { + requestSkill({ source: skill.source, path: skill.path }); openSettings("skills"); }, [requestSkill], @@ -48,7 +76,8 @@ export function AlwaysOnSkillChips({