diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c771aaebcb6e..a774fe7f243a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,5 +1,6 @@ import type { EnvironmentId, + ExplicitSkillInvocation, MessageId, ModelSelection, OrchestrationThreadShell, @@ -13,6 +14,7 @@ import { serializeComposerFileLink, type ComposerTrigger, } from "@t3tools/shared/composerTrigger"; +import { updateExplicitSkillInvocationsForTextEdit } from "@t3tools/shared/explicitSkillInvocations"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; @@ -91,6 +93,7 @@ export const COMPOSER_EXPANDED_CHROME = 156; export interface ThreadComposerProps { readonly draftMessage: string; + readonly draftSkillInvocations: ReadonlyArray; readonly draftAttachments: ReadonlyArray; readonly placeholder: string; readonly contentMaxWidth?: number; @@ -110,7 +113,10 @@ export interface ThreadComposerProps { readonly environmentId: EnvironmentId; readonly projectCwd: string | null; readonly editorRef?: RefObject; - readonly onChangeDraftMessage: (value: string) => void; + readonly onChangeDraftMessage: ( + value: string, + skillInvocations: ReadonlyArray, + ) => void; readonly onPickDraftImages: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; @@ -287,8 +293,20 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const settingsRoutePresentedRef = useRef(false); const wasExpandedBeforePreviewRef = useRef(false); const inFlightThreadIdsRef = useRef(new Set()); + const skillInvocationsRef = useRef([...props.draftSkillInvocations]); + const previousDraftMessageRef = useRef(props.draftMessage); const { onExpandedChange } = props; + useEffect(() => { + previousDraftMessageRef.current = props.draftMessage; + skillInvocationsRef.current = [...props.draftSkillInvocations]; + }, [ + props.draftMessage, + props.draftSkillInvocations, + props.environmentId, + props.selectedThread.id, + ]); + const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; // Opening and presentation count as active so the composer stays expanded @@ -547,6 +565,24 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; + const handleDraftMessageChange = useCallback( + (nextMessage: string, addedInvocation?: ExplicitSkillInvocation) => { + const previousMessage = previousDraftMessageRef.current; + skillInvocationsRef.current = updateExplicitSkillInvocationsForTextEdit({ + previousText: previousMessage, + nextText: nextMessage, + invocations: skillInvocationsRef.current, + }); + if (addedInvocation) { + skillInvocationsRef.current.push(addedInvocation); + skillInvocationsRef.current.sort((left, right) => left.start - right.start); + } + previousDraftMessageRef.current = nextMessage; + onChangeDraftMessage(nextMessage, skillInvocationsRef.current); + }, + [onChangeDraftMessage], + ); + const handleSend = useCallback(async () => { const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; @@ -556,6 +592,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer if (messageId === null) { return; } + skillInvocationsRef.current = []; + previousDraftMessageRef.current = ""; // Sending a prompt starts agent work: arm the lock-screen card while the // app is foregrounded and the activity token can be registered. Armed // after the send so its preference read and native Activity start don't @@ -590,7 +628,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer "", ); setComposerSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); + handleDraftMessageChange(result.text); onUpdateInteractionMode(item.command); return; } @@ -613,9 +651,18 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer replacement, ); setComposerSelection({ start: result.cursor, end: result.cursor }); - onChangeDraftMessage(result.text); + handleDraftMessageChange( + result.text, + item.type === "skill" + ? { + name: item.skill.name, + start: composerTrigger.rangeStart, + end: composerTrigger.rangeStart + item.skill.name.length + 1, + } + : undefined, + ); }, - [composerTrigger, draftMessage, onChangeDraftMessage, onUpdateInteractionMode], + [composerTrigger, draftMessage, handleDraftMessageChange, onUpdateInteractionMode], ); // ── Model menu ─────────────────────────────────────────── @@ -799,7 +846,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer value={props.draftMessage} skills={selectedProviderStatus?.skills ?? []} selection={composerSelection} - onChangeText={props.onChangeDraftMessage} + onChangeText={handleDraftMessageChange} onSelectionChange={handleSelectionChange} onPasteImages={(uris) => void props.onNativePasteImages(uris)} placeholder={props.placeholder} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2c6860199722..7e3b1182aa00 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -6,6 +6,7 @@ import { HeaderHeightContext } from "@react-navigation/elements"; import type { ApprovalRequestId, EnvironmentId, + ExplicitSkillInvocation, MessageId, ModelSelection, OrchestrationThreadShell, @@ -96,6 +97,7 @@ export interface ThreadDetailScreenProps { readonly activePendingUserInputAnswers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; + readonly draftSkillInvocations: ReadonlyArray; readonly draftAttachments: ReadonlyArray; readonly connectionStateLabel: EnvironmentConnectionPhase; /** Message sync status for the selected thread (drives the composer status pill). */ @@ -111,7 +113,10 @@ export interface ThreadDetailScreenProps { readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onOpenConnectionEditor: () => void; - readonly onChangeDraftMessage: (value: string) => void; + readonly onChangeDraftMessage: ( + value: string, + skillInvocations: ReadonlyArray, + ) => void; readonly onPickDraftImages: () => Promise; readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; @@ -742,6 +747,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ; readonly attachments: ReadonlyArray; readonly modelSelection?: ModelSelectionType; readonly runtimeMode?: RuntimeModeType; diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index b12ad2dc5843..3e0c7f8288a6 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -92,6 +92,7 @@ describe("thread outbox", () => { }, runtimeMode: "approval-required", interactionMode: "plan", + skillInvocations: [{ name: "review", start: 0, end: 7 }], } satisfies QueuedThreadMessage; expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual( diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index 8dbddfe1fece..1db15d3483df 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -154,6 +154,38 @@ describe("mobile composer drafts", () => { ).toThrow(); }); + it("persists explicit skill ranges and clears them with sent content", () => { + const draftKey = "environment-1:thread-1"; + const draft = decodePersistedComposerDrafts({ + schemaVersion: 1, + drafts: { + [draftKey]: { + text: "$review this", + skillInvocations: [{ name: "review", start: 0, end: 7 }], + attachments: [], + }, + }, + })[draftKey]; + + expect(draft?.skillInvocations).toEqual([{ name: "review", start: 0, end: 7 }]); + expect(clearComposerDraftContentState({ [draftKey]: draft! }, draftKey)).toEqual({}); + }); + + it("restores skill ranges when a failed send merges into matching text", () => { + const draftKey = "environment-1:thread-1"; + const merged = mergeComposerDraftContentState( + { [draftKey]: { text: "$review this", attachments: [] } }, + draftKey, + { + text: "$review this", + skillInvocations: [{ name: "review", start: 0, end: 7 }], + attachments: [], + }, + ); + + expect(merged[draftKey]?.skillInvocations).toEqual([{ name: "review", start: 0, end: 7 }]); + }); + it("clears sent content without clearing the selected model or workspace", () => { const draftKey = "environment-1:thread-1"; const draft: ComposerDraft = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 7dbea23596c7..3247acb4c57d 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,10 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; import { ModelSelection as ModelSelectionSchema, + ExplicitSkillInvocation as ExplicitSkillInvocationSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, + type ExplicitSkillInvocation, type ModelSelection, type ProviderInteractionMode, type RuntimeMode, @@ -40,6 +42,7 @@ export class ComposerDraftPersistenceError extends Schema.TaggedErrorClass; readonly attachments: ReadonlyArray; readonly importedShareIds?: ReadonlyArray; readonly modelSelection?: ModelSelection; @@ -50,6 +53,7 @@ export interface ComposerDraft { export interface ComposerDraftContent { readonly text: string; + readonly skillInvocations?: ReadonlyArray; readonly attachments: ReadonlyArray; readonly sourceShareId?: string; } @@ -75,6 +79,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ const ComposerDraftSchema = Schema.Struct({ text: Schema.String, + skillInvocations: Schema.optional(Schema.Array(ExplicitSkillInvocationSchema)), attachments: Schema.Array(DraftComposerImageAttachmentSchema), importedShareIds: Schema.optional(Schema.Array(Schema.String)), modelSelection: Schema.optional(ModelSelectionSchema), @@ -280,11 +285,18 @@ function updateComposerDrafts( schedulePersistComposerDrafts(next); } -export function setComposerDraftText(draftKey: string, value: string): void { +export function setComposerDraftText( + draftKey: string, + value: string, + skillInvocations?: ReadonlyArray, +): void { updateComposerDrafts((current) => { + const existing = normalizeDraft(current[draftKey]); + const { skillInvocations: _skillInvocations, ...draftWithoutSkills } = existing; const draft = { - ...normalizeDraft(current[draftKey]), + ...(skillInvocations === undefined ? existing : draftWithoutSkills), text: value, + ...(skillInvocations && skillInvocations.length > 0 ? { skillInvocations } : {}), }; if (isEmptyDraft(draft)) { const next = { ...current }; @@ -400,7 +412,12 @@ export function clearComposerDraftContentState( if (!existing) { return current; } - const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; + const { + importedShareIds: _importedShareIds, + skillInvocations: _skillInvocations, + workspaceSelection, + ...retained + } = existing; const draft = { ...retained, ...(options?.clearWorkspaceSelection || workspaceSelection === undefined @@ -460,6 +477,7 @@ export function copyComposerDraftContentState( [targetDraftKey]: { ...target, text: source.text, + ...(source.skillInvocations ? { skillInvocations: source.skillInvocations } : {}), attachments: source.attachments, ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), }, @@ -494,6 +512,13 @@ function mergeComposerDraftText(existing: string, incoming: string): string { return `${existing}\n\n${incoming}`; } +function mergedTextOffset(existing: string, incoming: string): number | null { + if (incoming.length === 0) return null; + if (existing.length === 0 || existing === incoming) return 0; + if (existing.endsWith(`\n\n${incoming}`)) return existing.length - incoming.length; + return existing.length + 2; +} + export function mergeComposerDraftContentState( current: Record, draftKey: string, @@ -516,13 +541,35 @@ export function mergeComposerDraftContentState( PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ); const text = mergeComposerDraftText(existing.text, content.text); + const incomingTextOffset = mergedTextOffset(existing.text, content.text); + const skillInvocations = [...(existing.skillInvocations ?? [])]; + if (incomingTextOffset !== null) { + for (const invocation of content.skillInvocations ?? []) { + const shifted = { + ...invocation, + start: invocation.start + incomingTextOffset, + end: invocation.end + incomingTextOffset, + }; + if ( + !skillInvocations.some( + (existingInvocation) => + existingInvocation.name === shifted.name && + existingInvocation.start === shifted.start && + existingInvocation.end === shifted.end, + ) + ) { + skillInvocations.push(shifted); + } + } + } const importedShareIds = content.sourceShareId ? [...(existing.importedShareIds ?? []), content.sourceShareId] : existing.importedShareIds; if ( text === existing.text && attachments.length === existing.attachments.length && - importedShareIds === existing.importedShareIds + importedShareIds === existing.importedShareIds && + skillInvocations.length === (existing.skillInvocations?.length ?? 0) ) { return current; } @@ -531,6 +578,7 @@ export function mergeComposerDraftContentState( [draftKey]: { ...existing, text, + ...(skillInvocations.length > 0 ? { skillInvocations } : {}), attachments, ...(importedShareIds ? { importedShareIds } : {}), }, diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index dd7ace60ad99..870d00e4da6f 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -5,6 +5,7 @@ import * as Cause from "effect/Cause"; import { CommandId, + type ExplicitSkillInvocation, MessageId, type EnvironmentId, type ModelSelection, @@ -21,6 +22,7 @@ import { } from "@t3tools/client-runtime/state/threads"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; +import { remapExplicitSkillInvocations } from "@t3tools/shared/explicitSkillInvocations"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { @@ -81,6 +83,7 @@ export function useThreadDraftForThread(input: { return { draftMessage: draft.text, + draftSkillInvocations: draft.skillInvocations ?? [], draftAttachments: draft.attachments, }; } @@ -126,6 +129,7 @@ export function useThreadComposerState() { const selectedDraft = selectedThreadKey ? composerDrafts[selectedThreadKey] : null; const draftMessage = selectedDraft?.text ?? ""; + const draftSkillInvocations = selectedDraft?.skillInvocations ?? []; const draftAttachments = selectedDraft?.attachments ?? []; const selectedThreadQueueCount = selectedThreadQueuedMessages.length; const selectedThread = selectedThreadDetail ?? selectedThreadShell; @@ -167,6 +171,11 @@ export function useThreadComposerState() { const draft = getComposerDraftSnapshot(threadKey); const thread = selectedThreadDetail ?? selectedThreadShell; const text = draft.text.trim(); + const sentSkillInvocations = remapExplicitSkillInvocations({ + sourceText: draft.text, + outgoingText: text, + invocations: draft.skillInvocations ?? [], + }); const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { return null; @@ -249,6 +258,7 @@ export function useThreadComposerState() { messageId, commandId: CommandId.make(metadata.commandId), text, + ...(sentSkillInvocations.length > 0 ? { skillInvocations: sentSkillInvocations } : {}), attachments, modelSelection: draft.modelSelection ?? thread.modelSelection, runtimeMode: draft.runtimeMode ?? thread.runtimeMode, @@ -261,7 +271,11 @@ export function useThreadComposerState() { // append: the merge path slots existing attachments first and truncates // at the send limit, which would silently drop this message's images if // the user attached new ones while the write was in flight. - void mergeComposerDraftContent(threadKey, { text, attachments: [] }); + void mergeComposerDraftContent(threadKey, { + text, + skillInvocations: sentSkillInvocations, + attachments: [], + }); appendComposerDraftAttachments(threadKey, attachments); setPendingConnectionError( error instanceof Error ? error.message : "Failed to save the queued message.", @@ -276,13 +290,13 @@ export function useThreadComposerState() { ]); const onChangeDraftMessage = useCallback( - (value: string) => { + (value: string, skillInvocations: ReadonlyArray) => { if (!selectedThreadShell) { return; } const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - setComposerDraftText(threadKey, value); + setComposerDraftText(threadKey, value, skillInvocations); }, [selectedThreadShell], ); @@ -398,6 +412,7 @@ export function useThreadComposerState() { selectedThreadQueueCount, activeWorkStartedAt, draftMessage, + draftSkillInvocations, draftAttachments, modelSelection, runtimeMode, diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..4d6551008f9b 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -227,6 +227,9 @@ export function useThreadOutboxDrain(): void { role: "user", text: queuedMessage.text, attachments: toUploadChatImageAttachments(queuedMessage.attachments), + ...(queuedMessage.skillInvocations !== undefined + ? { skillInvocations: queuedMessage.skillInvocations } + : {}), }, modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a3588244d827..7d5e90764d14 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -540,8 +540,9 @@ describe("ProviderCommandReactor", () => { message: { messageId: asMessageId("user-message-1"), role: "user", - text: "hello reactor", + text: " hello $reactor ", attachments: [], + skillInvocations: [{ name: "reactor", start: 8, end: 16 }], }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", @@ -566,6 +567,10 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.threadId).toBe("thread-1"); expect(thread?.session?.status).toBe("starting"); expect(thread?.session?.runtimeMode).toBe("approval-required"); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + input: "hello $reactor", + skillInvocations: [{ name: "reactor", start: 6, end: 14 }], + }); }); effectIt.effect("projects starting before a slow provider session finishes", () => diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 812893d8c843..d952f0fd829a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -9,10 +9,12 @@ import { type OrchestrationSession, ThreadId, type ProviderSession, + type ProviderSendTurnInput, type RuntimeMode, type TurnId, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { remapExplicitSkillInvocations } from "@t3tools/shared/explicitSkillInvocations"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -775,6 +777,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly messageText: string; readonly attachments?: ReadonlyArray; + readonly skillInvocations?: ProviderSendTurnInput["skillInvocations"]; readonly modelSelection?: ModelSelection; readonly interactionMode?: "default" | "plan"; readonly createdAt: string; @@ -794,6 +797,13 @@ const make = Effect.gen(function* () { } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; + const skillInvocations = normalizedInput + ? remapExplicitSkillInvocations({ + sourceText: input.messageText, + outgoingText: normalizedInput, + invocations: input.skillInvocations ?? [], + }) + : []; const activeSession = yield* providerService .listSessions() .pipe( @@ -826,6 +836,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), + ...(skillInvocations !== undefined ? { skillInvocations } : {}), ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }; @@ -1206,6 +1217,9 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(event.payload.skillInvocations !== undefined + ? { skillInvocations: event.payload.skillInvocations } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index bf5c509fa16b..17df50d13b7c 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -307,8 +307,9 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { message: { messageId: asMessageId("message-user-1"), role: "user", - text: "hello", + text: "hello $review", attachments: [], + skillInvocations: [{ name: "review", start: 6, end: 13 }], }, modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "reasoningEffort", value: "high" }, @@ -334,12 +335,14 @@ it.layer(NodeServices.layer)("decider project scripts", (it) => { expect(turnStartEvent.payload).toMatchObject({ threadId: ThreadId.make("thread-1"), messageId: asMessageId("message-user-1"), + skillInvocations: [{ name: "review", start: 6, end: 13 }], modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.3-codex", [ { id: "reasoningEffort", value: "high" }, { id: "fastMode", value: true }, ]), runtimeMode: "approval-required", }); + expect(events[0]?.payload).not.toHaveProperty("skillInvocations"); }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f3fdd462f437..66846da6ed61 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -988,6 +988,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, messageId: command.message.messageId, + ...(command.message.skillInvocations !== undefined + ? { skillInvocations: command.message.skillInvocations } + : {}), ...(command.modelSelection !== undefined ? { modelSelection: command.modelSelection } : {}), diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 60db1d0c5e26..1205c4a124d6 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -1,10 +1,42 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverClaudeSkills } from "./ClaudeSkills.ts"; +import { discoverClaudeSkills, prepareClaudeSkillContents } from "./ClaudeSkills.ts"; + +describe("prepareClaudeSkillContents", () => { + it("expands full and positional arguments", () => { + assert.deepEqual( + prepareClaudeSkillContents( + ["---", "name: migrate", "---", "", "Move $0 from $1. Full: $ARGUMENTS"].join("\n"), + 'Button "old folder"', + ), + { + kind: "prepared", + contents: 'Move Button from old folder. Full: Button "old folder"', + }, + ); + }); + + it("appends arguments when the body has no placeholder", () => { + assert.deepEqual(prepareClaudeSkillContents("# Review", "this change"), { + kind: "prepared", + contents: "# Review\n\nARGUMENTS: this change", + }); + }); + + it("rejects runtime fields the fallback cannot preserve", () => { + assert.deepEqual( + prepareClaudeSkillContents( + ["---", "name: review", "context: fork", "allowed-tools: Read", "---", "Review"].join("\n"), + "this change", + ), + { kind: "unsupported", fields: ["allowed-tools", "context"] }, + ); + }); +}); const writeSkill = Effect.fn(function* ( skillsDir: string, @@ -66,6 +98,29 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("hides skills disabled for user invocation", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + yield* writeSkill( + path.join(configDir, "skills"), + "model-only", + [ + "---", + "name: model-only", + "description: Not available in the composer.", + "user-invocable: false", + "---", + ].join("\n"), + ); + + assert.deepEqual(yield* discoverClaudeSkills({ homePath: configDir }), []); + }), + ); + it.effect("discovers project skills from the workspace .agents directory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 5c33fba0b9e9..b52088bc119e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -17,7 +17,9 @@ import type { ClaudeSettings, ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { parse as parseYamlDocument } from "yaml"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { fromYaml } from "@t3tools/shared/schemaYaml"; import { expandHomePath } from "../../pathExpansion.ts"; @@ -25,10 +27,38 @@ type ClaudeSkillScope = "user" | "project"; const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const ClaudeSkillFrontmatter = fromYaml( + Schema.Struct({ + name: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + "user-invocable": Schema.optional(Schema.Boolean), + }), +); +const decodeClaudeSkillFrontmatter = Schema.decodeUnknownOption(ClaudeSkillFrontmatter); +const decodeClaudeSkillFrontmatterRecord = Schema.decodeUnknownOption( + fromYaml(Schema.Record(Schema.String, Schema.Unknown)), +); + +const UNSUPPORTED_FALLBACK_FIELDS = [ + "allowed-tools", + "disallowed-tools", + "model", + "context", + "agent", + "background", + "hooks", + "arguments", +] as const; + type SkillFrontmatter = | { readonly kind: "missing" } | { readonly kind: "malformed" } - | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; + | { + readonly kind: "parsed"; + readonly name?: string; + readonly description?: string; + readonly userInvocable: boolean; + }; function parseSkillFrontmatter(contents: string): SkillFrontmatter { const match = FRONTMATTER_PATTERN.exec(contents); @@ -36,26 +66,97 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { return { kind: "missing" }; } - let parsed: unknown; - try { - parsed = parseYamlDocument(match[1] ?? ""); - } catch { - return { kind: "malformed" }; - } - if (typeof parsed !== "object" || parsed === null) { + const parsed = Option.getOrUndefined(decodeClaudeSkillFrontmatter(match[1] ?? "")); + if (!parsed) { return { kind: "malformed" }; } - const record = parsed as Record; - const name = typeof record.name === "string" ? record.name.trim() : ""; - const description = typeof record.description === "string" ? record.description.trim() : ""; + const name = parsed.name?.trim() ?? ""; + const description = parsed.description?.trim() ?? ""; return { kind: "parsed", + userInvocable: parsed["user-invocable"] !== false, ...(name ? { name } : {}), ...(description ? { description } : {}), }; } +function splitClaudeSkillArguments(value: string): ReadonlyArray { + const arguments_: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escaped = false; + for (const character of value) { + if (escaped) { + current += character; + escaped = false; + } else if (character === "\\" && quote !== "'") { + escaped = true; + } else if (quote) { + if (character === quote) quote = null; + else current += character; + } else if (character === "'" || character === '"') { + quote = character; + } else if (/\s/.test(character)) { + if (current.length > 0) { + arguments_.push(current); + current = ""; + } + } else { + current += character; + } + } + if (escaped) current += "\\"; + if (current.length > 0) arguments_.push(current); + return arguments_; +} + +export type PreparedClaudeSkillContents = + | { readonly kind: "prepared"; readonly contents: string } + | { readonly kind: "malformed" } + | { readonly kind: "unsupported"; readonly fields: ReadonlyArray }; + +/** + * Prepares a Claude skill for providers that cannot ask Claude Code to invoke + * it natively. Argument placeholders are expanded; runtime-only frontmatter + * is rejected so the fallback never silently weakens a skill's contract. + */ +export function prepareClaudeSkillContents( + contents: string, + argumentsText: string, +): PreparedClaudeSkillContents { + const match = FRONTMATTER_PATTERN.exec(contents); + const body = match ? contents.slice(match[0].length).trimStart() : contents; + if (match) { + const frontmatter = Option.getOrUndefined(decodeClaudeSkillFrontmatterRecord(match[1] ?? "")); + if (!frontmatter) return { kind: "malformed" }; + const fields = UNSUPPORTED_FALLBACK_FIELDS.filter((field) => frontmatter[field] !== undefined); + if (fields.length > 0) return { kind: "unsupported", fields }; + } + + const positionalArguments = splitClaudeSkillArguments(argumentsText); + let substituted = false; + const prepared = body.replace( + /(\\*)\$(?:ARGUMENTS(?:\[(\d+)\])?|(\d+))/g, + (token, backslashes: string, indexed: string | undefined, shorthand: string | undefined) => { + const placeholder = token.slice(backslashes.length); + if (backslashes.length === 1) return placeholder; + substituted = true; + const index = indexed ?? shorthand; + const replacement = + index === undefined ? argumentsText : (positionalArguments[Number(index)] ?? placeholder); + return `${backslashes}${replacement}`; + }, + ); + return { + kind: "prepared", + contents: + !substituted && argumentsText.length > 0 + ? `${prepared.trimEnd()}\n\nARGUMENTS: ${argumentsText}` + : prepared, + }; +} + /** * Resolve the Claude config directory the CLI would use, matching the * precedence the spawned CLI sees: the instance's `homePath` (exported as @@ -133,6 +234,9 @@ export const discoverClaudeSkills = Effect.fn("discoverClaudeSkills")(function* if (frontmatter.kind === "malformed") { continue; } + if (frontmatter.kind === "parsed" && !frontmatter.userInvocable) { + continue; + } const name = (frontmatter.kind === "parsed" ? frontmatter.name : undefined) ?? entry.trim(); if (!name) { diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 2101664d5cb1..daa22a201a55 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -105,6 +105,7 @@ export const CursorDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -132,7 +133,7 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Drivers/CursorSkills.test.ts b/apps/server/src/provider/Drivers/CursorSkills.test.ts new file mode 100644 index 000000000000..12bc0a8de799 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorSkills.test.ts @@ -0,0 +1,90 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverCursorSkills } from "./CursorSkills.ts"; + +const writeSkill = Effect.fn(function* ( + skillsDir: string, + relativeDirectory: string, + contents: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skillDirectory = path.join(skillsDir, relativeDirectory); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); +}); + +const skill = (name: string, description = `Use ${name}.`) => + ["---", `name: ${name}`, `description: ${description}`, "---", "", `# ${name}`].join("\n"); + +it.layer(NodeServices.layer)("discoverCursorSkills", (it) => { + it.effect("discovers every documented direct user and project root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cursor-skills-" }); + const home = path.join(tempDir, "home"); + const workspace = path.join(tempDir, "workspace"); + const rootNames = [".agents", ".cursor", ".claude", ".codex"] as const; + + for (const rootName of rootNames) { + yield* writeSkill( + path.join(home, rootName, "skills"), + `user-${rootName.slice(1)}`, + skill(`user-${rootName.slice(1)}`), + ); + yield* writeSkill( + path.join(workspace, rootName, "skills"), + `project-${rootName.slice(1)}`, + skill(`project-${rootName.slice(1)}`), + ); + } + + const skills = yield* discoverCursorSkills(workspace, { HOME: home }); + + assert.deepEqual( + skills.map((entry) => [entry.name, entry.scope]), + [ + ["project-agents", "project"], + ["project-claude", "project"], + ["project-codex", "project"], + ["project-cursor", "project"], + ["user-agents", "user"], + ["user-claude", "user"], + ["user-codex", "user"], + ["user-cursor", "user"], + ], + ); + }), + ); + + it.effect("discovers nested skills and rejects invalid entries", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cursor-skills-" }); + const workspace = path.join(tempDir, "workspace"); + const skillsDirectory = path.join(workspace, ".cursor", "skills"); + + yield* writeSkill(skillsDirectory, "shipping/deploy", skill("deploy")); + yield* writeSkill(skillsDirectory, "wrong-folder", skill("another-name")); + yield* writeSkill( + skillsDirectory, + "no-description", + ["---", "name: no-description", "---"].join("\n"), + ); + + const skills = yield* discoverCursorSkills(workspace, { HOME: path.join(tempDir, "home") }); + + assert.deepEqual( + skills.map((entry) => entry.name), + ["deploy"], + ); + assert.equal(skills[0]?.path, path.join(skillsDirectory, "shipping", "deploy", "SKILL.md")); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts new file mode 100644 index 000000000000..b11666ed8a16 --- /dev/null +++ b/apps/server/src/provider/Drivers/CursorSkills.ts @@ -0,0 +1,121 @@ +/** + * CursorSkills — filesystem discovery for the Cursor `$` picker. + * + * Cursor Agent discovers skills from its own, Agent Skills, Claude, and Codex + * directories. T3 reads the same on-disk skills because Cursor ACP does not + * expose a skill catalogue. + * + * @module provider/Drivers/CursorSkills + */ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { fromYaml } from "@t3tools/shared/schemaYaml"; + +type CursorSkillScope = "user" | "project"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const SKILL_NAME_PATTERN = /^[a-z0-9-]+$/; + +const CursorSkillFrontmatter = fromYaml( + Schema.Struct({ + name: Schema.String, + description: Schema.String, + }), +); +const decodeCursorSkillFrontmatter = Schema.decodeUnknownOption(CursorSkillFrontmatter); + +type SkillFrontmatter = + | { readonly kind: "malformed" } + | { readonly kind: "parsed"; readonly name: string; readonly description: string }; + +function parseSkillFrontmatter(contents: string): SkillFrontmatter { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) { + return { kind: "malformed" }; + } + + const parsed = Option.getOrUndefined(decodeCursorSkillFrontmatter(match[1] ?? "")); + if (!parsed) { + return { kind: "malformed" }; + } + + const name = parsed.name.trim(); + const description = parsed.description.trim(); + if (!SKILL_NAME_PATTERN.test(name) || !description) { + return { kind: "malformed" }; + } + + return { kind: "parsed", name, description }; +} + +function isSkillFile(entry: string): boolean { + return entry === "SKILL.md" || entry.replaceAll("\\", "/").endsWith("/SKILL.md"); +} + +/** + * List skills from Cursor's documented user and project roots. The scan is + * best-effort so unreadable or malformed entries never affect provider state. + */ +export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( + cwd?: string, + environment?: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = environment?.HOME?.trim() || NodeOS.homedir(); + const roots: ReadonlyArray<{ directory: string; scope: CursorSkillScope }> = [ + { directory: path.join(homePath, ".agents", "skills"), scope: "user" }, + { directory: path.join(homePath, ".cursor", "skills"), scope: "user" }, + { directory: path.join(homePath, ".claude", "skills"), scope: "user" }, + { directory: path.join(homePath, ".codex", "skills"), scope: "user" }, + ...(cwd + ? [ + { directory: path.join(cwd, ".agents", "skills"), scope: "project" as const }, + { directory: path.join(cwd, ".cursor", "skills"), scope: "project" as const }, + { directory: path.join(cwd, ".claude", "skills"), scope: "project" as const }, + { directory: path.join(cwd, ".codex", "skills"), scope: "project" as const }, + ] + : []), + ]; + + const skillsByName = new Map(); + for (const root of roots) { + const entries = yield* fileSystem + .readDirectory(root.directory, { recursive: true }) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + for (const entry of [...entries].filter(isSkillFile).sort()) { + const skillPath = path.join(root.directory, entry); + const contents = yield* fileSystem + .readFileString(skillPath) + .pipe(Effect.orElseSucceed(() => undefined)); + if (contents === undefined) { + continue; + } + + const frontmatter = parseSkillFrontmatter(contents); + if (frontmatter.kind === "malformed") { + continue; + } + if (frontmatter.name !== path.basename(path.dirname(skillPath))) { + continue; + } + + skillsByName.set(frontmatter.name, { + name: frontmatter.name, + description: frontmatter.description, + path: skillPath, + enabled: true, + scope: root.scope, + }); + } + } + + return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 46a7c02665cd..19cd36b4962c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -782,6 +782,60 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("explicitly injects a selected skill document into Claude prompts", () => { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-skills-")); + const configDir = NodePath.join(baseDir, "claude-home"); + const skillsDir = NodePath.join(configDir, "skills", "wayfinder"); + NodeFS.mkdirSync(skillsDir, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(skillsDir, "SKILL.md"), + [ + "---", + "name: wayfinder", + "description: Plan and document the work.", + "disable-model-invocation: true", + "---", + "", + "# Wayfinder", + "Map the work before implementation.", + ].join("\n"), + ); + const harness = makeHarness({ + baseDir, + cwd: NodePath.join(baseDir, "workspace"), + claudeConfig: { homePath: configDir }, + }); + + return Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(baseDir, { recursive: true, force: true })), + ); + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "Use $wayfinder to plan this change.", + skillInvocations: [{ name: "wayfinder", start: 4, end: 14 }], + attachments: [], + }); + + const promptText = yield* Effect.promise(() => + readFirstPromptText(harness.getLastCreateQueryInput()), + ); + assert.include(promptText ?? "", "Map the work before implementation."); + assert.include(promptText ?? "", "[T3 explicitly invoked skill: wayfinder]"); + assert.notInclude(promptText ?? "", "$wayfinder"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("embeds image attachments in Claude user messages", () => { const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "claude-attachments-")); const harness = makeHarness({ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 173b7a2b0355..2ec0cb87d781 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -36,6 +36,7 @@ import { type ProviderRuntimeTurnStatus, type ProviderSendTurnInput, type ProviderSession, + type ServerProviderSkill, type ThreadTokenUsageSnapshot, type ProviderUserInputAnswers, type RuntimeContentStreamKind, @@ -81,6 +82,7 @@ import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { discoverClaudeSkills, prepareClaudeSkillContents } from "../Drivers/ClaudeSkills.ts"; import { getClaudeModelCapabilities, isClaudeUltracodeEffort, @@ -98,6 +100,11 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { + replaceExplicitSkillInvocations, + renderProviderSkillPrompt, + loadInvokedSkills, +} from "../skillInvocations.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -273,6 +280,7 @@ function rememberPendingTaskModel( interface ClaudeSessionContext { session: ProviderSession; + readonly skills: ReadonlyArray; readonly promptQueue: Queue.Queue; readonly query: ClaudeQueryRuntime; streamFiber: Fiber.Fiber | undefined; @@ -1730,6 +1738,76 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); + const discoverSkills = (cwd?: string) => + discoverClaudeSkills(claudeSettings, cwd, claudeEnvironment).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const prepareSkillPrompt = (input: ProviderSendTurnInput, context: ClaudeSessionContext) => + Effect.gen(function* () { + const prompt = input.input?.trim(); + const invocations = input.skillInvocations ?? []; + if (!prompt || invocations.length === 0) { + return undefined; + } + + const resolved = yield* loadInvokedSkills({ + provider: PROVIDER, + providerLabel: "Claude", + prompt, + invocations, + skills: context.skills, + readFile: (skillPath) => + fileSystem.readFileString(skillPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: `Failed to read Claude skill '${skillPath}'.`, + cause, + }), + ), + ), + }); + const argumentsText = replaceExplicitSkillInvocations( + prompt, + resolved.invocations, + () => "", + ).trim(); + const documents = resolved.documents.map((document) => ({ + ...document, + prepared: prepareClaudeSkillContents(document.contents, argumentsText), + })); + const malformed = documents.find((document) => document.prepared.kind === "malformed"); + if (malformed) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Claude skill '$${malformed.name}' has malformed frontmatter.`, + }); + } + const unsupported = documents.find((document) => document.prepared.kind === "unsupported"); + if (unsupported?.prepared.kind === "unsupported") { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Claude skill '$${unsupported.name}' requires native runtime fields unsupported by T3's explicit fallback: ${unsupported.prepared.fields.join(", ")}.`, + }); + } + return renderProviderSkillPrompt( + prompt, + documents.map((document) => ({ + name: document.name, + path: document.path, + contents: + document.prepared.kind === "prepared" ? document.prepared.contents : document.contents, + })), + resolved.invocations, + ); + }); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( @@ -3751,7 +3829,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( // Same reason as the approvals above: a request nobody can answer any more // must not stay open, or the thread can never be settled. - for (const pending of [...context.pendingUserInputs.values()]) { + for (const pending of context.pendingUserInputs.values()) { yield* pending.cancel; } @@ -3842,6 +3920,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const startedAt = yield* nowIso; + const skills = yield* discoverSkills(input.cwd); const resumeState = readClaudeResumeState(input.resumeCursor); const threadId = input.threadId; const existingResumeSessionId = resumeState?.resume; @@ -4407,6 +4486,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const context: ClaudeSessionContext = { session, + skills, promptQueue, query: queryRuntime, streamFiber: undefined, @@ -4515,6 +4595,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( input.modelSelection !== undefined && input.modelSelection.instanceId === boundInstanceId ? input.modelSelection : undefined; + const skillPrompt = yield* prepareSkillPrompt(input, context); // A sendTurn while a real turn is running is a steer: the message is // queued into the live SDK agent loop and the work continues as the same @@ -4599,11 +4680,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } - const message = yield* buildUserMessageEffect(input, { - fileSystem, - attachmentsDir: serverConfig.attachmentsDir, - boundInstanceId, - }); + const message = yield* buildUserMessageEffect( + skillPrompt === undefined ? input : { ...input, input: skillPrompt }, + { + fileSystem, + attachmentsDir: serverConfig.attachmentsDir, + boundInstanceId, + }, + ); yield* Queue.offer(context.promptQueue, { type: "message", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0f7d999662e9..d8bcfe9aee5d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -57,6 +57,7 @@ import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, + CodexSessionRuntimeUnknownSkillError, describeMcpElicitation, makeCodexSessionRuntime, type CodexSessionRuntimeError, @@ -70,6 +71,7 @@ const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTrans const isCodexSessionRuntimeThreadIdMissingError = Schema.is( CodexSessionRuntimeThreadIdMissingError, ); +const isCodexSessionRuntimeUnknownSkillError = Schema.is(CodexSessionRuntimeUnknownSkillError); const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); const PROVIDER = ProviderDriverKind.make("codex"); @@ -117,6 +119,15 @@ function mapCodexRuntimeError( }); } + if (isCodexSessionRuntimeUnknownSkillError(error)) { + return new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Unknown Codex skill${error.names.length === 1 ? "" : "s"}: ${error.names.map((name) => `$${name}`).join(", ")}.`, + cause: error, + }); + } + return new ProviderAdapterRequestError({ provider: PROVIDER, method, @@ -1834,6 +1845,9 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( return yield* session.runtime .sendTurn({ ...(input.input !== undefined ? { input: input.input } : {}), + ...(input.skillInvocations !== undefined + ? { skillInvocations: input.skillInvocations } + : {}), ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } : {}), diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 52a8fdd25dc7..60f6ec277fd3 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -246,7 +246,7 @@ function appendCustomCodexModels( return customEntries.length === 0 ? models : [...models, ...customEntries]; } -function parseCodexSkillsListResponse( +export function parseCodexSkillsListResponse( response: CodexSchema.V2SkillsListResponse, cwd: string, ): ReadonlyArray { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 6a6cec5b1e61..c6ec8f164e8c 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -17,6 +17,7 @@ import { import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, + CodexSessionRuntimeUnknownSkillError, describeMcpElicitation, hasConfiguredMcpServer, isRecoverableThreadResumeError, @@ -25,6 +26,8 @@ import { toMcpElicitationResponse, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); +const isCodexAppServerProtocolParseError = Schema.is(CodexErrors.CodexAppServerProtocolParseError); +const isCodexSessionRuntimeUnknownSkillError = Schema.is(CodexSessionRuntimeUnknownSkillError); describe("CodexSessionRuntimeIdentifierGenerationError", () => { it("retains identifier purpose and the random source failure", () => { @@ -81,6 +84,10 @@ describe("buildTurnStartParams", () => { ], }).pipe(Effect.flip), ); + if (!isCodexAppServerProtocolParseError(error)) { + NodeAssert.fail("expected CodexAppServerProtocolParseError"); + return; + } const { cause, ...directDiagnostics } = error; NodeAssert.equal(error.operation, "decode-request-payload"); @@ -224,6 +231,85 @@ describe("buildTurnStartParams", () => { }), ); + it.effect("attaches explicit $skill tokens as Codex skill user input", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "$grill-with-docs explain why this skill is not in the list", + skillInvocations: [{ name: "grill-with-docs", start: 0, end: 16 }], + availableSkills: [ + { + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + enabled: true, + }, + ], + }); + + NodeAssert.deepStrictEqual(params.input, [ + { + type: "text", + text: "[T3 explicitly invoked skill: grill-with-docs] explain why this skill is not in the list", + }, + { + type: "skill", + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + }, + ]); + }), + ); + + it.effect("fails instead of sending an unknown $skill token", () => + Effect.gen(function* () { + const error = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "$missing-skill do this", + skillInvocations: [{ name: "missing-skill", start: 0, end: 14 }], + availableSkills: [ + { + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + enabled: true, + }, + ], + }).pipe(Effect.flip); + + if (!isCodexSessionRuntimeUnknownSkillError(error)) { + NodeAssert.fail("expected CodexSessionRuntimeUnknownSkillError"); + return; + } + NodeAssert.deepStrictEqual(error.names, ["missing-skill"]); + NodeAssert.equal(error.message, "Unknown Codex skill $missing-skill."); + }), + ); + + it.effect("leaves a message without $skill tokens unchanged", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Review this change", + availableSkills: [ + { + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + enabled: true, + }, + ], + }); + + NodeAssert.deepStrictEqual(params.input, [ + { + type: "text", + text: "Review this change", + }, + ]); + }), + ); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index b34067b7fb90..3b6e34a72e37 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -9,6 +9,7 @@ import { type ProviderApprovalOption, type ProviderEvent, type ProviderInteractionMode, + type ExplicitSkillInvocation, type ProviderRequestKind, type ProviderSession, type ProviderTurnStartResult, @@ -17,8 +18,8 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -36,7 +37,9 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { buildCodexInitializeParams, parseCodexSkillsListResponse } from "./CodexProvider.ts"; +import { bindCodexSkillInvocations } from "./codexSkillInvocations.ts"; +import { replaceExplicitSkillInvocations } from "../skillInvocations.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; @@ -164,6 +167,7 @@ export interface CodexSessionRuntimeOptions { export interface CodexSessionRuntimeSendTurnInput { readonly input?: string; + readonly skillInvocations?: ReadonlyArray; readonly attachments?: ReadonlyArray<{ readonly type: "image"; readonly url: string; @@ -215,7 +219,8 @@ export type CodexSessionRuntimeError = | CodexSessionRuntimePendingApprovalNotFoundError | CodexSessionRuntimePendingUserInputNotFoundError | CodexSessionRuntimeInvalidUserInputAnswersError - | CodexSessionRuntimeThreadIdMissingError; + | CodexSessionRuntimeThreadIdMissingError + | CodexSessionRuntimeUnknownSkillError; export class CodexSessionRuntimePendingApprovalNotFoundError extends Schema.TaggedErrorClass()( "CodexSessionRuntimePendingApprovalNotFoundError", @@ -261,6 +266,21 @@ export class CodexSessionRuntimeThreadIdMissingError extends Schema.TaggedErrorC } } +export class CodexSessionRuntimeUnknownSkillError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimeUnknownSkillError", + { + names: Schema.Array(Schema.String), + }, +) { + override get message(): string { + const listed = this.names.map((name) => `$${name}`).join(", "); + if (this.names.length === 1) { + return `Unknown Codex skill ${listed}.`; + } + return `Unknown Codex skills ${listed}.`; + } +} + interface PendingApproval { readonly requestId: ApprovalRequestId; readonly jsonRpcId: string; @@ -587,10 +607,16 @@ export function buildTurnStartParams(input: { readonly threadId: string; readonly runtimeMode: RuntimeMode; readonly prompt?: string; + readonly skillInvocations?: ReadonlyArray; readonly attachments?: ReadonlyArray<{ readonly type: "image"; readonly url: string; }>; + readonly availableSkills?: ReadonlyArray<{ + readonly name: string; + readonly path: string; + readonly enabled: boolean; + }>; readonly model?: string; readonly serviceTier?: CodexServiceTier; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; @@ -599,15 +625,34 @@ export function buildTurnStartParams(input: { readonly browserToolsAvailable?: boolean; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, - CodexErrors.CodexAppServerProtocolParseError + CodexErrors.CodexAppServerProtocolParseError | CodexSessionRuntimeUnknownSkillError > { + const boundSkills = bindCodexSkillInvocations( + input.prompt ?? "", + input.skillInvocations ?? [], + input.availableSkills ?? [], + ); + if (!boundSkills.ok) { + return Effect.fail(new CodexSessionRuntimeUnknownSkillError({ names: boundSkills.names })); + } + const turnInput: Array = []; - if (input.prompt) { + const prompt = input.prompt + ? replaceExplicitSkillInvocations( + input.prompt, + input.skillInvocations ?? [], + (name) => `[T3 explicitly invoked skill: ${name}]`, + ) + : undefined; + if (prompt) { turnInput.push({ type: "text", - text: input.prompt, + text: prompt, }); } + for (const skill of boundSkills.inputs) { + turnInput.push(skill); + } for (const attachment of input.attachments ?? []) { turnInput.push(attachment); } @@ -2105,11 +2150,21 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); + const skillInvocations = input.skillInvocations ?? []; + let availableSkills: ReturnType = []; + if (skillInvocations.length > 0) { + const session = yield* Ref.get(sessionRef); + const cwd = session.cwd ?? options.cwd; + const skillsResponse = yield* client.request("skills/list", { cwds: [cwd] }); + availableSkills = parseCodexSkillsListResponse(skillsResponse, cwd); + } const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, ...(input.input ? { prompt: input.input } : {}), + ...(skillInvocations.length > 0 ? { skillInvocations } : {}), ...(input.attachments ? { attachments: input.attachments } : {}), + ...(skillInvocations.length > 0 ? { availableSkills } : {}), ...(normalizedModel ? { model: normalizedModel } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index cd5cdb7f01aa..ed624ea94d9d 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -168,6 +168,77 @@ const cursorAdapterTestLayer = it.layer( ); cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("injects selected Cursor skill documents", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const settings = yield* ServerSettingsService; + const threadId = ThreadId.make("cursor-skill-invocation"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-acp-skill-")), + ); + const workspace = NodePath.join(tempDir, "workspace"); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const skillPath = NodePath.join(workspace, ".cursor", "skills", "deploy", "SKILL.md"); + const reviewSkillPath = NodePath.join(workspace, ".cursor", "skills", "review", "SKILL.md"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(skillPath), { recursive: true }); + await NodeFSP.mkdir(NodePath.dirname(reviewSkillPath), { recursive: true }); + await NodeFSP.writeFile( + skillPath, + ["---", "name: deploy", "description: Deploy the service.", "---", "", "# Deploy"].join( + "\n", + ), + "utf8", + ); + await NodeFSP.writeFile( + reviewSkillPath, + ["---", "name: review", "description: Review the service.", "---", "", "# Review"].join( + "\n", + ), + "utf8", + ); + await NodeFSP.writeFile(requestLogPath, "", "utf8"); + }); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, NodePath.join(tempDir, "argv.txt")), + ); + yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("cursor"), + cwd: workspace, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "default" }, + }); + yield* adapter.sendTurn({ + threadId, + input: "First use $deploy, then $review to ship the release.", + skillInvocations: [ + { name: "deploy", start: 10, end: 17 }, + { name: "review", start: 24, end: 31 }, + ], + attachments: [], + }); + + const requests = yield* waitForJsonLogMatch( + requestLogPath, + (entry) => entry.method === "session/prompt", + ); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + const prompt = (promptRequest?.params as { prompt?: Array<{ text?: string }> } | undefined) + ?.prompt?.[0]?.text; + assert.include(prompt ?? "", "# Deploy"); + assert.include(prompt ?? "", "# Review"); + assert.include(prompt ?? "", "[T3 explicitly invoked skill: deploy]"); + assert.include(prompt ?? "", "[T3 explicitly invoked skill: review]"); + assert.notInclude(prompt ?? "", "$deploy"); + assert.notInclude(prompt ?? "", "$review"); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 30c173d8fae8..7cb69ecf6d5c 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -7,6 +7,7 @@ import { ApprovalRequestId, type CursorSettings, + type ServerProviderSkill, type ProviderOptionSelection, EventId, type ProviderApprovalDecision, @@ -43,6 +44,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { discoverCursorSkills } from "../Drivers/CursorSkills.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -75,6 +77,7 @@ import { extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { renderProviderSkillPrompt, loadInvokedSkills } from "../skillInvocations.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -133,6 +136,8 @@ interface CursorSessionContext { readonly turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; + /** Skills discovered for the session cwd, refreshed lazily before a `$` turn. */ + skills: ReadonlyArray; /** Number of sendTurn prompts currently in flight or being prepared. * >0 means a turn is actively running, so a new sendTurn is a steer that * continues it, and only the last remaining prompt settles the turn. */ @@ -336,6 +341,12 @@ export function makeCursorAdapter( const threadLocksRef = yield* SynchronizedRef.make(new Map()); const runtimeEventPubSub = yield* PubSub.unbounded(); + const discoverSkills = (cwd?: string) => + discoverCursorSkills(cwd, options?.environment).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( @@ -530,6 +541,7 @@ export function makeCursorAdapter( const effectiveCursorSettings = options?.resolveSettings ? yield* options.resolveSettings : cursorSettings; + const skills = yield* discoverSkills(cwd); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ @@ -778,6 +790,7 @@ export function makeCursorAdapter( turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, + skills, promptsInFlight: 0, stopped: false, }; @@ -916,6 +929,34 @@ export function makeCursorAdapter( const sendTurn: CursorAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); + const promptText = input.input?.trim(); + let preparedPromptText = promptText; + if (promptText && input.skillInvocations && input.skillInvocations.length > 0) { + const resolved = yield* loadInvokedSkills({ + provider: PROVIDER, + providerLabel: "Cursor", + prompt: promptText, + invocations: input.skillInvocations, + skills: ctx.skills, + readFile: (skillPath) => + fileSystem.readFileString(skillPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Failed to read Cursor skill '${skillPath}'.`, + cause, + }), + ), + ), + }); + preparedPromptText = renderProviderSkillPrompt( + promptText, + resolved.documents, + resolved.invocations, + ); + } // A sendTurn while a prompt is in flight is a steer: the agent folds // the new prompt into the ongoing work, so the active turn id is // reused instead of opening a new turn. @@ -967,8 +1008,11 @@ export function makeCursorAdapter( } const promptParts: Array = []; - if (input.input?.trim()) { - promptParts.push({ type: "text", text: input.input.trim() }); + if (preparedPromptText) { + promptParts.push({ + type: "text", + text: preparedPromptText, + }); } if (input.attachments && input.attachments.length > 0) { for (const attachment of input.attachments) { diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index e969a7beab41..cd410eb9e513 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -87,7 +87,9 @@ exec ${mockAgentCommand} "$@" return wrapperPath; }); -const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* () { +const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper")(function* ( + version = "2026.04.09-f2b0fcd", +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const mockAgentPath = yield* resolveMockAgentPath(); @@ -99,7 +101,7 @@ const makeMockAgentWithAboutWrapper = Effect.fn("makeMockAgentWithAboutWrapper") const mockAgentCommand = ["node", mockAgentPath].map((arg) => JSON.stringify(arg)).join(" "); const script = `#!/bin/sh if [ "$1" = "about" ]; then - printf 'CLI Version 2026.04.09-f2b0fcd\\n' + printf 'CLI Version ${version}\\n' printf 'User Email cursor@example.com\\n' exit 0 fi @@ -323,6 +325,37 @@ describe("getCursorFallbackModels", () => { }); describe("buildCursorProviderSnapshot", () => { + it("publishes discovered skills for the shared composer picker", () => { + expect( + buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: baseCursorSettings, + parsed: { + version: "2026.04.09-f2b0fcd", + status: "ready", + auth: { status: "authenticated" }, + }, + skills: [ + { + name: "deploy", + description: "Deploy the service.", + path: "/workspace/.cursor/skills/deploy/SKILL.md", + enabled: true, + scope: "project", + }, + ], + }).skills, + ).toEqual([ + { + name: "deploy", + description: "Deploy the service.", + path: "/workspace/.cursor/skills/deploy/SKILL.md", + enabled: true, + scope: "project", + }, + ]); + }); + it("downgrades ready status to warning when ACP model discovery times out", () => { expect( buildCursorProviderSnapshot({ @@ -472,6 +505,43 @@ describe("checkCursorProviderStatus", () => { ]); await expect(runNode(waitForFileContent(requestLogPath))).resolves.toContain("initialize"); }); + + it("keeps discovered skills when the parameterized model picker is unavailable", async () => { + const fixture = await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-skills-", + }); + const workspace = path.join(tempDir, "workspace"); + const skillPath = path.join(workspace, ".cursor", "skills", "deploy", "SKILL.md"); + yield* fileSystem.makeDirectory(path.dirname(skillPath), { recursive: true }); + yield* fileSystem.writeFileString( + skillPath, + ["---", "name: deploy", "description: Deploy the service.", "---"].join("\n"), + ); + return { workspace, home: path.join(tempDir, "home") }; + }), + ); + const wrapperPath = await runNode(makeMockAgentWithAboutWrapper("2026.04.07-f2b0fcd")); + + const provider = await runNode( + checkCursorProviderStatus( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + { HOME: fixture.home }, + fixture.workspace, + ), + ); + + expect(provider.skills.map((skill) => skill.name)).toEqual(["deploy"]); + }); }); describe("discoverCursorModelsViaAcp", () => { diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index fee4306c4c5c..a764a9a645de 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -7,6 +7,7 @@ import type { ServerProviderAuth, ServerProviderModel, ServerProviderState, + ServerProviderSkill, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; @@ -30,6 +31,7 @@ import { } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { discoverCursorSkills } from "../Drivers/CursorSkills.ts"; import { buildBooleanOptionDescriptor, buildSelectOptionDescriptor, @@ -627,6 +629,7 @@ export function buildCursorProviderSnapshot(input: { readonly cursorSettings: CursorSettings; readonly parsed: CursorAboutResult; readonly discoveredModels?: ReadonlyArray; + readonly skills?: ReadonlyArray; readonly discoveryWarning?: string; }): ServerProviderDraft { const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); @@ -639,6 +642,7 @@ export function buildCursorProviderSnapshot(input: { input.cursorSettings.customModels, EMPTY_CAPABILITIES, ), + skills: input.skills ?? [], probe: { installed: true, version: input.parsed.version, @@ -987,6 +991,7 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, + cwd?: string, ): Effect.fn.Return< ServerProviderDraft, never, @@ -1056,6 +1061,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( } const parsed = parseCursorAboutOutput(aboutProbe.success.value); + const skills = yield* discoverCursorSkills(cwd, environment); const cursorCliConfigChannel = yield* readCursorCliConfigChannel(); const parameterizedModelPickerUnsupportedMessage = getCursorParameterizedModelPickerUnsupportedMessage({ @@ -1068,6 +1074,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( enabled: cursorSettings.enabled, checkedAt, models: fallbackModels, + skills, probe: { installed: true, version: parsed.version, @@ -1109,6 +1116,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( Option.filter(discoveredModels, (models) => models.length > 0), () => [] as const, ), + skills, ...(discoveryWarning ? { discoveryWarning } : {}), }); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index eeee17d9ac6a..3e03d735fdd2 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -38,15 +38,22 @@ const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); const mockAgentCommand = process.execPath; +const encodeInspectJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); -async function makeMockGrokWrapper(extraEnv?: Record) { +async function makeMockGrokWrapper(extraEnv?: Record, inspectJson?: string) { const dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-mock-")); const wrapperPath = NodePath.join(dir, "fake-grok.sh"); const envExports = Object.entries(extraEnv ?? {}) .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) .join("\n"); + const inspectBranch = `export T3_GROK_INSPECT_JSON=${JSON.stringify(inspectJson ?? '{"skills":[]}')} +if [ "$1" = "inspect" ] && [ "$2" = "--json" ]; then + printf '%s\\n' "$T3_GROK_INSPECT_JSON" + exit 0 +fi`; const script = `#!/bin/sh ${envExports} +${inspectBranch} exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" `; await NodeFSP.writeFile(wrapperPath, script, "utf8"); @@ -278,6 +285,61 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + it.effect("injects explicitly selected skills into Grok ACP prompts", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-skill-invocation"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-skill-")), + ); + const workspace = NodePath.join(tempDir, "workspace"); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const skillPath = NodePath.join(workspace, ".agents", "skills", "review", "SKILL.md"); + const inspectJson = yield* encodeInspectJson({ + skills: [ + { + name: "review", + description: "Review the change.", + source: { type: "project", path: skillPath }, + userInvocable: true, + }, + ], + }); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(NodePath.dirname(skillPath), { recursive: true }); + await NodeFSP.writeFile( + skillPath, + "---\nname: review\ndescription: Review the change.\n---\n\n# Review checklist", + "utf8", + ); + await NodeFSP.writeFile(requestLogPath, "", "utf8"); + }); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ T3_ACP_REQUEST_LOG_PATH: requestLogPath }, inspectJson), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: workspace, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, + }); + yield* adapter.sendTurn({ + threadId, + input: "Use $review to inspect this change.", + skillInvocations: [{ name: "review", start: 4, end: 11 }], + attachments: [], + }); + + const requests = yield* waitForFileContent(requestLogPath, 40, "# Review checklist"); + assert.include(requests, "# Review checklist"); + assert.notInclude(requests, "$review"); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("closes the ACP child process when a session stops", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-stop-session-close"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index d0b704b93d15..539288530493 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -5,6 +5,7 @@ import { type ProviderApprovalDecision, type ProviderRuntimeEvent, type ProviderSession, + type ServerProviderSkill, type ProviderUserInputAnswers, ProviderDriverKind, ProviderInstanceId, @@ -59,6 +60,7 @@ import { } from "../acp/AcpCoreRuntimeEvents.ts"; import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, @@ -80,6 +82,7 @@ import { } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { renderProviderSkillPrompt, loadInvokedSkills } from "../skillInvocations.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -131,6 +134,7 @@ interface GrokSessionContext { readonly threadId: ThreadId; readonly acpSessionId: string; session: ProviderSession; + readonly skills: ReadonlyArray; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; notificationFiber: Fiber.Fiber | undefined; @@ -370,6 +374,10 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte : DEFAULT_GROK_ACTIVE_TOOL_INACTIVITY_TIMEOUT_MS; const activeToolInactivityTimeoutNanos = BigInt(activeToolInactivityTimeoutMs) * NANOS_PER_MILLI; + const discoverSkills = (cwd?: string) => + discoverGrokSkills(grokSettings, options?.environment ?? hostEnvironment, cwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const randomUUIDv4 = crypto.randomUUIDv4.pipe( @@ -955,6 +963,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const cwd = path.resolve(input.cwd.trim()); + const skills = yield* discoverSkills(cwd); const grokModelSelection = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const existing = sessions.get(input.threadId); @@ -1266,6 +1275,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte threadId: input.threadId, acpSessionId: started.sessionId, session, + skills, scope: sessionScope, acp, notificationFiber: undefined, @@ -1504,6 +1514,33 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); const text = input.input?.trim(); + let promptText = text; + if (text && input.skillInvocations && input.skillInvocations.length > 0) { + const resolved = yield* loadInvokedSkills({ + provider: PROVIDER, + providerLabel: "Grok", + prompt: text, + invocations: input.skillInvocations, + skills: ctx.skills, + readFile: (skillPath) => + fileSystem.readFileString(skillPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Failed to read Grok skill '${skillPath}'.`, + cause, + }), + ), + ), + }); + promptText = renderProviderSkillPrompt( + text, + resolved.documents, + resolved.invocations, + ); + } const imagePromptParts = yield* Effect.forEach( input.attachments ?? [], (attachment) => @@ -1538,7 +1575,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }), ); const promptParts: Array = [ - ...(text ? [{ type: "text" as const, text }] : []), + ...(promptText ? [{ type: "text" as const, text: promptText }] : []), ...imagePromptParts, ]; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..d1133b0511df 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -64,6 +64,8 @@ const runtimeMock = { closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, promptCalls: [] as Array, + skillCalls: [] as Array<{ directory?: string }>, + skills: [] as Array<{ name: string; location: string; content: string }>, promptAsyncError: null as Error | null, closeError: null as Error | null, messages: [] as MessageEntry[], @@ -84,6 +86,8 @@ const runtimeMock = { this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; this.state.promptCalls.length = 0; + this.state.skillCalls.length = 0; + this.state.skills = []; this.state.promptAsyncError = null; this.state.closeError = null; this.state.messages = []; @@ -203,6 +207,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { : runtimeMock.state.messages; }, }, + app: { + skills: async (input?: { directory?: string }) => { + runtimeMock.state.skillCalls.push(input?.directory ? { directory: input.directory } : {}); + return { data: runtimeMock.state.skills }; + }, + }, event: { subscribe: async () => ({ stream: (async function* () { @@ -388,6 +398,45 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("injects explicitly selected skills into OpenCode prompts", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-skill"); + runtimeMock.state.skills = [ + { + name: "review", + location: "/workspace/.agents/skills/review/SKILL.md", + content: "---\nname: review\n---\n\n# Review checklist", + }, + ]; + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + cwd: "/workspace", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId, + input: "Use $review to inspect this change.", + skillInvocations: [{ name: "review", start: 4, end: 11 }], + modelSelection: createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), + }); + + const prompt = runtimeMock.state.promptCalls.at(-1) as { + parts?: Array<{ type?: string; text?: string }>; + }; + const promptText = prompt.parts?.find((part) => part.type === "text")?.text ?? ""; + NodeAssert.equal(runtimeMock.state.skillCalls.length, 1); + NodeAssert.equal(runtimeMock.state.skillCalls[0]?.directory, "/workspace"); + NodeAssert.equal(promptText.includes("# Review checklist"), true); + NodeAssert.equal(promptText.includes("[T3 explicitly invoked skill: review]"), true); + NodeAssert.equal(promptText.includes("$review"), false); + + yield* adapter.stopSession(threadId); + }), + ); + it.effect("falls back to a fresh session when the persisted session is gone", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..4c9e9c78b962 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1,5 +1,6 @@ import { EventId, + type ProviderSendTurnInput, type OpenCodeSettings, ProviderDriverKind, ProviderInstanceId, @@ -51,6 +52,7 @@ import { toOpenCodeQuestionAnswers, type OpenCodeServerConnection, } from "../opencodeRuntime.ts"; +import { renderProviderSkillPrompt, loadInvokedSkills } from "../skillInvocations.ts"; import * as Option from "effect/Option"; const PROVIDER = ProviderDriverKind.make("opencode"); @@ -221,8 +223,15 @@ function isOpenCodeDefaultTitle(title: string): boolean { return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } +interface OpenCodeSkillSnapshot { + readonly name: string; + readonly location: string; + readonly content: string; +} + interface OpenCodeSessionContext { session: ProviderSession; + readonly skills: ReadonlyArray; readonly client: OpencodeClient; readonly server: OpenCodeServerConnection; readonly directory: string; @@ -603,6 +612,31 @@ export function makeOpenCodeAdapter( options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const runtimeEvents = yield* Queue.unbounded(); const sessions = new Map(); + const prepareSkillPrompt = ( + context: OpenCodeSessionContext, + text: string | undefined, + invocations: ProviderSendTurnInput["skillInvocations"], + ) => + Effect.gen(function* () { + if (!text || !invocations || invocations.length === 0) { + return text; + } + + const skillsByPath = new Map(context.skills.map((skill) => [skill.location, skill])); + const resolved = yield* loadInvokedSkills({ + provider: PROVIDER, + providerLabel: "OpenCode", + prompt: text, + invocations, + skills: context.skills.map((skill) => ({ + name: skill.name, + path: skill.location, + enabled: true, + })), + readFile: (skillPath) => Effect.succeed(skillsByPath.get(skillPath)?.content ?? ""), + }); + return renderProviderSkillPrompt(text, resolved.documents, resolved.invocations); + }); const randomUUIDv4 = crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => @@ -1367,6 +1401,10 @@ export function makeOpenCodeAdapter( } const createdAt = yield* nowIso; + const skillResponse = yield* runOpenCodeSdk("app.skills", () => + started.client.app.skills({ directory }), + ).pipe(Effect.mapError(toRequestError)); + const skills = (skillResponse.data ?? []) satisfies ReadonlyArray; const session: ProviderSession = { provider: PROVIDER, providerInstanceId: boundInstanceId, @@ -1388,6 +1426,7 @@ export function makeOpenCodeAdapter( const context: OpenCodeSessionContext = { session, + skills, client: started.client, server: started.server, directory, @@ -1471,6 +1510,7 @@ export function makeOpenCodeAdapter( issue: "OpenCode turns require text input or at least one attachment.", }); } + const promptText = yield* prepareSkillPrompt(context, text, input.skillInvocations); const agent = getModelSelectionStringOptionValue(modelSelection, "agent"); const variant = getModelSelectionStringOptionValue(modelSelection, "variant"); @@ -1505,7 +1545,10 @@ export function makeOpenCodeAdapter( model: parsedModel, ...(context.activeAgent ? { agent: context.activeAgent } : {}), ...(context.activeVariant ? { variant: context.activeVariant } : {}), - parts: [...(text ? [{ type: "text" as const, text }] : []), ...fileParts], + parts: [ + ...(promptText ? [{ type: "text" as const, text: promptText }] : []), + ...fileParts, + ], }), ).pipe( Effect.mapError(toRequestError), diff --git a/apps/server/src/provider/Layers/codexSkillInvocations.test.ts b/apps/server/src/provider/Layers/codexSkillInvocations.test.ts new file mode 100644 index 000000000000..0a716a82a074 --- /dev/null +++ b/apps/server/src/provider/Layers/codexSkillInvocations.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { bindCodexSkillInvocations } from "./codexSkillInvocations.ts"; + +const grillWithDocs = { + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + enabled: true, +}; + +describe("bindCodexSkillInvocations", () => { + it("attaches a selected skill as structured Codex input", () => { + expect( + bindCodexSkillInvocations( + "Use $grill-with-docs please", + [{ name: "grill-with-docs", start: 4, end: 20 }], + [grillWithDocs], + ), + ).toEqual({ + ok: true, + inputs: [{ type: "skill", name: "grill-with-docs", path: grillWithDocs.path }], + }); + }); + + it("allows explicitly selected Codex skills disabled for model invocation", () => { + expect( + bindCodexSkillInvocations( + "$grill-with-docs go", + [{ name: "grill-with-docs", start: 0, end: 16 }], + [{ ...grillWithDocs, enabled: false }], + ), + ).toEqual({ + ok: true, + inputs: [{ type: "skill", name: "grill-with-docs", path: grillWithDocs.path }], + }); + }); + + it("does not infer invocations from ordinary dollar text", () => { + expect(bindCodexSkillInvocations("check $HOME/.config", [], [grillWithDocs])).toEqual({ + ok: true, + inputs: [], + }); + }); + + it("rejects unknown and stale invocation metadata", () => { + expect( + bindCodexSkillInvocations( + "$missing-skill do this", + [{ name: "missing-skill", start: 0, end: 14 }], + [grillWithDocs], + ), + ).toEqual({ ok: false, names: ["missing-skill"] }); + expect( + bindCodexSkillInvocations( + "$grill-with-docs go", + [{ name: "grill-with-docs", start: 1, end: 17 }], + [grillWithDocs], + ), + ).toEqual({ ok: false, names: ["grill-with-docs"] }); + }); + + it("deduplicates selected skills by path", () => { + expect( + bindCodexSkillInvocations( + "$grill-with-docs then $grill-with-docs", + [ + { name: "grill-with-docs", start: 0, end: 16 }, + { name: "grill-with-docs", start: 22, end: 38 }, + ], + [grillWithDocs], + ), + ).toEqual({ + ok: true, + inputs: [{ type: "skill", name: "grill-with-docs", path: grillWithDocs.path }], + }); + }); +}); diff --git a/apps/server/src/provider/Layers/codexSkillInvocations.ts b/apps/server/src/provider/Layers/codexSkillInvocations.ts new file mode 100644 index 000000000000..6e7dd55b1f1e --- /dev/null +++ b/apps/server/src/provider/Layers/codexSkillInvocations.ts @@ -0,0 +1,40 @@ +import type { ExplicitSkillInvocation, ServerProviderSkill } from "@t3tools/contracts"; +import { resolveProviderSkillInvocations } from "../skillInvocations.ts"; + +export type CodexSkillUserInput = { + readonly type: "skill"; + readonly name: string; + readonly path: string; +}; + +export type BindCodexSkillInvocationsResult = + | { readonly ok: true; readonly inputs: ReadonlyArray } + | { readonly ok: false; readonly names: readonly string[] }; + +export function bindCodexSkillInvocations( + prompt: string, + invocations: ReadonlyArray, + skills: ReadonlyArray>, +): BindCodexSkillInvocationsResult { + if (invocations.length === 0) { + return { ok: true, inputs: [] }; + } + + // Codex reports skills disabled for model invocation as disabled. Users can + // still invoke those skills explicitly. + const resolution = resolveProviderSkillInvocations(prompt, invocations, skills, { + allowDisabled: true, + }); + const failedNames = [...new Set([...resolution.invalidNames, ...resolution.unknownNames])]; + if (failedNames.length > 0) { + return { ok: false, names: failedNames }; + } + return { + ok: true, + inputs: resolution.references.map((skill) => ({ + type: "skill", + name: skill.name, + path: skill.path, + })), + }; +} diff --git a/apps/server/src/provider/skillInvocations.test.ts b/apps/server/src/provider/skillInvocations.test.ts new file mode 100644 index 000000000000..46527a235165 --- /dev/null +++ b/apps/server/src/provider/skillInvocations.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { renderProviderSkillPrompt, resolveProviderSkillInvocations } from "./skillInvocations.ts"; + +const reviewSkill = { + name: "review", + path: "/workspace/.agents/skills/review/SKILL.md", + enabled: true, +}; + +describe("resolveProviderSkillInvocations", () => { + it("resolves only explicit composer selections", () => { + const prompt = "Use $review, then inspect $HOME/.config."; + const invocation = { name: "review", start: 4, end: 11 }; + + expect(resolveProviderSkillInvocations(prompt, [invocation], [reviewSkill])).toEqual({ + references: [reviewSkill], + invocations: [invocation], + unknownNames: [], + invalidNames: [], + }); + }); + + it("reports unknown, disabled, and invalid explicit invocations", () => { + expect( + resolveProviderSkillInvocations( + "$missing $review", + [ + { name: "missing", start: 0, end: 8 }, + { name: "review", start: 10, end: 17 }, + ], + [{ ...reviewSkill, enabled: false }], + ), + ).toEqual({ + references: [], + invocations: [], + unknownNames: ["missing"], + invalidNames: ["review"], + }); + }); +}); + +describe("renderProviderSkillPrompt", () => { + it("replaces selected ranges and includes the skill location and document", () => { + const rendered = renderProviderSkillPrompt( + "Use $review to inspect $HOME.", + [ + { + ...reviewSkill, + contents: "---\nname: review\n---\n\n# Review checklist", + }, + ], + [{ name: "review", start: 4, end: 11 }], + ); + + expect(rendered).toContain( + "file: /workspace/.agents/skills/review/SKILL.md\n---\nname: review\n---\n\n# Review checklist", + ); + expect(rendered).toContain("Use [T3 explicitly invoked skill: review] to inspect $HOME."); + }); +}); diff --git a/apps/server/src/provider/skillInvocations.ts b/apps/server/src/provider/skillInvocations.ts new file mode 100644 index 000000000000..0b8c2e4193d1 --- /dev/null +++ b/apps/server/src/provider/skillInvocations.ts @@ -0,0 +1,182 @@ +import type { + ExplicitSkillInvocation, + ProviderDriverKind, + ServerProviderSkill, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { ProviderAdapterValidationError } from "./Errors.ts"; + +export type ProviderSkillReference = Pick; + +export interface ProviderSkillDocument { + readonly name: string; + readonly path: string; + readonly contents: string; +} + +export interface ProviderSkillInvocationResolution { + readonly references: ReadonlyArray; + readonly invocations: ReadonlyArray; + readonly unknownNames: ReadonlyArray; + readonly invalidNames: ReadonlyArray; +} + +export function replaceExplicitSkillInvocations( + prompt: string, + invocations: ReadonlyArray, + replace: (name: string) => string, +): string { + let result = prompt; + for (const invocation of invocations.toReversed()) { + result = `${result.slice(0, invocation.start)}${replace(invocation.name)}${result.slice(invocation.end)}`; + } + return result; +} + +export function loadProviderSkillDocuments( + references: ReadonlyArray, + readFile: (path: string) => Effect.Effect, +): Effect.Effect, E> { + return Effect.forEach(references, (reference) => + readFile(reference.path).pipe( + Effect.map( + (contents): ProviderSkillDocument => ({ + name: reference.name, + path: reference.path, + contents, + }), + ), + ), + ); +} + +export function findProviderSkill( + name: string, + skills: ReadonlyArray, + options?: { readonly allowDisabled?: boolean }, +): ProviderSkillReference | undefined { + const candidates = options?.allowDisabled ? skills : skills.filter((skill) => skill.enabled); + const exact = candidates.find((skill) => skill.name === name); + if (exact) { + return exact; + } + + const lowerName = name.toLowerCase(); + return candidates.find((skill) => skill.name.toLowerCase() === lowerName); +} + +export function resolveProviderSkillInvocations( + prompt: string, + invocations: ReadonlyArray, + skills: ReadonlyArray, + options?: { readonly allowDisabled?: boolean }, +): ProviderSkillInvocationResolution { + const references: ProviderSkillReference[] = []; + const validInvocations: ExplicitSkillInvocation[] = []; + const unknownNames: string[] = []; + const invalidNames: string[] = []; + const seenPaths = new Set(); + let previousEnd = 0; + + for (const invocation of invocations) { + const expectedToken = `$${invocation.name}`; + const validRange = + invocation.start >= previousEnd && + invocation.end > invocation.start && + prompt.slice(invocation.start, invocation.end) === expectedToken; + if (!validRange) { + if (!invalidNames.includes(invocation.name)) { + invalidNames.push(invocation.name); + } + continue; + } + previousEnd = invocation.end; + + const skill = findProviderSkill(invocation.name, skills, options); + if (!skill) { + if (!unknownNames.includes(invocation.name)) { + unknownNames.push(invocation.name); + } + continue; + } + validInvocations.push(invocation); + if (!seenPaths.has(skill.path)) { + seenPaths.add(skill.path); + references.push(skill); + } + } + + return { references, invocations: validInvocations, unknownNames, invalidNames }; +} + +export function loadInvokedSkills(input: { + readonly provider: ProviderDriverKind; + readonly providerLabel: string; + readonly prompt: string; + readonly invocations: ReadonlyArray; + readonly skills: ReadonlyArray; + readonly readFile: (path: string) => Effect.Effect; +}): Effect.Effect< + { + readonly documents: ReadonlyArray; + readonly invocations: ReadonlyArray; + }, + E | ProviderAdapterValidationError +> { + const resolution = resolveProviderSkillInvocations(input.prompt, input.invocations, input.skills); + if (resolution.invalidNames.length > 0) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: input.provider, + operation: "sendTurn", + issue: `Invalid explicit ${input.providerLabel} skill invocation metadata for: ${resolution.invalidNames.map((name) => `$${name}`).join(", ")}.`, + }), + ); + } + if (resolution.unknownNames.length > 0) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: input.provider, + operation: "sendTurn", + issue: `Unknown ${input.providerLabel} skill${resolution.unknownNames.length === 1 ? "" : "s"}: ${resolution.unknownNames.map((name) => `$${name}`).join(", ")}.`, + }), + ); + } + return loadProviderSkillDocuments(resolution.references, input.readFile).pipe( + Effect.map((documents) => ({ documents, invocations: resolution.invocations })), + ); +} + +/** + * Makes a provider-neutral skill document part of the turn without leaving a + * `$skill` token for the provider to reinterpret as a model-invoked tool. + */ +export function renderProviderSkillPrompt( + prompt: string, + documents: ReadonlyArray, + invocations: ReadonlyArray, +): string { + if (documents.length === 0) { + return prompt; + } + + const documentsByName = new Map(); + for (const document of documents) { + documentsByName.set(document.name, document); + documentsByName.set(document.name.toLowerCase(), document); + } + + const promptWithoutTokens = replaceExplicitSkillInvocations(prompt, invocations, (name) => { + const document = documentsByName.get(name) ?? documentsByName.get(name.toLowerCase()); + return document ? `[T3 explicitly invoked skill: ${document.name}]` : `$${name}`; + }); + const skillContext = documents + .map( + (document) => + `\nname: ${document.name}\nfile: ${document.path}\n${document.contents.trim()}\n`, + ) + .join("\n\n"); + + return `The user explicitly invoked the skills below. Follow their instructions. Resolve relative file references from the parent directory of each skill file.\n\n${skillContext}\n\n${promptWithoutTokens}`; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0188af478c0..8198f67aed71 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -135,6 +135,7 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; +import { remapExplicitSkillInvocations } from "@t3tools/shared/explicitSkillInvocations"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { @@ -557,6 +558,7 @@ function formatOutgoingPrompt(params: { const promptEffort = resolvePromptInjectedEffort(caps, params.effort); return applyClaudePromptEffortPrefix(params.text, promptEffort); } + const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -5391,6 +5393,7 @@ function ChatViewContent(props: ChatViewProps) { selectedProviderModels: ctxSelectedProviderModels, selectedPromptEffort: ctxSelectedPromptEffort, selectedModelSelection: ctxSelectedModelSelection, + skillInvocations: composerSkillInvocations, } = sendCtx; const composerImages = directAnnotation?.image && @@ -5412,7 +5415,7 @@ function ChatViewContent(props: ChatViewProps) { }, ] : sendContextPreviewAnnotations; - const promptForSend = promptRef.current; + const promptForSend = sendCtx.prompt; const { trimmedPrompt: trimmed, sendableTerminalContexts: sendableComposerTerminalContexts, @@ -5630,6 +5633,11 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, }); + const outgoingSkillInvocations = remapExplicitSkillInvocations({ + sourceText: messageTextForSend, + outgoingText: outgoingMessageText, + invocations: composerSkillInvocations, + }); if (composerRef.current?.validateProviderInput(outgoingMessageText) === false) { return; } @@ -5865,6 +5873,9 @@ function ChatViewContent(props: ChatViewProps) { role: "user", text: outgoingMessageText, attachments: turnAttachmentsResult.value, + ...(outgoingSkillInvocations.length > 0 + ? { skillInvocations: outgoingSkillInvocations } + : {}), }, modelSelection: ctxSelectedModelSelection, titleSeed: title, diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 15d31c7323b0..8565b251d276 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -5,7 +5,7 @@ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; -import { type ServerProviderSkill } from "@t3tools/contracts"; +import { type ExplicitSkillInvocation, type ServerProviderSkill } from "@t3tools/contracts"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { $applyNodeReplacement, @@ -865,6 +865,17 @@ function collectTerminalContextIds(node: LexicalNode): string[] { return []; } +function collectExplicitSkillInvocations(node: LexicalNode): ExplicitSkillInvocation[] { + if (node instanceof ComposerSkillNode) { + const start = getExpandedAbsoluteOffsetForPoint(node, 0); + return [{ name: node.__skillName, start, end: start + node.getTextContentSize() }]; + } + if ($isElementNode(node)) { + return node.getChildren().flatMap((child) => collectExplicitSkillInvocations(child)); + } + return []; +} + export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; @@ -874,6 +885,7 @@ export interface ComposerPromptEditorHandle { cursor: number; expandedCursor: number; terminalContextIds: string[]; + skillInvocations: ExplicitSkillInvocation[]; }; } @@ -1553,6 +1565,7 @@ function ComposerPromptEditorInner({ cursor: initialCursor, expandedCursor: expandCollapsedComposerCursor(value, initialCursor), terminalContextIds: terminalContexts.map((context) => context.id), + skillInvocations: [] as ExplicitSkillInvocation[], }); const isApplyingControlledUpdateRef = useRef(false); const terminalContextActions = useMemo( @@ -1591,6 +1604,7 @@ function ComposerPromptEditorInner({ cursor: normalizedCursor, expandedCursor: expandCollapsedComposerCursor(value, normalizedCursor), terminalContextIds: terminalContexts.map((context) => context.id), + skillInvocations: [], }; terminalContextsSignatureRef.current = terminalContextsSignature; skillsSignatureRef.current = skillsSignature; @@ -1631,6 +1645,7 @@ function ComposerPromptEditorInner({ cursor: boundedCursor, expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), terminalContextIds: snapshotRef.current.terminalContextIds, + skillInvocations: snapshotRef.current.skillInvocations, }; onChangeRef.current( snapshotRef.current.value, @@ -1648,6 +1663,7 @@ function ComposerPromptEditorInner({ cursor: number; expandedCursor: number; terminalContextIds: string[]; + skillInvocations: ExplicitSkillInvocation[]; } => { let snapshot = snapshotRef.current; editor.getEditorState().read(() => { @@ -1666,11 +1682,13 @@ function ComposerPromptEditorInner({ $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const terminalContextIds = collectTerminalContextIds($getRoot()); + const skillInvocations = collectExplicitSkillInvocations($getRoot()); snapshot = { value: nextValue, cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, + skillInvocations, }; }); snapshotRef.current = snapshot; @@ -1714,13 +1732,23 @@ function ComposerPromptEditorInner({ $readExpandedSelectionOffsetFromEditorState(fallbackExpandedCursor), ); const terminalContextIds = collectTerminalContextIds($getRoot()); + const skillInvocations = collectExplicitSkillInvocations($getRoot()); const previousSnapshot = snapshotRef.current; if ( previousSnapshot.value === nextValue && previousSnapshot.cursor === nextCursor && previousSnapshot.expandedCursor === nextExpandedCursor && previousSnapshot.terminalContextIds.length === terminalContextIds.length && - previousSnapshot.terminalContextIds.every((id, index) => id === terminalContextIds[index]) + previousSnapshot.terminalContextIds.every( + (id, index) => id === terminalContextIds[index], + ) && + previousSnapshot.skillInvocations.length === skillInvocations.length && + previousSnapshot.skillInvocations.every( + (invocation, index) => + invocation.name === skillInvocations[index]?.name && + invocation.start === skillInvocations[index]?.start && + invocation.end === skillInvocations[index]?.end, + ) ) { return; } @@ -1732,6 +1760,7 @@ function ComposerPromptEditorInner({ cursor: nextCursor, expandedCursor: nextExpandedCursor, terminalContextIds, + skillInvocations, }; const cursorAdjacentToMention = isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6d0ca8a765ba..a5ff8a6b697e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,6 +1,7 @@ import type { ApprovalRequestId, EnvironmentId, + ExplicitSkillInvocation, ModelSelection, PreviewAnnotationPayload, ProviderApprovalDecision, @@ -519,6 +520,7 @@ export interface ChatComposerHandle { cursor: number; expandedCursor: number; terminalContextIds: string[]; + skillInvocations: ExplicitSkillInvocation[]; }; /** Reset composer cursor/trigger/highlight after external prompt mutations (e.g. onSend). */ resetCursorState: (options?: { @@ -543,6 +545,7 @@ export interface ChatComposerHandle { selectedProvider: ProviderDriverKind; selectedModel: string; selectedProviderModels: ReadonlyArray; + skillInvocations: ExplicitSkillInvocation[]; }; /** Validate the fully composed text immediately before a provider turn starts. */ validateProviderInput: (providerInput: string) => boolean; @@ -1767,6 +1770,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursor: number; expandedCursor: number; terminalContextIds: string[]; + skillInvocations: ExplicitSkillInvocation[]; } => { const editorSnapshot = composerEditorRef.current?.readSnapshot(); if (editorSnapshot) { @@ -1777,6 +1781,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursor: composerCursor, expandedCursor: expandCollapsedComposerCursor(promptRef.current, composerCursor), terminalContextIds: composerTerminalContexts.map((context) => context.id), + skillInvocations: [], }; }, [composerCursor, composerTerminalContexts, promptRef]); @@ -2883,21 +2888,25 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(nextCollapsedCursor); }); }, - getSendContext: () => ({ - prompt: promptRef.current, - images: composerImagesRef.current, - terminalContexts: composerTerminalContextsRef.current, - elementContexts: composerElementContextsRef.current, - previewAnnotations: composerPreviewAnnotations, - reviewComments: composerReviewComments, - selectedPromptEffort, - selectedModelOptionsForDispatch, - selectedModelSelection, - providerAvailable: !noProviderAvailable, - selectedProvider, - selectedModel, - selectedProviderModels, - }), + getSendContext: () => { + const snapshot = composerEditorRef.current?.readSnapshot(); + return { + prompt: snapshot?.value ?? promptRef.current, + images: composerImagesRef.current, + terminalContexts: composerTerminalContextsRef.current, + elementContexts: composerElementContextsRef.current, + previewAnnotations: composerPreviewAnnotations, + reviewComments: composerReviewComments, + selectedPromptEffort, + selectedModelOptionsForDispatch, + selectedModelSelection, + providerAvailable: !noProviderAvailable, + selectedProvider, + selectedModel, + selectedProviderModels, + skillInvocations: snapshot?.skillInvocations ?? [], + }; + }, validateProviderInput: (providerInput: string) => { const validationMessage = getComposerSubmissionValidationMessage({ prompt: promptRef.current, diff --git a/docs/user/composer.md b/docs/user/composer.md index 35d634556d88..6554500fef9e 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -17,11 +17,22 @@ such as System, Personal, Project, or App. By default, the `/` menu includes skills. To keep this menu command-only, turn off **Show skills in slash menu** in **Settings → General**. Skill results use the `/skill:Skill Name` label and add the -same `$name` skill token to your message. The original skill name remains searchable. If the provider -also reports that skill as a native slash command, T3 Code hides the duplicate native entry and keeps -the `/skill:Skill Name` label. +same `$name` skill chip to your message. When you send the message, T3 passes the selected skill +explicitly to the provider. Typing ordinary dollar-prefixed text does not invoke a skill. If the +provider also reports that skill as a native slash command, T3 Code hides the duplicate native entry +and keeps the `/skill:Skill Name` label. On desktop, press `Cmd+Enter` on macOS or `Ctrl+Enter` on Windows and Linux from a new thread to start it in the background. T3 Code opens another new thread and shows an **Open** action for the thread that started. The new thread keeps the selected workspace mode and base branch. If **New worktree** is selected, each background thread creates its own worktree. + +## Cursor skills + +When a thread uses Cursor, type `$` in the composer to search the skills T3 Code found. Choose a +skill to add it to your message. T3 Code passes the selected skill's instructions to Cursor +explicitly, including when several skills are selected in one message. + +T3 Code finds skills stored in your project or user skill folders. Cursor-managed built-in, +marketplace, and plugin skills are not shown because Cursor does not make that list available to +T3 Code. diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 52b893f39c3b..1aca4c71f173 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -822,6 +822,18 @@ it.effect("decodes thread.turn-start-requested source proposed plan metadata whe }), ); +it.effect("decodes explicit skill invocation metadata when present", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartRequestedPayload({ + threadId: "thread-2", + messageId: "msg-2", + skillInvocations: [{ name: "review", start: 4, end: 11 }], + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.deepStrictEqual(parsed.skillInvocations, [{ name: "review", start: 4, end: 11 }]); + }), +); + it.effect("decodes thread.turn-start-requested title seed when present", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartRequestedPayload({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 682d65fda8ac..bab45ceb8a51 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -207,6 +207,22 @@ export type ChatAttachment = typeof ChatAttachment.Type; const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); export type UploadChatAttachment = typeof UploadChatAttachment.Type; +/** A skill chip the user explicitly selected in the composer. Offsets refer to the sent text. */ +export const ExplicitSkillInvocation = Schema.Struct({ + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + start: NonNegativeInt, + end: NonNegativeInt, +}).check( + Schema.makeFilter( + (input: { readonly start: number; readonly end: number }) => + input.end > input.start || "Skill invocation end must be greater than start.", + ), +); +export type ExplicitSkillInvocation = typeof ExplicitSkillInvocation.Type; +export const ExplicitSkillInvocations = Schema.Array(ExplicitSkillInvocation).check( + Schema.isMaxLength(256), +); + export const ProjectScriptIcon = Schema.Literals([ "play", "test", @@ -861,6 +877,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ role: Schema.Literal("user"), text: Schema.String, attachments: Schema.Array(ChatAttachment), + skillInvocations: Schema.optional(ExplicitSkillInvocations), }), modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), @@ -882,6 +899,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ role: Schema.Literal("user"), text: Schema.String, attachments: Schema.Array(Schema.Union([UploadChatAttachment, ChatAttachment])), + skillInvocations: Schema.optional(ExplicitSkillInvocations), }), modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), @@ -1278,6 +1296,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, + skillInvocations: Schema.optional(ExplicitSkillInvocations), modelSelection: Schema.optional(ModelSelection), titleSeed: Schema.optional(TrimmedNonEmptyString), runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_RUNTIME_MODE))), diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index ba7ca63745b6..dff4069ff8dd 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -121,6 +121,16 @@ describe("ProviderSessionStartInput", () => { }); describe("ProviderSendTurnInput", () => { + it("accepts explicit skill invocation ranges", () => { + const parsed = decodeProviderSendTurnInput({ + threadId: "thread-1", + input: "Use $review", + skillInvocations: [{ name: "review", start: 4, end: 11 }], + }); + + expect(parsed.skillInvocations).toEqual([{ name: "review", start: 4, end: 11 }]); + }); + it("accepts codex modelSelection", () => { const parsed = decodeProviderSendTurnInput({ threadId: "thread-1", diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 42a943923037..e0ee33f5e1dc 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -10,6 +10,7 @@ import { } from "./baseSchemas.ts"; import { ChatAttachment, + ExplicitSkillInvocations, ModelSelection, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, @@ -73,6 +74,7 @@ export const ProviderSendTurnInput = Schema.Struct({ attachments: Schema.optional( Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), ), + skillInvocations: Schema.optional(ExplicitSkillInvocations), modelSelection: Schema.optional(ModelSelection), interactionMode: Schema.optional(ProviderInteractionMode), }); diff --git a/packages/shared/package.json b/packages/shared/package.json index eeaa5f59e087..24bb63160b83 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -175,6 +175,10 @@ "types": "./src/composerInlineTokens.ts", "import": "./src/composerInlineTokens.ts" }, + "./explicitSkillInvocations": { + "types": "./src/explicitSkillInvocations.ts", + "import": "./src/explicitSkillInvocations.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" diff --git a/packages/shared/src/explicitSkillInvocations.test.ts b/packages/shared/src/explicitSkillInvocations.test.ts new file mode 100644 index 000000000000..4ef8ffe5abf8 --- /dev/null +++ b/packages/shared/src/explicitSkillInvocations.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + remapExplicitSkillInvocations, + updateExplicitSkillInvocationsForTextEdit, +} from "./explicitSkillInvocations.ts"; + +describe("explicit skill invocation ranges", () => { + it("remaps ranges through trimming and an outgoing prefix", () => { + expect( + remapExplicitSkillInvocations({ + sourceText: " $review this ", + outgoingText: "Think carefully.\n\n$review this", + invocations: [{ name: "review", start: 2, end: 9 }], + }), + ).toEqual([{ name: "review", start: 18, end: 25 }]); + }); + + it("drops stale ranges instead of invoking matching names elsewhere", () => { + expect( + remapExplicitSkillInvocations({ + sourceText: "$review this", + outgoingText: "$review this", + invocations: [{ name: "review", start: 1, end: 8 }], + }), + ).toEqual([]); + }); + + it("moves untouched ranges and drops edited tokens", () => { + const moved = updateExplicitSkillInvocationsForTextEdit({ + previousText: "$review then $test", + nextText: "please $review then $test", + invocations: [ + { name: "review", start: 0, end: 7 }, + { name: "test", start: 13, end: 18 }, + ], + }); + expect(moved).toEqual([ + { name: "review", start: 7, end: 14 }, + { name: "test", start: 20, end: 25 }, + ]); + expect( + updateExplicitSkillInvocationsForTextEdit({ + previousText: "please $review then $test", + nextText: "please $review then $best", + invocations: moved, + }), + ).toEqual([{ name: "review", start: 7, end: 14 }]); + }); +}); diff --git a/packages/shared/src/explicitSkillInvocations.ts b/packages/shared/src/explicitSkillInvocations.ts new file mode 100644 index 000000000000..9a4b5ca2facc --- /dev/null +++ b/packages/shared/src/explicitSkillInvocations.ts @@ -0,0 +1,71 @@ +export interface ExplicitSkillInvocationRange { + readonly name: string; + readonly start: number; + readonly end: number; +} + +export function remapExplicitSkillInvocations(input: { + readonly sourceText: string; + readonly outgoingText: string; + readonly invocations: ReadonlyArray; +}): T[] { + if (input.invocations.length === 0) return []; + + const trimmedSource = input.sourceText.trim(); + const leadingTrim = input.sourceText.length - input.sourceText.trimStart().length; + const prefixLength = input.outgoingText.length - trimmedSource.length; + if (!input.outgoingText.endsWith(trimmedSource) || prefixLength < 0) return []; + + return input.invocations.flatMap((invocation) => { + if (input.sourceText.slice(invocation.start, invocation.end) !== `$${invocation.name}`) { + return []; + } + const start = invocation.start - leadingTrim + prefixLength; + const end = invocation.end - leadingTrim + prefixLength; + return start < prefixLength || end > input.outgoingText.length + ? [] + : [{ ...invocation, start, end }]; + }); +} + +export function updateExplicitSkillInvocationsForTextEdit< + T extends ExplicitSkillInvocationRange, +>(input: { + readonly previousText: string; + readonly nextText: string; + readonly invocations: ReadonlyArray; +}): T[] { + let prefixLength = 0; + while ( + prefixLength < input.previousText.length && + prefixLength < input.nextText.length && + input.previousText[prefixLength] === input.nextText[prefixLength] + ) { + prefixLength += 1; + } + + let suffixLength = 0; + while ( + suffixLength < input.previousText.length - prefixLength && + suffixLength < input.nextText.length - prefixLength && + input.previousText[input.previousText.length - 1 - suffixLength] === + input.nextText[input.nextText.length - 1 - suffixLength] + ) { + suffixLength += 1; + } + + const previousChangeEnd = input.previousText.length - suffixLength; + const delta = input.nextText.length - input.previousText.length; + return input.invocations.flatMap((invocation) => { + const candidate = + invocation.end <= prefixLength + ? invocation + : invocation.start >= previousChangeEnd + ? { ...invocation, start: invocation.start + delta, end: invocation.end + delta } + : null; + return candidate && + input.nextText.slice(candidate.start, candidate.end) === `$${candidate.name}` + ? [candidate] + : []; + }); +}