diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 00000000000..3023b55b403 --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,63 @@ +# Codex Workspace Skill Loading + +Fix Codex repo-local skill discovery in the composer by resolving skills for the active project/worktree cwd, instead of relying on the global provider status snapshot. + +Expected behavior: + +- Repo-local Codex skills for the active workspace appear in the `$` skill picker. +- The server exposes a workspace-aware `server.listProviderSkills` path and validates enabled Codex skill-listing requests against the requested cwd. +- The server routes skill listing through a bounded request lister that coalesces concurrent requests for the same provider/cwd, limits cross-workspace concurrency, and applies a short TTL only to successful lookups so reconnects or repeated composer renders do not repeatedly spawn Codex app-server probes while transient failures remain immediately retryable. +- The Codex provider requests `skills/list` with the current workspace cwd, times out hung app-server probes, and terminates the probe process when a timeout occurs. +- Provider skill-list failures preserve structured reason, operation, provider instance, normalized cwd, and bounded cause diagnostics for missing providers, invalid cwd, settings failures, Codex home preparation, probe timeouts, and probe failures while keeping stable user-facing messages. Raw thrown values are not sent directly to clients; the server keeps a small plain diagnostic shape so file paths, process output, and unexpected objects do not expand the wire payload. +- Non-Codex or disabled providers keep returning provider snapshot skills instead of failing workspace skill search. +- The client runtime keys provider-skill query state by environment, provider instance, and cwd, with a bounded stale window so reconnects refresh workspace-local skills without reusing another workspace's snapshot. Client-side fallback skill changes do not refresh the workspace query or respawn Codex skill probes. +- Web workspace-skill lookup follows the route environment's connection state. While disconnected, it does not start an RPC, retains only verified skills for the same environment/provider/cwd across lazy menu close/reopen cycles, otherwise falls back to provider snapshot skills with a non-pending reconnect error, and resumes the workspace refresh when the environment reconnects. +- The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. +- The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, including one at the end of the message, so an empty draft does not probe Codex merely to decorate nonexistent messages. +- The shared client-runtime policy also drives mobile thread composers and feeds. Mobile follows the selected environment's connection state, retains only verified same-workspace skills while disconnected or across lazy lookup close/reopen cycles, shows loading and structured reconnect/error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving failed refreshes or workspace switches. +- Mobile thread detail keeps workspace lookup lazy: it activates only while the composer `$` menu is active, when the draft contains a complete skill token, or when a visible sent user prompt contains a complete skill reference. +- New-task drafts request workspace skills lazily only after a complete `$skill` reference. Local drafts resolve the selected checkout or project root, while future-worktree drafts deliberately have no cwd and use provider snapshots until the target directory exists. + +Primary files: + +- `apps/server/src/ws.ts` +- `apps/server/src/provider/ProviderSkillsLister.ts` +- `apps/server/src/provider/Layers/CodexProvider.ts` +- `apps/web/src/components/ChatView.tsx` +- `apps/web/src/components/chat/ChatComposer.tsx` +- `apps/web/src/components/chat/MessagesTimeline.tsx` +- `apps/web/src/lib/providerWorkspaceSkillsState.ts` +- `apps/mobile/src/state/providerWorkspaceSkillsState.ts` +- `apps/mobile/src/features/threads/new-task-provider-skills.ts` +- `apps/mobile/src/features/threads/thread-composer-skill-items.ts` +- `packages/client-runtime/src/state/providerWorkspaceSkills.ts` +- `packages/contracts/src/server.ts` +- `packages/client-runtime/src/state/server.ts` + +Relevant tests live in: + +- `apps/server/src/server.test.ts` +- `apps/server/src/provider/ProviderSkillsLister.test.ts` +- `apps/server/src/provider/Layers/CodexProvider.test.ts` +- `apps/server/src/provider/Layers/CursorProvider.test.ts` +- `apps/server/src/provider/Layers/GrokProvider.test.ts` +- `apps/web/src/components/chat/MessagesTimeline.test.tsx` +- `apps/web/src/lib/providerWorkspaceSkillsState.test.ts` +- `apps/mobile/src/features/threads/new-task-provider-skills.test.ts` +- `apps/mobile/src/features/threads/thread-composer-skill-items.test.ts` +- `packages/client-runtime/src/state/providerWorkspaceSkills.test.ts` +- `packages/client-runtime/src/state/runtime.test.ts` + +Useful focused commands: + +```sh +(cd apps/server && pnpm exec vp test run --passWithNoTests src/provider/ProviderSkillsLister.test.ts src/provider/Layers/CodexProvider.test.ts src/provider/Layers/CursorProvider.test.ts src/provider/Layers/GrokProvider.test.ts) +(cd apps/web && pnpm exec vp test run --passWithNoTests --project unit src/lib/providerWorkspaceSkillsState.test.ts) +(cd apps/mobile && pnpm exec vp test run --passWithNoTests src/features/threads/new-task-provider-skills.test.ts src/features/threads/thread-composer-skill-items.test.ts) +(cd packages/client-runtime && pnpm exec vp test run --passWithNoTests src/state/providerWorkspaceSkills.test.ts) +``` + +## Development Ports + +- Web: `5735` +- Server/WebSocket: `13775` diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index d51b6c5d9ff..a60e67a2935 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -26,6 +26,7 @@ "./types": "./src/SelectableMarkdownText.types.ts" }, "peerDependencies": { + "@t3tools/shared": "workspace:*", "expo-asset": "*", "expo-clipboard": "*", "expo-haptics": "*", diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index dc84755cbbd..7ec1fc5068c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -1,4 +1,5 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; +import { parseInlineSkillTokens } from "@t3tools/shared/skillInlineTokens"; import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; @@ -184,8 +185,6 @@ function appendRun( return runs; } -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; - function formatSkillLabel(skill: SelectableMarkdownSkill): string { const displayName = skill.displayName?.trim(); if (displayName) { @@ -216,14 +215,13 @@ function decorateSkillRuns( let cursor = 0; let matched = false; - for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { - const prefix = match[1] ?? ""; - const name = match[2] ?? ""; + for (const match of parseInlineSkillTokens(run.text)) { + const { name } = match; const skill = skillByName.get(name); if (!skill) { continue; } - const start = (match.index ?? 0) + prefix.length; + const { start } = match; const end = start + name.length + 1; if (start > cursor) { decorated.push({ ...run, text: run.text.slice(cursor, start) }); diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index ccf6a307122..cb2d72ccdc6 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -42,6 +42,7 @@ interface ComposerCommandPopoverProps { readonly items: ReadonlyArray; readonly triggerKind: ComposerTriggerKind | null; readonly isLoading: boolean; + readonly error: string | null; readonly onSelect: (item: ComposerCommandItem) => void; } @@ -181,6 +182,13 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ) : null} + {props.error ? ( + + + {props.error} + + + ) : null} {props.items.length > 0 ? ( ))} - ) : ( + ) : props.error ? null : ( {emptyText(props.triggerKind, props.isLoading)} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 59af7199af7..eb5005fa92b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -7,6 +7,7 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + ServerProviderSkill, } from "@t3tools/contracts"; import { detectComposerTrigger, @@ -56,11 +57,6 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; -import { - insertRankedSearchResult, - normalizeSearchQuery, - scoreQueryMatch, -} from "@t3tools/shared/searchRanking"; import { applyProviderOptionMenuEvent, buildProviderOptionMenuActions, @@ -69,6 +65,7 @@ import { } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { buildComposerSkillItems } from "./thread-composer-skill-items"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -99,6 +96,9 @@ export interface ThreadComposerProps { readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; + readonly workspaceSkills: ReadonlyArray; + readonly workspaceSkillsIsPending: boolean; + readonly workspaceSkillsError: string | null; readonly queueCount: number; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; @@ -115,6 +115,7 @@ export interface ThreadComposerProps { readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; + readonly onWorkspaceSkillsLookupActiveChange?: (active: boolean) => void; } /** @@ -360,6 +361,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return detectComposerTrigger(props.draftMessage, composerSelection.end); }, [composerSelection, props.draftMessage]); + const workspaceSkillsLookupActive = composerTrigger?.kind === "skill"; + const { onWorkspaceSkillsLookupActiveChange } = props; + useEffect(() => { + onWorkspaceSkillsLookupActiveChange?.(workspaceSkillsLookupActive); + return () => onWorkspaceSkillsLookupActiveChange?.(false); + }, [onWorkspaceSkillsLookupActiveChange, workspaceSkillsLookupActive]); const pathSearch = useComposerPathSearch({ environmentId: props.environmentId, cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, @@ -412,86 +419,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); - const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { - trimLeadingPattern: /^\$+/, - }); - - if (!normalizedQuery) { - return enabledSkills.slice(0, 20).map((skill) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: skill.displayName ?? skill.name, - description: skill.shortDescription ?? skill.description ?? "", - })); - } - - const ranked: Array<{ - item: (typeof enabledSkills)[number]; - score: number; - tieBreaker: string; - }> = []; - for (const skill of enabledSkills) { - const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); - const scores = [ - scoreQueryMatch({ - value: skill.name.toLowerCase(), - query: normalizedQuery, - exactBase: 0, - prefixBase: 2, - boundaryBase: 4, - includesBase: 6, - fuzzyBase: 100, - boundaryMarkers: ["-", "_", "/"], - }), - scoreQueryMatch({ - value: displayLabel, - query: normalizedQuery, - exactBase: 1, - prefixBase: 3, - boundaryBase: 5, - includesBase: 7, - fuzzyBase: 110, - }), - scoreQueryMatch({ - value: skill.shortDescription?.toLowerCase() ?? "", - query: normalizedQuery, - exactBase: 20, - prefixBase: 22, - boundaryBase: 24, - includesBase: 26, - }), - scoreQueryMatch({ - value: skill.description?.toLowerCase() ?? "", - query: normalizedQuery, - exactBase: 30, - prefixBase: 32, - boundaryBase: 34, - includesBase: 36, - }), - ].filter((s): s is number => s !== null); - - if (scores.length > 0) { - insertRankedSearchResult( - ranked, - { - item: skill, - score: Math.min(...scores), - tieBreaker: `${displayLabel}\u0000${skill.name}`, - }, - 20, - ); - } - } - - return ranked.map(({ item: skill }) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: skill.displayName ?? skill.name, - description: skill.shortDescription ?? skill.description ?? "", - })); + return buildComposerSkillItems(props.workspaceSkills, composerTrigger.query); } if (composerTrigger.kind === "path") { @@ -509,7 +437,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, props.workspaceSkills, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -724,12 +652,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer layout={COMPOSER_LAYOUT_TRANSITION} style={{ maxWidth: props.contentMaxWidth }} > - {composerTrigger && composerMenuItems.length > 0 ? ( + {composerTrigger && (composerMenuItems.length > 0 || composerTrigger.kind === "skill") ? ( @@ -783,7 +716,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ref={inputRef} multiline value={props.draftMessage} - skills={selectedProviderStatus?.skills ?? []} + skills={props.workspaceSkills} selection={composerSelection} onChangeText={props.onChangeDraftMessage} onSelectionChange={handleSelectionChange} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5cb04290f66..fdfbefbc0ee 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -12,6 +12,7 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + ServerProviderSkill, ThreadId, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; @@ -41,6 +42,8 @@ import { } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { shouldLoadThreadProviderWorkspaceSkills } from "./thread-provider-skills"; +import { useProviderWorkspaceSkills } from "../../state/providerWorkspaceSkillsState"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; @@ -180,6 +183,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadKeyRef = useRef(selectedThreadKey); const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); + const [composerSkillMenuTargetKey, setComposerSkillMenuTargetKey] = useState(null); const [anchorMessageId, setAnchorMessageId] = useState(null); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; @@ -225,12 +229,45 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const selectedProviderSkills = useMemo( + const selectedProviderFallbackSkills = useMemo>( () => props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) ?.skills ?? [], [props.serverConfig, selectedInstanceId], ); + const handleWorkspaceSkillsLookupActiveChange = useCallback( + (active: boolean) => { + const nextTargetKey = active ? selectedThreadKey : null; + setComposerSkillMenuTargetKey((current) => + current === nextTargetKey ? current : nextTargetKey, + ); + }, + [selectedThreadKey], + ); + const providerWorkspaceSkillsLookupEnabled = useMemo( + () => + showContent && + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: composerSkillMenuTargetKey === selectedThreadKey, + draftMessage: props.draftMessage, + feed: selectedThreadFeed, + }), + [ + composerSkillMenuTargetKey, + props.draftMessage, + selectedThreadFeed, + selectedThreadKey, + showContent, + ], + ); + const selectedProviderWorkspaceSkills = useProviderWorkspaceSkills({ + environmentId: props.environmentId, + instanceId: selectedInstanceId, + cwd: props.threadCwd ?? props.projectWorkspaceRoot, + enabled: providerWorkspaceSkillsLookupEnabled, + connectionAvailable: props.connectionStateLabel === "connected", + fallbackSkills: selectedProviderFallbackSkills, + }); useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; @@ -370,7 +407,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} - skills={selectedProviderSkills} + skills={selectedProviderWorkspaceSkills.skills} /> ) : ( @@ -428,6 +465,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadSyncPhase={threadSyncPhase} selectedThread={props.selectedThread} serverConfig={props.serverConfig} + workspaceSkills={selectedProviderWorkspaceSkills.skills} + workspaceSkillsIsPending={selectedProviderWorkspaceSkills.isPending} + workspaceSkillsError={selectedProviderWorkspaceSkills.error} queueCount={props.selectedThreadQueueCount} activeThreadBusy={props.activeThreadBusy} environmentId={props.environmentId} @@ -444,6 +484,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} onExpandedChange={setComposerExpanded} + onWorkspaceSkillsLookupActiveChange={handleWorkspaceSkillsLookupActiveChange} /> diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 74fe2f4852a..28a227f14cf 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -51,10 +51,16 @@ import { } from "../../state/use-thread-outbox"; import { setPendingConnectionError, + useRemoteConnectionStatus, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; +import { useProviderWorkspaceSkills } from "../../state/providerWorkspaceSkillsState"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { + promptHasNewTaskProviderSkillReference, + resolveNewTaskProviderSkillsCwd, +} from "./new-task-provider-skills"; type WorkspaceMode = "local" | "worktree"; @@ -170,6 +176,7 @@ const NewTaskFlowContext = React.createContext(n export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projects = useProjects(); const threads = useThreadShells(); + const { connectedEnvironments } = useRemoteConnectionStatus(); const { savedConnectionsById } = useSavedRemoteConnections(); const repositoryGroups = useMemo( @@ -278,6 +285,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) ? editingPendingProject : (projectsForEnvironment[0] ?? null)); + const providerSkillsConnectionAvailable = + selectedProject !== null && + connectedEnvironments.some( + (environment) => + environment.environmentId === selectedProject.environmentId && + environment.connectionState === "connected", + ); // Only offer machines that actually host the currently selected repository, so // switching computers moves the same repo across machines instead of jumping to @@ -391,13 +405,32 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = useMemo( + const selectedProviderFallbackSkills = useMemo( () => selectedEnvironmentServerConfig?.providers.find( (provider) => provider.instanceId === selectedModel?.instanceId, )?.skills ?? [], [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); + const promptNeedsWorkspaceSkills = useMemo( + () => promptHasNewTaskProviderSkillReference(prompt), + [prompt], + ); + const selectedProviderSkills = useProviderWorkspaceSkills({ + environmentId: selectedProject?.environmentId ?? null, + instanceId: selectedModel?.instanceId ?? null, + // A new-worktree draft has no target directory yet. Do not inspect the + // selected base branch's checkout because its uncommitted skills may not + // exist in the worktree that task creation will materialize. + cwd: resolveNewTaskProviderSkillsCwd({ + workspaceMode, + selectedWorktreePath, + projectWorkspaceRoot: selectedProject?.workspaceRoot ?? null, + }), + enabled: promptNeedsWorkspaceSkills, + connectionAvailable: providerSkillsConnectionAvailable, + fallbackSkills: selectedProviderFallbackSkills, + }).skills; const setSelectedModelKey = useCallback( (key: string | null) => { if (!key || !selectedProjectDraftKey) { diff --git a/apps/mobile/src/features/threads/new-task-provider-skills.test.ts b/apps/mobile/src/features/threads/new-task-provider-skills.test.ts new file mode 100644 index 00000000000..9311a80c5ce --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-provider-skills.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + promptHasNewTaskProviderSkillReference, + resolveNewTaskProviderSkillsCwd, +} from "./new-task-provider-skills"; + +describe("promptHasNewTaskProviderSkillReference", () => { + it("loads workspace metadata only after a skill reference is complete", () => { + expect(promptHasNewTaskProviderSkillReference("Use $review-follow-up")).toBe(false); + expect(promptHasNewTaskProviderSkillReference("Use $review-follow-up next")).toBe(true); + }); + + it("ignores non-skill composer tokens", () => { + expect(promptHasNewTaskProviderSkillReference("Read @AGENTS.md next")).toBe(false); + }); +}); + +describe("resolveNewTaskProviderSkillsCwd", () => { + it("uses the selected checkout only for local tasks", () => { + expect( + resolveNewTaskProviderSkillsCwd({ + workspaceMode: "local", + selectedWorktreePath: "/repo/worktrees/feature", + projectWorkspaceRoot: "/repo", + }), + ).toBe("/repo/worktrees/feature"); + }); + + it("uses the project root when a local task has no alternate checkout", () => { + expect( + resolveNewTaskProviderSkillsCwd({ + workspaceMode: "local", + selectedWorktreePath: null, + projectWorkspaceRoot: "/repo", + }), + ).toBe("/repo"); + }); + + it("uses provider fallback while a future worktree has no cwd", () => { + expect( + resolveNewTaskProviderSkillsCwd({ + workspaceMode: "worktree", + selectedWorktreePath: "/repo/worktrees/existing-feature", + projectWorkspaceRoot: "/repo", + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-provider-skills.ts b/apps/mobile/src/features/threads/new-task-provider-skills.ts new file mode 100644 index 00000000000..fc0e4e5bb89 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-provider-skills.ts @@ -0,0 +1,26 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; + +export type NewTaskWorkspaceMode = "local" | "worktree"; + +export function promptHasNewTaskProviderSkillReference(prompt: string): boolean { + return collectComposerInlineTokens(prompt).some((token) => token.type === "skill"); +} + +/** + * Resolves the only workspace path whose skills are safe to expose before a + * task exists. A future worktree has no materialized directory yet, so using a + * checkout for its selected base branch could leak uncommitted skills that the + * new worktree will not contain. Returning null keeps the provider snapshot as + * the fallback until the task has a real cwd. + */ +export function resolveNewTaskProviderSkillsCwd(input: { + readonly workspaceMode: NewTaskWorkspaceMode; + readonly selectedWorktreePath: string | null; + readonly projectWorkspaceRoot: string | null; +}): string | null { + if (input.workspaceMode === "worktree") { + return null; + } + + return input.selectedWorktreePath ?? input.projectWorkspaceRoot; +} diff --git a/apps/mobile/src/features/threads/thread-composer-skill-items.test.ts b/apps/mobile/src/features/threads/thread-composer-skill-items.test.ts new file mode 100644 index 00000000000..28f7bb67d70 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-composer-skill-items.test.ts @@ -0,0 +1,52 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildComposerSkillItems } from "./thread-composer-skill-items"; + +function skill( + name: string, + input: Partial> = {}, +): ServerProviderSkill { + return { + name, + path: `/skills/${name}/SKILL.md`, + enabled: true, + ...input, + }; +} + +describe("buildComposerSkillItems", () => { + it("exposes enabled workspace skills and excludes disabled snapshot entries", () => { + const repoLocal = skill("update-worktrees", { displayName: "Update Worktrees" }); + + expect( + buildComposerSkillItems([repoLocal, skill("disabled", { enabled: false })], "$"), + ).toEqual([ + { + id: "skill:update-worktrees", + type: "skill", + skill: repoLocal, + label: "Update Worktrees", + description: "", + }, + ]); + }); + + it("searches workspace skill names, labels, and descriptions", () => { + const releaseNotes = skill("release-notes", { + displayName: "Find Release Notes", + shortDescription: "Assess changelog entries", + }); + const updateWorktrees = skill("update-worktrees", { + displayName: "Update Worktrees", + description: "Refresh active workspaces", + }); + + expect(buildComposerSkillItems([updateWorktrees, releaseNotes], "$release")).toEqual([ + expect.objectContaining({ skill: releaseNotes }), + ]); + expect(buildComposerSkillItems([releaseNotes, updateWorktrees], "workspaces")).toEqual([ + expect.objectContaining({ skill: updateWorktrees }), + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-composer-skill-items.ts b/apps/mobile/src/features/threads/thread-composer-skill-items.ts new file mode 100644 index 00000000000..8e70ca37528 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-composer-skill-items.ts @@ -0,0 +1,94 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, +} from "@t3tools/shared/searchRanking"; + +import type { ComposerCommandItem } from "./ComposerCommandPopover"; + +type ComposerSkillCommandItem = Extract; + +function skillCommandItem(skill: ServerProviderSkill): ComposerSkillCommandItem { + return { + id: `skill:${skill.name}`, + type: "skill", + skill, + label: skill.displayName ?? skill.name, + description: skill.shortDescription ?? skill.description ?? "", + }; +} + +export function buildComposerSkillItems( + skills: ReadonlyArray, + query: string, +): Array { + const enabledSkills = skills.filter((skill) => skill.enabled); + const normalizedQuery = normalizeSearchQuery(query, { + trimLeadingPattern: /^\$+/, + }); + + if (!normalizedQuery) { + return enabledSkills.slice(0, 20).map(skillCommandItem); + } + + const ranked: Array<{ + item: ServerProviderSkill; + score: number; + tieBreaker: string; + }> = []; + for (const skill of enabledSkills) { + const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); + const scores = [ + scoreQueryMatch({ + value: skill.name.toLowerCase(), + query: normalizedQuery, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: displayLabel, + query: normalizedQuery, + exactBase: 1, + prefixBase: 3, + boundaryBase: 5, + includesBase: 7, + fuzzyBase: 110, + }), + scoreQueryMatch({ + value: skill.shortDescription?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + scoreQueryMatch({ + value: skill.description?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 30, + prefixBase: 32, + boundaryBase: 34, + includesBase: 36, + }), + ].filter((score): score is number => score !== null); + + if (scores.length > 0) { + insertRankedSearchResult( + ranked, + { + item: skill, + score: Math.min(...scores), + tieBreaker: `${displayLabel}\u0000${skill.name}`, + }, + 20, + ); + } + } + + return ranked.map(({ item }) => skillCommandItem(item)); +} diff --git a/apps/mobile/src/features/threads/thread-provider-skills.test.ts b/apps/mobile/src/features/threads/thread-provider-skills.test.ts new file mode 100644 index 00000000000..9a7a9deca08 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-provider-skills.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldLoadThreadProviderWorkspaceSkills } from "./thread-provider-skills"; + +function feedMessage(role: string, text: string) { + return { + type: "message", + message: { role, text }, + }; +} + +describe("shouldLoadThreadProviderWorkspaceSkills", () => { + it("keeps an empty thread composer lazy", () => { + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: false, + draftMessage: "", + feed: [], + }), + ).toBe(false); + }); + + it("loads while the composer skill menu is active", () => { + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: true, + draftMessage: "$review-follow-up", + feed: [], + }), + ).toBe(true); + }); + + it("loads for complete draft and sent user skill references", () => { + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: false, + draftMessage: "Use $review-follow-up next", + feed: [], + }), + ).toBe(true); + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: false, + draftMessage: "", + feed: [feedMessage("user", "Use $review-follow-up")], + }), + ).toBe(true); + }); + + it("ignores assistant references and incomplete inactive drafts", () => { + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: false, + draftMessage: "Use $review-follow-up", + feed: [feedMessage("assistant", "Try $review-follow-up")], + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-provider-skills.ts b/apps/mobile/src/features/threads/thread-provider-skills.ts new file mode 100644 index 00000000000..71c545378fe --- /dev/null +++ b/apps/mobile/src/features/threads/thread-provider-skills.ts @@ -0,0 +1,31 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { hasInlineSkillToken } from "@t3tools/shared/skillInlineTokens"; + +interface ThreadProviderSkillFeedEntry { + readonly type: string; + readonly message?: { + readonly role: string; + readonly text: string; + }; +} + +export function shouldLoadThreadProviderWorkspaceSkills(input: { + readonly composerSkillMenuActive: boolean; + readonly draftMessage: string; + readonly feed: ReadonlyArray; +}): boolean { + if (input.composerSkillMenuActive) { + return true; + } + + if (collectComposerInlineTokens(input.draftMessage).some((token) => token.type === "skill")) { + return true; + } + + return input.feed.some( + (entry) => + entry.type === "message" && + entry.message?.role === "user" && + hasInlineSkillToken(entry.message.text), + ); +} diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 6e41f2243a9..86ef6164523 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -173,6 +173,39 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); + it("uses sent-message boundaries for skill references", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + content: "$update-main? echo $HOME/.codex and use PHP $value;", + }, + ], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node, [ + { name: "update-main", displayName: "Update Main" }, + { name: "HOME", displayName: "Home" }, + { name: "value", displayName: "Value" }, + ]), + ).toEqual([ + { + text: "$update-main", + role: "body", + skillName: "update-main", + skillLabel: "Update Main", + }, + { text: "? echo $HOME/.codex and use PHP $value;", role: "body" }, + ]); + }); + it("leaves unknown skill-like text unchanged", () => { const node: MarkdownNode = { type: "document", diff --git a/apps/mobile/src/state/providerWorkspaceSkillsState.ts b/apps/mobile/src/state/providerWorkspaceSkillsState.ts new file mode 100644 index 00000000000..2efd810717c --- /dev/null +++ b/apps/mobile/src/state/providerWorkspaceSkillsState.ts @@ -0,0 +1,88 @@ +import { + EMPTY_PROVIDER_WORKSPACE_SKILLS, + formatProviderWorkspaceSkillsError, + PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE, + providerWorkspaceSkillsTargetKey, + resolveNextProviderWorkspaceSkillsSnapshot, + resolveProviderWorkspaceSkills, + type ProviderWorkspaceSkillsSnapshot, + type ProviderWorkspaceSkillsState, + type ProviderWorkspaceSkillsTarget, +} from "@t3tools/client-runtime/state/provider-workspace-skills"; +import { useEffect, useMemo, useRef } from "react"; + +import { useEnvironmentQuery } from "./query"; +import { serverEnvironment } from "./server"; + +export function useProviderWorkspaceSkills( + target: ProviderWorkspaceSkillsTarget, +): ProviderWorkspaceSkillsState { + const stableTarget = useMemo( + () => ({ + environmentId: target.environmentId, + instanceId: target.instanceId, + cwd: target.cwd?.trim() || null, + enabled: target.enabled, + connectionAvailable: target.connectionAvailable !== false, + }), + [ + target.connectionAvailable, + target.cwd, + target.enabled, + target.environmentId, + target.instanceId, + ], + ); + const targetKey = providerWorkspaceSkillsTargetKey({ ...stableTarget, enabled: true }); + const key = stableTarget.enabled ? targetKey : null; + const unavailable = key !== null && !stableTarget.connectionAvailable; + const query = useEnvironmentQuery( + key !== null && + !unavailable && + stableTarget.environmentId !== null && + stableTarget.instanceId !== null + ? serverEnvironment.providerSkills({ + environmentId: stableTarget.environmentId, + input: { + instanceId: stableTarget.instanceId, + cwd: stableTarget.cwd!, + }, + }) + : null, + ); + + const previousWorkspaceSkillsRef = useRef(null); + const querySkills = query.data?.skills ?? null; + useEffect(() => { + previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ + key: targetKey, + skills: querySkills, + isPending: query.isPending, + error: query.error, + inactive: key === null, + unavailable, + current: previousWorkspaceSkillsRef.current, + }); + }, [key, query.error, query.isPending, querySkills, targetKey, unavailable]); + + if (key === null) { + return { skills: target.fallbackSkills, isPending: false, error: null }; + } + const previousWorkspaceSkills = previousWorkspaceSkillsRef.current; + return { + skills: resolveProviderWorkspaceSkills({ + nextKey: key, + nextSkills: querySkills, + isPending: query.isPending, + error: query.error, + unavailable, + currentKey: previousWorkspaceSkills?.key ?? null, + currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_PROVIDER_WORKSPACE_SKILLS, + fallbackSkills: target.fallbackSkills, + }), + isPending: unavailable ? false : query.isPending, + error: unavailable + ? PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE + : formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), + }; +} diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index c29d01d397b..a8164b3332f 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -10,6 +10,7 @@ const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(fals export interface EnvironmentQueryView { readonly data: A | null; readonly error: string | null; + readonly errorCause: Cause.Cause | null; readonly isPending: boolean; readonly refresh: () => void; } @@ -30,6 +31,7 @@ export function useEnvironmentQuery( return { data: Option.getOrNull(AsyncResult.value(result)), error: result._tag === "Failure" ? formatError(result.cause) : null, + errorCause: result._tag === "Failure" ? result.cause : null, isPending: atom !== null && result.waiting, refresh, }; diff --git a/apps/server/scripts/codex-skills-mock-app-server.ts b/apps/server/scripts/codex-skills-mock-app-server.ts new file mode 100644 index 00000000000..1f4900dee8f --- /dev/null +++ b/apps/server/scripts/codex-skills-mock-app-server.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; + +const cwdLogPath = process.env.T3_CODEX_CWD_LOG_PATH; +const argsLogPath = process.env.T3_CODEX_ARGS_LOG_PATH; +const exitLogPath = process.env.T3_CODEX_EXIT_LOG_PATH; +const hangSkillsList = process.env.T3_CODEX_HANG_SKILLS_LIST === "1"; + +function appendLog(path: string | undefined, line: string): void { + if (path) NodeFS.appendFileSync(path, `${line}\n`, "utf8"); +} + +function respond(id: number | string, result: unknown): void { + process.stdout.write(`${JSON.stringify({ id, result })}\n`); +} + +process.once("SIGTERM", () => { + appendLog(exitLogPath, "SIGTERM"); + process.exit(0); +}); +process.once("exit", (code) => appendLog(exitLogPath, `exit:${code}`)); + +let remainder = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + remainder += chunk; + const lines = remainder.split("\n"); + remainder = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + const message = JSON.parse(line) as Record; + const id = message.id; + if ((typeof id !== "number" && typeof id !== "string") || typeof message.method !== "string") { + continue; + } + + switch (message.method) { + case "initialize": + appendLog(cwdLogPath, process.cwd()); + appendLog(argsLogPath, JSON.stringify(process.argv.slice(2))); + respond(id, { + userAgent: "t3code-codex-skills-test", + codexHome: process.cwd(), + platformFamily: "unix", + platformOs: "linux", + }); + break; + case "account/read": + respond(id, { + account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, + requiresOpenaiAuth: false, + }); + break; + case "skills/list": + if (!hangSkillsList) { + respond(id, { + data: [ + { + cwd: process.cwd(), + errors: [], + skills: [ + { + name: "workspace-skill", + description: "A workspace-scoped test skill.", + shortDescription: "Workspace test skill", + path: `${process.cwd()}/.agents/skills/workspace-skill/SKILL.md`, + scope: "repo", + enabled: true, + }, + ], + }, + ], + }); + } + break; + default: + respond(id, {}); + } + } +}); +process.stdin.on("end", () => process.exit(0)); diff --git a/apps/server/src/diagnostics/ErrorCause.ts b/apps/server/src/diagnostics/ErrorCause.ts new file mode 100644 index 00000000000..e0c7be04432 --- /dev/null +++ b/apps/server/src/diagnostics/ErrorCause.ts @@ -0,0 +1,85 @@ +const MAX_DIAGNOSTIC_TEXT_LENGTH = 2_000; + +export interface SanitizedErrorCause { + readonly tag?: string; + readonly name?: string; + readonly message?: string; + readonly detail?: string; + readonly code?: string; + readonly exitCode?: number; + readonly timeoutMs?: number; + readonly truncated?: boolean; +} + +type MutableSanitizedErrorCause = { + -readonly [Key in keyof SanitizedErrorCause]?: SanitizedErrorCause[Key]; +}; + +function truncateDiagnosticText(value: string): { + readonly text: string; + readonly truncated: boolean; +} { + if (value.length <= MAX_DIAGNOSTIC_TEXT_LENGTH) { + return { text: value, truncated: false }; + } + return { + text: `${value.slice(0, MAX_DIAGNOSTIC_TEXT_LENGTH)}\n\n[truncated]`, + truncated: true, + }; +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function numberField(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function addTextField( + output: MutableSanitizedErrorCause, + key: "tag" | "name" | "message" | "detail" | "code", + value: string | undefined, +) { + if (!value) return; + const truncated = truncateDiagnosticText(value); + output[key] = truncated.text; + if (truncated.truncated) output.truncated = true; +} + +export function sanitizeErrorCause(cause: unknown): SanitizedErrorCause { + if (typeof cause === "string") { + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "message", cause); + return output; + } + + if (typeof cause === "object" && cause !== null) { + const record = cause as Record; + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "tag", stringField(record, "_tag")); + addTextField(output, "name", stringField(record, "name")); + addTextField(output, "message", stringField(record, "message")); + addTextField(output, "detail", stringField(record, "detail")); + addTextField(output, "code", stringField(record, "code")); + + const exitCode = numberField(record, "exitCode"); + if (exitCode !== undefined) output.exitCode = exitCode; + + const timeoutMs = numberField(record, "timeoutMs"); + if (timeoutMs !== undefined) output.timeoutMs = timeoutMs; + + return Object.keys(output).length > 0 ? output : { tag: "Object" }; + } + + if (cause instanceof Error) { + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "name", cause.name); + addTextField(output, "message", cause.message); + return output; + } + + return { message: "Unknown error" }; +} diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index ffcc94ca77d..ed8b59fd8b2 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -119,6 +119,7 @@ export const CodexDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -166,7 +167,12 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( + const checkProvider = checkCodexProviderStatus( + effectiveConfig, + serverConfig.cwd, + undefined, + processEnv, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2ccd..97e5ed2df3f 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,68 @@ -import { assert, it } from "@effect/vitest"; +import * as NodeOS from "node:os"; +import * as NodeTimersPromises from "node:timers/promises"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import { + applyPreferredCodexDefaultModel, + listCodexProviderSkills, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; +import { listCodexProviderSkillsWithTimeout } from "../ProviderSkillsLister.ts"; + +const CodexArgsLog = Schema.fromJsonString(Schema.Array(Schema.String)); + +const resolveMockAppServerPath = Effect.fn("resolveMockAppServerPath")(function* () { + const path = yield* Path.Path; + return yield* path.fromFileUrl( + new URL("../../../scripts/codex-skills-mock-app-server.ts", import.meta.url), + ); +}); + +const makeMockAppServer = Effect.fn("makeMockAppServer")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAppServerPath = yield* resolveMockAppServerPath(); + const directory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "codex-skills-provider-", + }); + const binaryPath = path.join(directory, "codex"); + const command = [process.execPath, mockAppServerPath] + .map((argument) => JSON.stringify(argument)) + .join(" "); + yield* fileSystem.writeFileString(binaryPath, `#!/bin/sh\nexec ${command} "$@"\n`); + yield* fileSystem.chmod(binaryPath, 0o755); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "codex-skills-workspace-", + }); + return { + binaryPath, + cwd: yield* fileSystem.realPath(workspaceDirectory), + argsLogPath: path.join(directory, "args.log"), + cwdLogPath: path.join(directory, "cwd.log"), + exitLogPath: path.join(directory, "exit.log"), + }; +}); + +const waitForFileContent = Effect.fn("waitForFileContent")(function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + for (let attempt = 0; attempt < 40; attempt += 1) { + const content = yield* fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + if (content.trim()) return content; + yield* Effect.promise(() => NodeTimersPromises.setTimeout(50)); + } + return yield* Effect.die(`Timed out waiting for file content at ${filePath}`); +}); it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -103,6 +165,65 @@ it("uses standard routing when the catalog has no default service tier", () => { ]); }); +describe("listCodexProviderSkills", () => { + it.effect("lists workspace skills from the configured cwd", () => + Effect.gen(function* () { + const fixture = yield* makeMockAppServer(); + const skills = yield* listCodexProviderSkills({ + binaryPath: fixture.binaryPath, + launchArgs: "--enable workspace-skill-test", + cwd: fixture.cwd, + environment: { + ...process.env, + T3_CODEX_ARGS_LOG_PATH: fixture.argsLogPath, + T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath, + }, + }).pipe(Effect.scoped); + + expect(skills).toEqual([ + { + name: "workspace-skill", + description: "A workspace-scoped test skill.", + shortDescription: "Workspace test skill", + path: `${fixture.cwd}/.agents/skills/workspace-skill/SKILL.md`, + scope: "repo", + enabled: true, + }, + ]); + const args = yield* Schema.decodeUnknownEffect(CodexArgsLog)( + (yield* waitForFileContent(fixture.argsLogPath)).trim(), + ); + expect(args).toEqual(["app-server", "--enable", "workspace-skill-test"]); + expect((yield* waitForFileContent(fixture.cwdLogPath)).trim()).toBe(fixture.cwd); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports timeouts and terminates the app-server", () => + Effect.gen(function* () { + const fixture = yield* makeMockAppServer(); + const fiber = yield* listCodexProviderSkillsWithTimeout({ + instanceId: ProviderInstanceId.make("codex"), + binaryPath: fixture.binaryPath, + cwd: fixture.cwd, + environment: { + ...process.env, + T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath, + T3_CODEX_EXIT_LOG_PATH: fixture.exitLogPath, + T3_CODEX_HANG_SKILLS_LIST: "1", + }, + }).pipe(Effect.forkChild); + + yield* waitForFileContent(fixture.cwdLogPath); + yield* TestClock.adjust("15 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + expect(error.message).toBe( + `Timed out listing Codex skills after 15s (provider: 'codex', cwd: '${fixture.cwd}').`, + ); + expect(yield* waitForFileContent(fixture.exitLogPath)).toContain("SIGTERM"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); + it("marks the most preferred available model as default", () => { const models = applyPreferredCodexDefaultModel([ { slug: "gpt-5.6-terra", name: "GPT-5.6-Terra", isCustom: false, capabilities: null }, diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 1ed9c750c18..542c4fa228b 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -300,6 +300,64 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( return models; }); +export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; +}) { + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = { + ...input.environment, + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }; + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + codexAppServerArgs(input.launchArgs), + { + env: environment, + extendEnv: true, + }, + ); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); + const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + const accountResponse = yield* client.request("account/read", {}); + if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + return []; + } + + const response = yield* client.request("skills/list", { + cwds: [input.cwd], + }); + return parseCodexSkillsListResponse(response, input.cwd); +}); + export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { @@ -496,6 +554,7 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( codexSettings: CodexSettings, + cwd: string, probe: (input: { readonly binaryPath: string; readonly homePath?: string; @@ -539,7 +598,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, launchArgs: resolveCodexLaunchArgs(codexSettings.launchArgs, resolvedEnvironment), - cwd: process.cwd(), + cwd, customModels: codexSettings.customModels, environment: resolvedEnvironment, }).pipe( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5efbb6f1c14..db58e642870 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -307,21 +307,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te describe("checkCodexProviderStatus", () => { it.effect("uses the app-server account and model list for provider status", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - skills: [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ], - }), - ), + let observedCwd: string | null = null; + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + "/tmp/t3-code-cwd", + (input) => { + observedCwd = input.cwd; + return Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ], + }), + ); + }, ); + assert.strictEqual(observedCwd, "/tmp/t3-code-cwd"); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); assert.strictEqual(status.version, "1.0.0"); @@ -354,7 +361,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te let observedLaunchArgs: string | undefined; const settings = decodeCodexSettings({ launchArgs: "--strict-config --enable foo" }); - const status = yield* checkCodexProviderStatus(settings, (input) => { + const status = yield* checkCodexProviderStatus(settings, process.cwd(), (input) => { observedLaunchArgs = input.launchArgs; return Effect.succeed(makeCodexProbeSnapshot()); }); @@ -366,7 +373,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -390,15 +397,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te "returns ready with unknown auth when app-server does not require OpenAI auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: false, - }, - }), - ), + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + process.cwd(), + () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: false, + }, + }), + ), ); assert.strictEqual(status.status, "ready"); @@ -408,7 +418,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("returns an api key label for codex api key auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -428,7 +438,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -448,7 +458,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("returns unavailable when codex is missing", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.fail( new CodexErrors.CodexAppServerSpawnError({ command: "codex app-server", @@ -469,10 +479,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("closes the app-server probe scope when provider status times out", () => Effect.gen(function* () { const killCalls = yield* Ref.make(0); - const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( - Effect.provide(hangingScopedSpawnerLayer(killCalls)), - Effect.forkChild, - ); + const statusFiber = yield* checkCodexProviderStatus( + defaultCodexSettings, + process.cwd(), + ).pipe(Effect.provide(hangingScopedSpawnerLayer(killCalls)), Effect.forkChild); yield* Effect.yieldNow; yield* TestClock.adjust("11 seconds"); @@ -1778,7 +1788,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te it.effect("skips codex probes entirely when the provider is disabled", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( + const status = yield* checkCodexProviderStatus(disabledCodexSettings, process.cwd()).pipe( Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), ); assert.strictEqual(status.enabled, false); diff --git a/apps/server/src/provider/ProviderSkillsLister.test.ts b/apps/server/src/provider/ProviderSkillsLister.test.ts new file mode 100644 index 00000000000..b7249269cbc --- /dev/null +++ b/apps/server/src/provider/ProviderSkillsLister.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; + +import { makeBoundedRequestCache, optionalTrimmedNonEmptyString } from "./ProviderSkillsLister.ts"; + +describe("optionalTrimmedNonEmptyString", () => { + it("returns a trimmed value for non-empty strings", () => { + expect(optionalTrimmedNonEmptyString(" /tmp/project ")).toBe("/tmp/project"); + }); + + it("returns undefined for missing or blank strings", () => { + expect(optionalTrimmedNonEmptyString(undefined)).toBeUndefined(); + expect(optionalTrimmedNonEmptyString(" ")).toBeUndefined(); + }); +}); + +describe("makeBoundedRequestCache", () => { + it.effect("coalesces concurrent requests for the same key", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const release = yield* Deferred.make(); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Ref.update(calls, (count) => count + 1).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(`value:${key}`), + ), + }); + + const first = yield* Effect.forkChild(requests.get("repo")); + const second = yield* Effect.forkChild(requests.get("repo")); + yield* Effect.yieldNow; + expect(yield* Ref.get(calls)).toBe(1); + + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(first)).toBe("value:repo"); + expect(yield* Fiber.join(second)).toBe("value:repo"); + expect(yield* Ref.get(calls)).toBe(1); + }), + ); + + it.effect("bounds concurrent lookups across different keys", () => + Effect.gen(function* () { + const active = yield* Ref.make(0); + const maxActive = yield* Ref.make(0); + const release = yield* Deferred.make(); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Effect.acquireUseRelease( + Ref.updateAndGet(active, (count) => count + 1).pipe( + Effect.tap((count) => Ref.update(maxActive, (maximum) => Math.max(maximum, count))), + ), + () => Deferred.await(release).pipe(Effect.as(key)), + () => Ref.update(active, (count) => count - 1), + ), + }); + + const fibers = yield* Effect.forEach(["one", "two", "three"], requests.get, { + concurrency: "unbounded", + discard: false, + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(yield* Ref.get(maxActive)).toBe(2); + + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(fibers)).toEqual(["one", "two", "three"]); + }), + ); + + it.effect("retries immediately after a failed lookup", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Ref.getAndUpdate(calls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 0 ? Effect.fail("transient") : Effect.succeed(`value:${key}`), + ), + ), + }); + + expect(yield* Effect.flip(requests.get("repo"))).toBe("transient"); + expect(yield* requests.get("repo")).toBe("value:repo"); + expect(yield* Ref.get(calls)).toBe(2); + }), + ); +}); diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts new file mode 100644 index 00000000000..d02509b1c2a --- /dev/null +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -0,0 +1,325 @@ +import { + CodexSettings, + ServerProviderSkillsListError, + type ProviderInstanceId, + type ServerProviderSkillsListFailureReason, + type ServerProviderSkillsListResult, +} from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + CodexShadowHomeEntryConflictError, + CodexShadowHomeFileSystemError, + CodexShadowHomePathConflictError, + CodexShadowHomePrivateEntrySymlinkError, + materializeCodexShadowHome, + resolveCodexHomeLayout, +} from "./Drivers/CodexHomeLayout.ts"; +import { listCodexProviderSkills } from "./Layers/CodexProvider.ts"; +import { resolveCodexLaunchArgs } from "./Layers/codexLaunchArgs.ts"; +import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { sanitizeErrorCause } from "../diagnostics/ErrorCause.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; + +const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); +const PROVIDER_SKILLS_CACHE_CAPACITY = 64; +const PROVIDER_SKILLS_CACHE_TTL = Duration.seconds(1); +const PROVIDER_SKILLS_MAX_CONCURRENCY = 4; +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); +const isWorkspaceRootNotExistsError = Schema.is(WorkspacePaths.WorkspaceRootNotExistsError); +const isWorkspaceRootNotDirectoryError = Schema.is(WorkspacePaths.WorkspaceRootNotDirectoryError); +const isWorkspaceRootCreateFailedError = Schema.is(WorkspacePaths.WorkspaceRootCreateFailedError); +const isWorkspaceRootStatFailedError = Schema.is(WorkspacePaths.WorkspaceRootStatFailedError); +const isCodexShadowHomePathConflictError = Schema.is(CodexShadowHomePathConflictError); +const isCodexShadowHomeEntryConflictError = Schema.is(CodexShadowHomeEntryConflictError); +const isCodexShadowHomePrivateEntrySymlinkError = Schema.is( + CodexShadowHomePrivateEntrySymlinkError, +); +const isCodexShadowHomeFileSystemError = Schema.is(CodexShadowHomeFileSystemError); + +export interface ProviderSkillsListInput { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; +} + +export interface BoundedRequestCache { + readonly get: (key: Key) => Effect.Effect; +} + +export const makeBoundedRequestCache = Effect.fn("makeBoundedRequestCache")(function* < + Key, + A, + E, + R, +>(options: { + readonly capacity: number; + readonly concurrency: number; + readonly timeToLive: Duration.Input; + readonly lookup: (key: Key) => Effect.Effect; +}): Effect.fn.Return, never, R> { + const semaphore = yield* Semaphore.make(options.concurrency); + const cache = yield* Cache.makeWith((key: Key) => semaphore.withPermits(1)(options.lookup(key)), { + capacity: options.capacity, + timeToLive: (exit) => (Exit.isSuccess(exit) ? options.timeToLive : Duration.zero), + }); + return { + get: (key) => Cache.get(cache, key), + }; +}); + +export function optionalTrimmedNonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : undefined; +} + +function providerSkillsListError(input: { + readonly reason: ServerProviderSkillsListFailureReason; + readonly operation: string; + readonly message: string; + readonly detail?: string | undefined; + readonly instanceId?: ProviderInstanceId | undefined; + readonly cwd?: string | undefined; + readonly cause?: unknown; +}) { + const cwd = optionalTrimmedNonEmptyString(input.cwd); + return new ServerProviderSkillsListError({ + reason: input.reason, + operation: input.operation, + message: input.message, + ...(input.detail === undefined ? {} : { detail: input.detail }), + ...(input.instanceId === undefined ? {} : { instanceId: input.instanceId }), + ...(cwd === undefined ? {} : { cwd }), + ...(input.cause === undefined ? {} : { cause: sanitizeErrorCause(input.cause) }), + }); +} + +function workspaceCwdFailureDetail(cause: unknown): string { + if (isWorkspaceRootNotExistsError(cause)) { + return `Workspace root does not exist: ${cause.normalizedWorkspaceRoot}.`; + } + if (isWorkspaceRootNotDirectoryError(cause)) { + return `Workspace root is not a directory: ${cause.normalizedWorkspaceRoot}.`; + } + if (isWorkspaceRootCreateFailedError(cause)) { + return `Failed to create workspace root: ${cause.normalizedWorkspaceRoot}.`; + } + if (isWorkspaceRootStatFailedError(cause)) { + return `Failed to stat workspace root '${cause.normalizedWorkspaceRoot}' during '${cause.phase}'.`; + } + return "Check the requested workspace path and filesystem permissions."; +} + +function codexHomePrepareFailureDetail(cause: unknown): string { + if (isCodexShadowHomePathConflictError(cause)) { + return `Codex shadow home path '${cause.effectiveHomePath}' must be different from shared home path '${cause.sharedHomePath}'.`; + } + if (isCodexShadowHomeEntryConflictError(cause)) { + return `Codex shadow home entry '${cause.entryName}' already exists and is not a symlink.`; + } + if (isCodexShadowHomePrivateEntrySymlinkError(cause)) { + return `Codex shadow home private entry '${cause.entryName}' must be a real file.`; + } + if (isCodexShadowHomeFileSystemError(cause)) { + return `Codex shadow home filesystem operation '${cause.operation}' failed for '${cause.path}'.`; + } + return "Check the configured Codex home paths and filesystem permissions."; +} + +function codexSkillListFailure(input: { + readonly cause: unknown; + readonly instanceId: ProviderInstanceId; + readonly cwd: string; +}) { + if (Cause.isTimeoutError(input.cause)) { + return providerSkillsListError({ + reason: "probe-timeout", + operation: "ProviderSkillsLister.listCodexProviderSkills", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`, + cause: input.cause, + }); + } + return providerSkillsListError({ + reason: "probe-failed", + operation: "ProviderSkillsLister.listCodexProviderSkills", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`, + cause: input.cause, + }); +} + +export const listCodexProviderSkillsWithTimeout = Effect.fn("listCodexProviderSkillsWithTimeout")( + function* (input: { + readonly instanceId: ProviderInstanceId; + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) { + return yield* listCodexProviderSkills({ + binaryPath: input.binaryPath, + ...(input.homePath ? { homePath: input.homePath } : {}), + ...(input.launchArgs ? { launchArgs: input.launchArgs } : {}), + cwd: input.cwd, + environment: input.environment, + }).pipe( + Effect.scoped, + Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), + Effect.mapError((cause) => + codexSkillListFailure({ + cause, + instanceId: input.instanceId, + cwd: input.cwd, + }), + ), + ); + }, +); + +function requestKey(input: ProviderSkillsListInput): string { + return JSON.stringify([input.instanceId, input.cwd]); +} + +function parseRequestKey(key: string): ProviderSkillsListInput { + const [instanceId, cwd] = JSON.parse(key) as [ProviderInstanceId, string]; + return { instanceId, cwd }; +} + +export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(function* () { + const providerRegistry = yield* ProviderRegistry; + const serverSettings = yield* ServerSettingsService; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const listUncached = Effect.fn("ProviderSkillsLister.listUncached")(function* ( + input: ProviderSkillsListInput, + ): Effect.fn.Return { + const providers = yield* providerRegistry.getProviders; + const snapshot = providers.find((provider) => provider.instanceId === input.instanceId); + if (!snapshot) { + return yield* providerSkillsListError({ + reason: "provider-not-found", + operation: "ProviderSkillsLister.list", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Provider instance '${input.instanceId}' was not found.`, + }); + } + if (snapshot.driver !== "codex") { + return { skills: snapshot.skills }; + } + + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((cause) => + providerSkillsListError({ + reason: "settings-read-failed", + operation: "ProviderSkillsLister.readSettings", + instanceId: input.instanceId, + cwd: input.cwd, + message: "Failed to read provider settings.", + cause, + }), + ), + ); + const instanceConfig = deriveProviderInstanceConfigMap(settings)[input.instanceId]; + if (!instanceConfig || instanceConfig.driver !== "codex") { + return yield* providerSkillsListError({ + reason: "provider-not-configured", + operation: "ProviderSkillsLister.resolveCodexInstance", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Codex provider instance '${input.instanceId}' is not configured.`, + }); + } + + const decodedConfig = yield* decodeCodexSettings(instanceConfig.config ?? {}).pipe( + Effect.mapError((cause) => + providerSkillsListError({ + reason: "settings-decode-failed", + operation: "ProviderSkillsLister.decodeCodexSettings", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, + cause, + }), + ), + ); + const effectiveConfig = { + ...decodedConfig, + enabled: instanceConfig.enabled ?? decodedConfig.enabled, + }; + if (!effectiveConfig.enabled) { + return { skills: snapshot.skills }; + } + + const normalizedCwd = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd).pipe( + Effect.mapError((cause) => + providerSkillsListError({ + reason: "invalid-cwd", + operation: "ProviderSkillsLister.normalizeCwd", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Invalid Codex skills cwd '${input.cwd}'.`, + detail: workspaceCwdFailureDetail(cause), + cause, + }), + ), + ); + const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( + Effect.provideService(Path.Path, path), + ); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => + providerSkillsListError({ + reason: "home-prepare-failed", + operation: "ProviderSkillsLister.prepareCodexHome", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Failed to prepare Codex home for '${input.instanceId}'.`, + detail: codexHomePrepareFailureDetail(cause), + cause, + }), + ), + ); + const environment = mergeProviderInstanceEnvironment(instanceConfig.environment ?? []); + const skills = yield* listCodexProviderSkillsWithTimeout({ + instanceId: input.instanceId, + binaryPath: effectiveConfig.binaryPath, + ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, environment), + cwd: normalizedCwd, + environment, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner)); + return { skills }; + }); + + const requests = yield* makeBoundedRequestCache({ + capacity: PROVIDER_SKILLS_CACHE_CAPACITY, + concurrency: PROVIDER_SKILLS_MAX_CONCURRENCY, + timeToLive: PROVIDER_SKILLS_CACHE_TTL, + lookup: (key: string) => listUncached(parseRequestKey(key)), + }); + + return Effect.fn("ProviderSkillsLister.list")(function* (input: ProviderSkillsListInput) { + return yield* requests.get(requestKey(input)); + }); +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..08dadd393f0 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -26,6 +26,8 @@ import { ProviderDriverKind, ProviderInstanceId, ResolvedKeybindingRule, + type ServerProvider, + type ServerProviderSkill, ThreadId, WS_METHODS, WsRpcGroup, @@ -1135,6 +1137,24 @@ const responseJsonEffect = (response: HttpClientResponse.HttpClientResponse) const responseOk = (response: HttpClientResponse.HttpClientResponse) => response.status >= 200 && response.status < 300; +const makeServerProviderSnapshot = ( + input: Partial & { + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + }, +): ServerProvider => ({ + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...input, +}); + const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => Effect.gen(function* () { const { response, cookie } = yield* bootstrapBrowserSession(credential); @@ -4341,6 +4361,242 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc server.listProviderSkills errors for missing provider", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId: ProviderInstanceId.make("codex"), + cwd: process.cwd(), + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ServerProviderSkillsListError"); + assert.equal(result.failure.message, "Provider instance 'codex' was not found."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc server.listProviderSkills returns non-Codex snapshot skills", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const skill: ServerProviderSkill = { + name: "plan", + path: "/providers/claudeAgent/skills/plan/SKILL.md", + enabled: true, + }; + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + skills: [skill], + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: "/definitely/not/a/real/workspace/path", + }), + ), + ); + + assert.deepEqual(response.skills, [skill]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc server.listProviderSkills returns disabled Codex snapshot skills", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const skill: ServerProviderSkill = { + name: "fallback", + path: "/providers/codex/skills/fallback/SKILL.md", + enabled: true, + }; + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver, + skills: [skill], + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver, + enabled: false, + config: {}, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: "/definitely/not/a/real/workspace/path", + }), + ), + ); + + assert.deepEqual(response.skills, [skill]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc server.listProviderSkills validates enabled Codex cwd", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver, + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver, + enabled: true, + config: {}, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const missingWorkspacePath = "/definitely/not/a/real/workspace/path"; + const requestedCwd = ` ${missingWorkspacePath} `; + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: requestedCwd, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ServerProviderSkillsListError"); + assert.equal(result.failure.message, `Invalid Codex skills cwd '${missingWorkspacePath}'.`); + assert.equal(result.failure.cwd, missingWorkspacePath); + assert.notInclude(result.failure.message, "Workspace root does not exist"); + assert.equal( + result.failure.detail, + `Workspace root does not exist: ${missingWorkspacePath}.`, + ); + assert.property(result.failure, "cause"); + const failureCause = result.failure.cause; + assert.instanceOf(failureCause, Error); + assert.include(failureCause.message, "Workspace root does not exist"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc server.listProviderSkills reports Codex home details", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-provider-skills-", + }); + const sharedHomePath = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-codex-home-", + }); + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver, + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver, + enabled: true, + config: { + homePath: sharedHomePath, + shadowHomePath: sharedHomePath, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: workspaceDir, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ServerProviderSkillsListError"); + assert.equal(result.failure.reason, "home-prepare-failed"); + assert.equal(result.failure.message, "Failed to prepare Codex home for 'codex'."); + assert.include(result.failure.detail ?? "", "Codex shadow home path"); + assert.include(result.failure.detail ?? "", sharedHomePath); + assert.property(result.failure, "cause"); + const failureCause = result.failure.cause; + assert.instanceOf(failureCause, Error); + assert.include(failureCause.message, "Codex shadow home path"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f6f46d1e76e..02944b9eeb2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -52,6 +52,8 @@ import { AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, EnvironmentAuthorizationError, + ServerProviderSkillsListError, + type ServerProviderSkillsListResult, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -77,6 +79,10 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import { + makeProviderSkillsLister, + type ProviderSkillsListInput, +} from "./provider/ProviderSkillsLister.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -296,6 +302,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.serverProbe, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], + [WS_METHODS.serverListProviderSkills, AuthOrchestrationReadScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], [WS_METHODS.serverUpdateServer, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], @@ -398,10 +405,19 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = ( - currentSession: EnvironmentAuth.AuthenticatedSession, - previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], -) => +interface WsRpcLayerOptions { + readonly currentSession: EnvironmentAuth.AuthenticatedSession; + readonly listProviderSkills: ( + input: ProviderSkillsListInput, + ) => Effect.Effect; + readonly previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"]; +} + +const makeWsRpcLayer = ({ + currentSession, + listProviderSkills, + previewAutomationBroker, +}: WsRpcLayerOptions) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; @@ -1471,6 +1487,10 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverListProviderSkills]: (input) => + observeRpcEffect(WS_METHODS.serverListProviderSkills, listProviderSkills(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, @@ -2085,6 +2105,7 @@ const makeWsRpcLayer = ( export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { + const listProviderSkills = yield* makeProviderSkillsLister(); const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; return HttpRouter.add( @@ -2106,7 +2127,11 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker).pipe( + makeWsRpcLayer({ + currentSession: session, + listProviderSkills, + previewAutomationBroker, + }).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 57c12959ffb..9b212e3a1e7 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -23,10 +23,12 @@ import { isBranchMismatchDismissedForSession, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveProviderSkillsCwd, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, + timelineMessagesHaveCompleteSkillReference, } from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local"); @@ -136,6 +138,78 @@ describe("buildThreadTurnInterruptInput", () => { }); }); +describe("timelineMessagesHaveCompleteSkillReference", () => { + it("keeps empty drafts and unrelated messages from requesting workspace skills", () => { + expect(timelineMessagesHaveCompleteSkillReference([])).toBe(false); + expect( + timelineMessagesHaveCompleteSkillReference([ + { role: "user", text: "Inspect @AGENTS.md" }, + { role: "user", text: "$" }, + { role: "user", text: "$123invalid" }, + { role: "user", text: "echo $HOME/.codex" }, + { role: "user", text: "use PHP $value;" }, + ]), + ).toBe(false); + }); + + it("ignores complete skill references in assistant messages", () => { + expect( + timelineMessagesHaveCompleteSkillReference([ + { role: "assistant", text: "Try $repo-skill next." }, + ]), + ).toBe(false); + }); + + it("requests workspace skills when a sent user prompt contains a complete skill token", () => { + expect( + timelineMessagesHaveCompleteSkillReference([ + { role: "user", text: "Use $repo-skill to inspect this." }, + ]), + ).toBe(true); + expect( + timelineMessagesHaveCompleteSkillReference([{ role: "user", text: "Use $repo-skill" }]), + ).toBe(true); + expect( + timelineMessagesHaveCompleteSkillReference([{ role: "user", text: "Use $repo-skill?" }]), + ).toBe(true); + }); +}); + +describe("resolveProviderSkillsCwd", () => { + it("uses the selected checkout for local drafts", () => { + expect( + resolveProviderSkillsCwd({ + gitCwd: "/repo/worktrees/existing-feature", + isLocalDraftThread: true, + draftThreadEnvMode: "local", + worktreePath: "/repo/worktrees/existing-feature", + }), + ).toBe("/repo/worktrees/existing-feature"); + }); + + it("uses a materialized worktree for worktree drafts", () => { + expect( + resolveProviderSkillsCwd({ + gitCwd: "/repo/worktrees/new-feature", + isLocalDraftThread: true, + draftThreadEnvMode: "worktree", + worktreePath: "/repo/worktrees/new-feature", + }), + ).toBe("/repo/worktrees/new-feature"); + }); + + it("does not probe the base checkout for a future worktree draft", () => { + expect( + resolveProviderSkillsCwd({ + gitCwd: "/repo", + isLocalDraftThread: true, + draftThreadEnvMode: "worktree", + worktreePath: null, + }), + ).toBeNull(); + }); +}); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 466c9b24c87..74535fb4355 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -20,6 +20,7 @@ import { type TerminalContextDraft, } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; +import { hasInlineSkillToken } from "./chat/skillInlineTokens"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -193,6 +194,37 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ return previewUrls; } +export function timelineMessagesHaveCompleteSkillReference( + messages: ReadonlyArray>, + cache?: WeakMap, +): boolean { + return messages.some((message) => { + if (message.role !== "user") return false; + const cached = cache?.get(message); + if (cached !== undefined) return cached; + const hasSkillReference = hasInlineSkillToken(message.text); + cache?.set(message, hasSkillReference); + return hasSkillReference; + }); +} + +export function resolveProviderSkillsCwd(input: { + readonly gitCwd: string | null; + readonly isLocalDraftThread: boolean; + readonly draftThreadEnvMode: DraftThreadEnvMode | undefined; + readonly worktreePath: string | null; +}): string | null { + if ( + input.isLocalDraftThread && + input.draftThreadEnvMode === "worktree" && + input.worktreePath === null + ) { + return null; + } + + return input.gitCwd; +} + export interface PullRequestDialogState { initialReference: string | null; key: number; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..c6f89ce1f0a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -165,6 +165,7 @@ import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings" import { useNowMinute } from "../hooks/useNowMinute"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { useProviderWorkspaceSkills } from "../lib/providerWorkspaceSkillsState"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, @@ -262,10 +263,12 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveProviderSkillsCwd, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + timelineMessagesHaveCompleteSkillReference, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -1171,6 +1174,8 @@ function ChatViewContent(props: ChatViewProps) { () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], ); + const providerSkillsConnectionAvailable = + environmentById.get(environmentId)?.connection.phase === "connected"; const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useThread(routeThreadRef); @@ -2321,6 +2326,12 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; + const providerSkillsCwd = resolveProviderSkillsCwd({ + gitCwd, + isLocalDraftThread, + draftThreadEnvMode: draftThread?.envMode, + worktreePath: activeThread?.worktreePath ?? null, + }); const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null @@ -2354,6 +2365,24 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const activeProviderFallbackSkills = activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; + const timelineSkillReferenceCacheRef = useRef(new WeakMap()); + const timelineHasSkillReference = useMemo( + () => + timelineMessagesHaveCompleteSkillReference( + timelineMessages, + timelineSkillReferenceCacheRef.current, + ), + [timelineMessages], + ); + const activeProviderWorkspaceSkills = useProviderWorkspaceSkills({ + environmentId, + instanceId: activeProviderStatus?.instanceId ?? null, + cwd: providerSkillsCwd, + enabled: timelineHasSkillReference, + connectionAvailable: providerSkillsConnectionAvailable, + fallbackSkills: activeProviderFallbackSkills, + }); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< string | null @@ -5698,7 +5727,7 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={activeProviderWorkspaceSkills.skills} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} @@ -5830,6 +5859,8 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} + providerSkillsCwd={providerSkillsCwd} + providerSkillsConnectionAvailable={providerSkillsConnectionAvailable} promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b427037bdf7..d26267e8153 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -42,6 +42,7 @@ import { replaceTextRange, shouldSubmitComposerOnEnter, } from "../../composer-logic"; +import { promptHasComposerSkillReference } from "../../composer-editor-mentions"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { dataTransferHasComposerMention, @@ -62,6 +63,7 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { useProviderWorkspaceSkills } from "../../lib/providerWorkspaceSkillsState"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; @@ -192,6 +194,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const runtimeModeConfig: Record< RuntimeMode, @@ -571,6 +574,8 @@ export interface ChatComposerProps { keybindings: ResolvedKeybindingsConfig; terminalOpen: boolean; gitCwd: string | null; + providerSkillsCwd: string | null; + providerSkillsConnectionAvailable: boolean; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -661,6 +666,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) keybindings, terminalOpen, gitCwd, + providerSkillsCwd, + providerSkillsConnectionAvailable, promptRef, composerRef, composerImagesRef, @@ -860,6 +867,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const selectedProviderFallbackSkills = selectedProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -869,6 +877,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => getComposerPromptInjectionState(prompt), [prompt], ); + const promptHasSkillReference = useMemo(() => promptHasComposerSkillReference(prompt), [prompt]); const composerProviderState = useMemo( () => getComposerProviderState({ @@ -1016,6 +1025,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); + const providerWorkspaceSkills = useProviderWorkspaceSkills({ + environmentId, + instanceId: selectedProviderStatus?.instanceId ?? null, + cwd: providerSkillsCwd, + enabled: composerTriggerKind === "skill" || promptHasSkillReference, + connectionAvailable: providerSkillsConnectionAvailable, + fallbackSkills: selectedProviderFallbackSkills, + }); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -1071,7 +1088,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( + return searchProviderSkills(providerWorkspaceSkills.skills, composerTrigger.query).map( (skill) => ({ id: `skill:${selectedProvider}:${skill.name}`, type: "skill" as const, @@ -1086,7 +1103,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + providerWorkspaceSkills.skills, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1151,15 +1174,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "skill" && providerWorkspaceSkills.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { - return "No skills found. Try / to browse provider commands."; + return providerWorkspaceSkills.error ?? "No skills found. Try / to browse provider commands."; } return composerTriggerKind === "path" ? "No matching files or folders." : "No matching command."; - }, [composerTriggerKind]); + }, [composerTriggerKind, providerWorkspaceSkills.error]); // ------------------------------------------------------------------ // Provider traits UI @@ -2414,6 +2438,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTrigger.query.trim().length === 0 } emptyStateText={composerMenuEmptyState} + errorText={composerTriggerKind === "skill" ? providerWorkspaceSkills.error : null} activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlighted} onSelect={onSelectComposerItem} @@ -2559,7 +2584,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={providerWorkspaceSkills.skills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx new file mode 100644 index 00000000000..0d642cfc227 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx @@ -0,0 +1,41 @@ +import { ProviderDriverKind, type ServerProviderSkill } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { ComposerCommandMenu, type ComposerCommandItem } from "./ComposerCommandMenu"; + +const fallbackSkill: ServerProviderSkill = { + name: "provider-fallback", + path: "/skills/provider-fallback/SKILL.md", + enabled: true, +}; + +const fallbackSkillItem: ComposerCommandItem = { + id: "skill:provider-fallback", + type: "skill", + provider: ProviderDriverKind.make("codex"), + skill: fallbackSkill, + label: "provider-fallback", + description: "Provider snapshot skill", +}; + +describe("ComposerCommandMenu", () => { + it("surfaces workspace skill errors alongside fallback skills", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain("Failed to load workspace skills."); + expect(markup).toContain("provider-fallback"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 9021b6b4609..51d5d2756a9 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -110,6 +110,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { triggerKind: ComposerTriggerKind | null; groupSlashCommandSections?: boolean; emptyStateText?: string; + errorText?: string | null; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; @@ -140,6 +141,11 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { }} >
+ {props.errorText ? ( +

+ {props.errorText} +

+ ) : null} {props.items.length > 0 ? ( {groups.map((group, groupIndex) => ( @@ -165,7 +171,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
))} - ) : ( + ) : props.errorText ? null : (
{props.triggerKind === "skill" ? ( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 296a54457a2..0bf5dd0cc63 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -436,7 +436,33 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsible="false"'); }); - it("renders inline terminal labels with the composer chip UI", () => { + it("renders local skill references as chips in user message bubbles", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Update Main"); + expect(markup).toContain("Piz Git Workflow"); + expect(markup).toContain('data-markdown-copy="$update-main"'); + expect(markup).toContain('data-markdown-copy="$piz-git-workflow"'); + }); + + it("renders inline terminal labels with the composer chip UI", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( ; @@ -18,11 +17,7 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray candidate.name === name); if (!skill) { continue; diff --git a/apps/web/src/components/chat/skillInlineTokens.ts b/apps/web/src/components/chat/skillInlineTokens.ts new file mode 100644 index 00000000000..f6513e95a13 --- /dev/null +++ b/apps/web/src/components/chat/skillInlineTokens.ts @@ -0,0 +1,5 @@ +export { + hasInlineSkillToken, + parseInlineSkillTokens, + type InlineSkillToken, +} from "@t3tools/shared/skillInlineTokens"; diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 70c97400370..46617efea60 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + promptHasComposerSkillReference, selectionTouchesMentionBoundary, splitPromptIntoComposerSegments, } from "./composer-editor-mentions"; @@ -167,6 +168,22 @@ describe("splitPromptIntoComposerSegments", () => { }); }); +describe("promptHasComposerSkillReference", () => { + it("returns true only when the prompt contains a complete skill token", () => { + expect(promptHasComposerSkillReference("Use $review-follow-up please")).toBe(true); + expect(promptHasComposerSkillReference("Use $review-follow-up")).toBe(false); + expect(promptHasComposerSkillReference("Read @AGENTS.md please")).toBe(false); + }); + + it("ignores terminal context placeholders while checking text slices", () => { + expect( + promptHasComposerSkillReference( + `Inspect ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}$review-follow-up please`, + ), + ).toBe(true); + }); +}); + describe("selectionTouchesMentionBoundary", () => { it("returns true when selection includes the whitespace after a mention", () => { expect( diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 8b9a53808f8..d1ce659b98d 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -221,3 +221,13 @@ export function splitPromptIntoComposerSegments( return segments; } + +export function promptHasComposerSkillReference(prompt: string): boolean { + if (!prompt) { + return false; + } + + return forEachPromptTextSlice(prompt, (text) => + collectComposerInlineTokens(text).some((match) => match.type === "skill"), + ); +} diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts new file mode 100644 index 00000000000..a35940edebe --- /dev/null +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -0,0 +1,413 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { ServerProviderSkillsListError } from "@t3tools/contracts"; +import { + formatProviderWorkspaceSkillsError, + resolveNextProviderWorkspaceSkillsSnapshot, + resolvePendingProviderWorkspaceSkills, + resolveProviderWorkspaceSkills, +} from "@t3tools/client-runtime/state/provider-workspace-skills"; +import * as Cause from "effect/Cause"; +import { describe, expect, it } from "vite-plus/test"; + +function skill(name: string): ServerProviderSkill { + return { + name, + path: `/skills/${name}/SKILL.md`, + enabled: true, + }; +} + +describe("resolvePendingProviderWorkspaceSkills", () => { + it("preserves current skills while refreshing the same workspace key", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolvePendingProviderWorkspaceSkills({ + currentKey: "environment:codex:/repo", + nextKey: "environment:codex:/repo", + currentSkills, + }), + ).toBe(currentSkills); + }); + + it("does not expose previous or snapshot skills while a different workspace key is pending", () => { + const pendingSkills = resolvePendingProviderWorkspaceSkills({ + currentKey: "environment:codex:/old-repo", + nextKey: "environment:codex:/new-repo", + currentSkills: [skill("old-repo-skill"), skill("snapshot-skill")], + }); + + expect(pendingSkills).toEqual([]); + }); +}); + +describe("formatProviderWorkspaceSkillsError", () => { + it("appends structured provider skill diagnostics without putting cause text in the wrapper message", () => { + const error = new ServerProviderSkillsListError({ + reason: "invalid-cwd", + operation: "ProviderSkillsLister.normalizeCwd", + message: "Invalid Codex skills cwd '/missing'.", + detail: "Workspace root does not exist: /missing.", + cause: new Error("raw platform detail"), + }); + + expect( + formatProviderWorkspaceSkillsError({ + error: error.message, + cause: Cause.fail(error), + }), + ).toBe("Invalid Codex skills cwd '/missing'. Workspace root does not exist: /missing."); + expect(error.message).not.toContain("raw platform detail"); + }); + + it("preserves the query wrapper message when structured detail is available", () => { + const error = new ServerProviderSkillsListError({ + reason: "home-prepare-failed", + operation: "ProviderSkillsLister.prepareCodexHome", + message: "Failed to prepare Codex home for 'codex'.", + detail: "Check the configured Codex home paths and filesystem permissions.", + }); + + expect( + formatProviderWorkspaceSkillsError({ + error: "Environment request failed: Failed to prepare Codex home for 'codex'.", + cause: Cause.fail(error), + }), + ).toBe( + "Environment request failed: Failed to prepare Codex home for 'codex'. Check the configured Codex home paths and filesystem permissions.", + ); + }); + + it("leaves provider skill errors without detail unchanged", () => { + const error = new ServerProviderSkillsListError({ + reason: "probe-failed", + operation: "ProviderSkillsLister.listCodexProviderSkills", + message: "Failed to list Codex skills.", + }); + + expect( + formatProviderWorkspaceSkillsError({ + error: error.message, + cause: Cause.fail(error), + }), + ).toBe("Failed to list Codex skills."); + }); +}); + +describe("resolveProviderWorkspaceSkills", () => { + it("uses loaded skills as soon as workspace data is available", () => { + const loadedSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: false, + error: null, + currentKey: null, + currentSkills: [], + fallbackSkills: [skill("provider-fallback")], + }), + ).toBe(loadedSkills); + }); + + it("uses loaded skills even when the query is still pending", () => { + const loadedSkills = [skill("repo-local")]; + const currentSkills = [skill("stale-repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: true, + error: null, + currentKey: "environment:codex:/repo", + currentSkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toBe(loadedSkills); + }); + + it("uses fallback skills for empty loaded workspace skills", () => { + const loadedSkills: ReadonlyArray = []; + const fallbackSkills = [skill("provider-fallback")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: true, + error: null, + currentKey: "environment:codex:/repo", + currentSkills: [skill("repo-local")], + fallbackSkills, + }), + ).toBe(fallbackSkills); + }); + + it("preserves current skills while refreshing the same workspace", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: true, + error: null, + currentKey: "environment:codex:/repo", + currentSkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toBe(currentSkills); + }); + + it("clears current skills while loading a different workspace", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/new-repo", + nextSkills: null, + isPending: true, + error: null, + currentKey: "environment:codex:/old-repo", + currentSkills: [skill("old-repo-skill")], + fallbackSkills: [skill("provider-fallback")], + }), + ).toEqual([]); + }); + + it("settles unavailable lookups with same-workspace skills or provider fallback", () => { + const currentSkills = [skill("repo-local")]; + const fallbackSkills = [skill("provider-fallback")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "environment:codex:/repo", + currentSkills, + fallbackSkills, + }), + ).toBe(currentSkills); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/other-repo", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "environment:codex:/repo", + currentSkills, + fallbackSkills, + }), + ).toBe(fallbackSkills); + }); + + it("does not leak skills during rapid workspace switches", () => { + const repoASkills = [skill("repo-a")]; + const repoBSkills = [skill("repo-b")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-b", + nextSkills: null, + isPending: true, + error: null, + currentKey: "environment:codex:/repo-a", + currentSkills: repoASkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toEqual([]); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-a", + nextSkills: null, + isPending: true, + error: null, + currentKey: "environment:codex:/repo-b", + currentSkills: repoBSkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toEqual([]); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-a", + nextSkills: null, + isPending: true, + error: null, + currentKey: "environment:codex:/repo-a", + currentSkills: repoASkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toBe(repoASkills); + }); + + it("clears skills after a non-pending query with no data", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: false, + error: null, + currentKey: "environment:codex:/repo", + currentSkills: [skill("repo-local")], + fallbackSkills: [skill("provider-fallback")], + }), + ).toEqual([]); + }); + + it("uses fallback skills after a query error", () => { + const fallbackSkills = [skill("provider-fallback")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: false, + error: "Invalid git cwd", + currentKey: "environment:codex:/repo", + currentSkills: [], + fallbackSkills, + }), + ).toBe(fallbackSkills); + }); +}); + +describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { + it("stores settled workspace skills for the active key", () => { + const loadedSkills = [skill("repo-local")]; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: loadedSkills, + isPending: false, + error: null, + current: null, + }), + ).toEqual({ + key: "environment:codex:/repo", + skills: loadedSkills, + }); + }); + + it("preserves the current snapshot while pending", () => { + const current = { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: [skill("fresh-repo-local")], + isPending: true, + error: null, + current, + }), + ).toBe(current); + }); + + it("clears the snapshot when the target is disabled", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: null, + skills: [skill("repo-local")], + isPending: false, + error: null, + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); + + it("preserves only the same target snapshot while lookup is inactive", () => { + const current = { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: null, + isPending: false, + error: null, + inactive: true, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/other-repo", + skills: null, + isPending: false, + error: null, + inactive: true, + current, + }), + ).toBeNull(); + }); + + it("clears the snapshot after a settled query without data", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: null, + isPending: false, + error: null, + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); + + it("clears stale workspace skills after a failed refresh", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: [skill("stale-repo-local")], + isPending: false, + error: "Failed to list skills.", + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); + + it("preserves only the active workspace snapshot while unavailable", () => { + const current = { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: null, + isPending: false, + error: null, + unavailable: true, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/other-repo", + skills: null, + isPending: false, + error: null, + unavailable: true, + current, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts new file mode 100644 index 00000000000..ead957b1697 --- /dev/null +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -0,0 +1,88 @@ +import { + EMPTY_PROVIDER_WORKSPACE_SKILLS, + formatProviderWorkspaceSkillsError, + PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE, + providerWorkspaceSkillsTargetKey, + resolveNextProviderWorkspaceSkillsSnapshot, + resolveProviderWorkspaceSkills, + type ProviderWorkspaceSkillsSnapshot, + type ProviderWorkspaceSkillsState, + type ProviderWorkspaceSkillsTarget, +} from "@t3tools/client-runtime/state/provider-workspace-skills"; +import { useEffect, useMemo, useRef } from "react"; + +import { serverEnvironment } from "../state/server"; +import { useEnvironmentQuery } from "../state/query"; + +export function useProviderWorkspaceSkills( + target: ProviderWorkspaceSkillsTarget, +): ProviderWorkspaceSkillsState { + const stableTarget = useMemo( + () => ({ + environmentId: target.environmentId, + instanceId: target.instanceId, + cwd: target.cwd?.trim() || null, + enabled: target.enabled, + connectionAvailable: target.connectionAvailable !== false, + }), + [ + target.connectionAvailable, + target.cwd, + target.enabled, + target.environmentId, + target.instanceId, + ], + ); + const targetKey = providerWorkspaceSkillsTargetKey({ ...stableTarget, enabled: true }); + const key = stableTarget.enabled ? targetKey : null; + const unavailable = key !== null && !stableTarget.connectionAvailable; + const query = useEnvironmentQuery( + key !== null && + !unavailable && + stableTarget.environmentId !== null && + stableTarget.instanceId !== null + ? serverEnvironment.providerSkills({ + environmentId: stableTarget.environmentId, + input: { + instanceId: stableTarget.instanceId, + cwd: stableTarget.cwd!, + }, + }) + : null, + ); + + const previousWorkspaceSkillsRef = useRef(null); + const querySkills = query.data?.skills ?? null; + useEffect(() => { + previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ + key: targetKey, + skills: querySkills, + isPending: query.isPending, + error: query.error, + inactive: key === null, + unavailable, + current: previousWorkspaceSkillsRef.current, + }); + }, [key, query.error, query.isPending, querySkills, targetKey, unavailable]); + + if (key === null) { + return { skills: target.fallbackSkills, isPending: false, error: null }; + } + const previousWorkspaceSkills = previousWorkspaceSkillsRef.current; + return { + skills: resolveProviderWorkspaceSkills({ + nextKey: key, + nextSkills: querySkills, + isPending: query.isPending, + error: query.error, + unavailable, + currentKey: previousWorkspaceSkills?.key ?? null, + currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_PROVIDER_WORKSPACE_SKILLS, + fallbackSkills: target.fallbackSkills, + }), + isPending: unavailable ? false : query.isPending, + error: unavailable + ? PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE + : formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), + }; +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 2fbf183f91b..e93b747093f 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -66,6 +66,7 @@ function createBrowserLocalApi(): LocalApi { server: { getConfig: () => Promise.reject(unavailableLocalBackendError()), refreshProviders: () => Promise.reject(unavailableLocalBackendError()), + listProviderSkills: () => Promise.reject(unavailableLocalBackendError()), updateProvider: () => Promise.reject(unavailableLocalBackendError()), upsertKeybinding: () => Promise.reject(unavailableLocalBackendError()), removeKeybinding: () => Promise.reject(unavailableLocalBackendError()), diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index 2610f1724a0..5ff8437f1fa 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -10,6 +10,7 @@ const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(fals export interface EnvironmentQueryView { readonly data: A | null; readonly error: string | null; + readonly errorCause: Cause.Cause | null; readonly isPending: boolean; readonly refresh: () => void; } @@ -30,6 +31,7 @@ export function useEnvironmentQuery( return { data: Option.getOrNull(AsyncResult.value(result)), error: result._tag === "Failure" ? formatError(result.cause) : null, + errorCause: result._tag === "Failure" ? result.cause : null, isPending: atom !== null && result.waiting, refresh, }; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 4fa05f850e5..b019c0dc274 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -79,6 +79,10 @@ "types": "./src/state/preview.ts", "default": "./src/state/preview.ts" }, + "./state/provider-workspace-skills": { + "types": "./src/state/providerWorkspaceSkills.ts", + "default": "./src/state/providerWorkspaceSkills.ts" + }, "./state/projects": { "types": "./src/state/projects.ts", "default": "./src/state/projects.ts" diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts new file mode 100644 index 00000000000..0f20227a714 --- /dev/null +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts @@ -0,0 +1,292 @@ +import { + EnvironmentId, + ProviderInstanceId, + ServerProviderSkillsListError, + type ServerProviderSkill, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { describe, expect, it } from "vite-plus/test"; + +import { + formatProviderWorkspaceSkillsError, + providerWorkspaceSkillsTargetKey, + resolveNextProviderWorkspaceSkillsSnapshot, + resolveProviderWorkspaceSkills, +} from "./providerWorkspaceSkills.ts"; + +function skill(name: string): ServerProviderSkill { + return { + name, + path: `/skills/${name}/SKILL.md`, + enabled: true, + }; +} + +describe("providerWorkspaceSkillsTargetKey", () => { + it("normalizes an enabled environment, provider, and cwd target", () => { + expect( + providerWorkspaceSkillsTargetKey({ + environmentId: EnvironmentId.make("local"), + instanceId: ProviderInstanceId.make("codex"), + cwd: " /repo/worktree ", + enabled: true, + }), + ).toBe("local:codex:/repo/worktree"); + }); + + it("disables workspace queries without a usable cwd", () => { + expect( + providerWorkspaceSkillsTargetKey({ + environmentId: EnvironmentId.make("local"), + instanceId: ProviderInstanceId.make("codex"), + cwd: " ", + enabled: true, + }), + ).toBeNull(); + }); +}); + +describe("resolveProviderWorkspaceSkills", () => { + it("preserves loaded skills while the same workspace refreshes", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo", + nextSkills: null, + isPending: true, + error: null, + currentKey: "local:codex:/repo", + currentSkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toBe(currentSkills); + }); + + it("does not leak skills across workspace switches", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo-b", + nextSkills: null, + isPending: true, + error: null, + currentKey: "local:codex:/repo-a", + currentSkills: [skill("repo-a")], + fallbackSkills: [skill("provider-fallback")], + }), + ).toEqual([]); + }); + + it("preserves verified same-workspace skills while the environment is unavailable", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "local:codex:/repo", + currentSkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toBe(currentSkills); + }); + + it("uses provider fallback for an empty same-workspace snapshot while unavailable", () => { + const fallbackSkills = [skill("provider-fallback")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "local:codex:/repo", + currentSkills: [], + fallbackSkills, + }), + ).toBe(fallbackSkills); + }); + + it("uses provider fallback skills when a different workspace is unavailable", () => { + const fallbackSkills = [skill("provider-fallback")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo-b", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "local:codex:/repo-a", + currentSkills: [skill("repo-a")], + fallbackSkills, + }), + ).toBe(fallbackSkills); + }); + + it("uses provider snapshot skills for empty and failed workspace responses", () => { + const fallbackSkills = [skill("provider-fallback")]; + const base = { + nextKey: "local:codex:/repo", + currentKey: null, + currentSkills: [], + fallbackSkills, + } as const; + + expect( + resolveProviderWorkspaceSkills({ + ...base, + nextSkills: [], + isPending: false, + error: null, + }), + ).toBe(fallbackSkills); + expect( + resolveProviderWorkspaceSkills({ + ...base, + // Effect AsyncResult failures retain the previous success, so query + // consumers can receive stale data and an error at the same time. + nextSkills: [skill("stale-workspace-skill")], + isPending: false, + error: "Failed to list skills.", + }), + ).toBe(fallbackSkills); + }); +}); + +describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { + it("preserves only the same target snapshot while lookup is inactive", () => { + const current = { + key: "local:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: null, + isPending: false, + error: null, + inactive: true, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "local:codex:/other-repo", + skills: null, + isPending: false, + error: null, + inactive: true, + current, + }), + ).toBeNull(); + }); + + it("keeps the settled snapshot during refresh and clears it when disabled", () => { + const current = { + key: "local:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: [skill("fresh-repo-local")], + isPending: true, + error: null, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: null, + skills: current.skills, + isPending: false, + error: null, + current, + }), + ).toBeNull(); + }); + + it("clears a different workspace snapshot while the next lookup is pending", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "local:codex:/repo-b", + skills: null, + isPending: true, + error: null, + current: { + key: "local:codex:/repo-a", + skills: [skill("repo-a")], + }, + }), + ).toBeNull(); + }); + + it("clears a stale snapshot after a failed refresh", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "local:codex:/repo", + skills: [skill("stale-repo-local")], + isPending: false, + error: "Failed to list skills.", + current: { + key: "local:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); + + it("retains only a same-workspace snapshot while unavailable", () => { + const current = { + key: "local:codex:/repo-a", + skills: [skill("repo-a")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: null, + isPending: false, + error: null, + unavailable: true, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "local:codex:/repo-b", + skills: null, + isPending: false, + error: null, + unavailable: true, + current, + }), + ).toBeNull(); + }); +}); + +describe("formatProviderWorkspaceSkillsError", () => { + it("adds bounded structured detail without exposing a raw cause", () => { + const error = new ServerProviderSkillsListError({ + reason: "invalid-cwd", + operation: "ProviderSkillsLister.normalizeCwd", + message: "Invalid Codex skills cwd '/missing'.", + detail: "Workspace root does not exist: /missing.", + cause: new Error("raw platform detail"), + }); + + expect( + formatProviderWorkspaceSkillsError({ + error: error.message, + cause: Cause.fail(error), + }), + ).toBe("Invalid Codex skills cwd '/missing'. Workspace root does not exist: /missing."); + }); +}); diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts new file mode 100644 index 00000000000..f682e57cd2d --- /dev/null +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -0,0 +1,141 @@ +import { + ServerProviderSkillsListError, + type EnvironmentId, + type ProviderInstanceId, + type ServerProviderSkill, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; + +export interface ProviderWorkspaceSkillsTarget { + readonly environmentId: EnvironmentId | null; + readonly instanceId: ProviderInstanceId | null; + readonly cwd: string | null; + readonly enabled: boolean; + readonly connectionAvailable?: boolean; + readonly fallbackSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsState { + readonly skills: ReadonlyArray; + readonly isPending: boolean; + readonly error: string | null; +} + +export interface ProviderWorkspaceSkillsSnapshotInput { + readonly currentKey: string | null; + readonly nextKey: string; + readonly currentSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsResolutionInput extends ProviderWorkspaceSkillsSnapshotInput { + readonly nextSkills: ReadonlyArray | null; + readonly isPending: boolean; + readonly error: string | null; + readonly unavailable?: boolean; + readonly fallbackSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsSnapshot { + readonly key: string; + readonly skills: ReadonlyArray; +} + +export const EMPTY_PROVIDER_WORKSPACE_SKILLS: ReadonlyArray = []; +export const PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE = + "Reconnect this environment to refresh workspace skills."; + +const isServerProviderSkillsListError = Schema.is(ServerProviderSkillsListError); + +export function providerWorkspaceSkillsTargetKey( + target: Omit, +): string | null { + if ( + !target.enabled || + target.environmentId === null || + target.instanceId === null || + target.cwd === null || + target.cwd.trim().length === 0 + ) { + return null; + } + return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; +} + +function providerSkillsListErrorDetail(error: unknown): { + readonly detail: string | null; +} | null { + if (!isServerProviderSkillsListError(error)) return null; + return { + detail: + typeof error.detail === "string" && error.detail.trim().length > 0 ? error.detail : null, + }; +} + +export function formatProviderWorkspaceSkillsError(input: { + readonly error: string | null; + readonly cause: Cause.Cause | null; +}): string | null { + if (input.error === null) return null; + if (input.cause === null) return input.error; + + const providerError = providerSkillsListErrorDetail(Cause.squash(input.cause)); + if (providerError === null || providerError.detail === null) return input.error; + if (input.error.includes(providerError.detail)) return input.error; + return `${input.error} ${providerError.detail}`; +} + +export function resolvePendingProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsSnapshotInput, +): ReadonlyArray { + return input.currentKey === input.nextKey && input.currentSkills.length > 0 + ? input.currentSkills + : EMPTY_PROVIDER_WORKSPACE_SKILLS; +} + +/** + * Query result arrays are readonly cache values, so these helpers preserve references + * and rely on callers to keep them immutable. + */ +export function resolveProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsResolutionInput, +): ReadonlyArray { + if (input.unavailable === true) { + const currentSkills = resolvePendingProviderWorkspaceSkills(input); + return currentSkills.length > 0 ? currentSkills : input.fallbackSkills; + } + // AsyncResult failures can retain a previous success for stale-while-revalidate. + // A failed workspace refresh must still fall back to the provider snapshot rather + // than keeping a stale workspace's skills selectable. + if (input.error !== null) return input.fallbackSkills; + if (input.nextSkills !== null) { + return input.nextSkills.length > 0 ? input.nextSkills : input.fallbackSkills; + } + if (!input.isPending) return EMPTY_PROVIDER_WORKSPACE_SKILLS; + // Do not use the provider-wide fallback while the workspace lookup is pending: + // it can contain repo-local skills discovered for a different cwd. This also + // keeps disconnected clients from presenting unverified workspace metadata. + return resolvePendingProviderWorkspaceSkills(input); +} + +export function resolveNextProviderWorkspaceSkillsSnapshot(input: { + readonly key: string | null; + readonly skills: ReadonlyArray | null; + readonly isPending: boolean; + readonly error: string | null; + readonly inactive?: boolean; + readonly unavailable?: boolean; + readonly current: ProviderWorkspaceSkillsSnapshot | null; +}): ProviderWorkspaceSkillsSnapshot | null { + if (input.key === null) return null; + const current = input.current?.key === input.key ? input.current : null; + if (input.inactive === true) { + return current; + } + if (input.unavailable === true) { + return current; + } + if (input.error !== null) return null; + if (input.skills === null) return input.isPending ? current : null; + return input.isPending ? current : { key: input.key, skills: input.skills }; +} diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 7584e55d52e..35da6705228 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -144,14 +144,19 @@ describe("environmentRpcKey", () => { const environmentId = EnvironmentId.make("environment-1"); const originalTarget = { environmentId, - input: { cwd: "/repo/original" }, + input: { instanceId: "codex", cwd: "/repo/original" }, }; const nextTarget = { environmentId, - input: { cwd: "/repo/next" }, + input: { instanceId: "codex", cwd: "/repo/next" }, + }; + const nextProviderTarget = { + environmentId, + input: { instanceId: "codex-secondary", cwd: "/repo/original" }, }; expect(environmentRpcKey(originalTarget)).not.toBe(environmentRpcKey(nextTarget)); + expect(environmentRpcKey(originalTarget)).not.toBe(environmentRpcKey(nextProviderTarget)); expect(environmentRpcKey(originalTarget)).toBe(environmentRpcKey({ ...originalTarget })); expect( environmentRpcKey({ diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index ea7f5fb6d75..3f8d19e5770 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -298,6 +298,11 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + providerSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:provider-skills", + tag: WS_METHODS.serverListProviderSkills, + staleTimeMs: 30_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4b1676d7926..636de9f7c35 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -37,6 +37,8 @@ import type { ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, ServerProcessResourceHistoryResult, + ServerProviderSkillsListInput, + ServerProviderSkillsListResult, ServerProviderUpdateInput, ServerProviderUpdatedPayload, ServerRemoveKeybindingResult, @@ -1143,6 +1145,9 @@ export interface LocalApi { refreshProviders: (input?: { readonly instanceId?: ProviderInstanceId; }) => Promise; + listProviderSkills: ( + input: ServerProviderSkillsListInput, + ) => Promise; updateProvider: (input: ServerProviderUpdateInput) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index fa2d23b8ef2..c8b729da93f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -121,6 +121,9 @@ import { ServerLifecycleStreamEvent, ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, + ServerProviderSkillsListError, + ServerProviderSkillsListInput, + ServerProviderSkillsListResult, ServerProviderUpdatedPayload, ServerSelfUpdateError, ServerSelfUpdateInput, @@ -207,6 +210,7 @@ export const WS_METHODS = { serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverListProviderSkills: "server.listProviderSkills", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpsertKeybinding: "server.upsertKeybinding", @@ -277,6 +281,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerListProviderSkillsRpc = Rpc.make(WS_METHODS.serverListProviderSkills, { + payload: ServerProviderSkillsListInput, + success: ServerProviderSkillsListResult, + error: Schema.Union([ServerProviderSkillsListError, EnvironmentAuthorizationError]), +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -702,6 +712,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerListProviderSkillsRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, WsServerUpsertKeybindingRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 69699c7a839..49c2d61bc13 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -92,6 +92,47 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderSkillsListInput = Schema.Struct({ + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, +}); +export type ServerProviderSkillsListInput = typeof ServerProviderSkillsListInput.Type; + +export const ServerProviderSkillsListResult = Schema.Struct({ + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderSkillsListResult = typeof ServerProviderSkillsListResult.Type; + +export const ServerProviderSkillsListFailureReason = Schema.Literals([ + "provider-not-found", + "provider-not-configured", + "settings-read-failed", + "settings-decode-failed", + "invalid-cwd", + "home-prepare-failed", + "probe-timeout", + "probe-failed", +]); +export type ServerProviderSkillsListFailureReason = + typeof ServerProviderSkillsListFailureReason.Type; + +export class ServerProviderSkillsListError extends Schema.TaggedErrorClass()( + "ServerProviderSkillsListError", + { + message: TrimmedNonEmptyString, + detail: Schema.optional(TrimmedNonEmptyString), + // Optional for backward-compatible decoding of older failure payloads. + reason: Schema.optional(ServerProviderSkillsListFailureReason), + // Optional for backward-compatible decoding of older failure payloads. + operation: Schema.optional(TrimmedNonEmptyString), + instanceId: Schema.optional(ProviderInstanceId), + cwd: Schema.optional(TrimmedNonEmptyString), + // Server producers should attach a bounded, plain diagnostic object here, + // not the raw thrown value. + cause: Schema.optional(Schema.Defect()), + }, +) {} + /** * Availability of a configured provider instance from the runtime's POV. * diff --git a/packages/shared/package.json b/packages/shared/package.json index 7d0cc5eb820..28844d9e67d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -159,6 +159,10 @@ "types": "./src/composerInlineTokens.ts", "import": "./src/composerInlineTokens.ts" }, + "./skillInlineTokens": { + "types": "./src/skillInlineTokens.ts", + "import": "./src/skillInlineTokens.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" diff --git a/packages/shared/src/skillInlineTokens.test.ts b/packages/shared/src/skillInlineTokens.test.ts new file mode 100644 index 00000000000..e5347c8863b --- /dev/null +++ b/packages/shared/src/skillInlineTokens.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hasInlineSkillToken, parseInlineSkillTokens } from "./skillInlineTokens.ts"; + +describe("parseInlineSkillTokens", () => { + it("recognizes sent skill references at sentence boundaries", () => { + expect(parseInlineSkillTokens("Use $update-main? Then run $commit.")).toEqual([ + { + name: "update-main", + rawText: "$update-main", + start: 4, + }, + { + name: "commit", + rawText: "$commit", + start: 27, + }, + ]); + }); + + it("rejects code-variable continuations", () => { + expect(parseInlineSkillTokens("echo $HOME/.codex or use PHP $value;")).toEqual([]); + expect(hasInlineSkillToken("echo $HOME/.codex")).toBe(false); + expect(hasInlineSkillToken("use PHP $value;")).toBe(false); + }); +}); diff --git a/packages/shared/src/skillInlineTokens.ts b/packages/shared/src/skillInlineTokens.ts new file mode 100644 index 00000000000..149e470a55e --- /dev/null +++ b/packages/shared/src/skillInlineTokens.ts @@ -0,0 +1,30 @@ +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=$|\s|[,.!?])/g; +const SKILL_TOKEN_TEST_REGEX = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=$|\s|[,.!?])/; + +export interface InlineSkillToken { + readonly name: string; + readonly rawText: string; + readonly start: number; +} + +export function parseInlineSkillTokens(text: string): ReadonlyArray { + const tokens: InlineSkillToken[] = []; + + // matchAll clones the global RegExp, so repeated calls do not share lastIndex state. + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const prefix = match[1] ?? ""; + const name = match[2]; + if (!name) continue; + tokens.push({ + name, + rawText: `$${name}`, + start: (match.index ?? 0) + prefix.length, + }); + } + + return tokens; +} + +export function hasInlineSkillToken(text: string): boolean { + return SKILL_TOKEN_TEST_REGEX.test(text); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 94248a911c3..7a2185f154c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ overrides: vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= +packageExtensionsChecksum: sha256-dL4kgB3oS88+usby/QZU7EK4kxNoz9EGcSH5yDZBXZM= patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -253,7 +253,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe) + version: file:apps/mobile/modules/t3-markdown-text(beb74b814261aa4192920293ac865a8e) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -422,7 +422,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -477,7 +477,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -622,7 +622,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) @@ -688,7 +688,7 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) + version: https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c) drizzle-orm: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -704,7 +704,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -732,7 +732,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -751,7 +751,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -764,7 +764,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -783,7 +783,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -805,7 +805,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -839,7 +839,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -864,7 +864,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -886,7 +886,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -917,7 +917,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -2018,6 +2018,10 @@ packages: resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==} peerDependencies: effect: 4.0.0-beta.78 + vitest: '*' + peerDependenciesMeta: + vitest: + optional: true '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} @@ -4521,6 +4525,7 @@ packages: '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} peerDependencies: + '@t3tools/shared': workspace:* expo-asset: '*' expo-clipboard: '*' expo-haptics: '*' @@ -11767,9 +11772,43 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0)': dependencies: effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + optionalDependencies: + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml '@egjs/hammerjs@2.0.17': dependencies: @@ -14564,8 +14603,9 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(beb74b814261aa4192920293ac865a8e)': dependencies: + '@t3tools/shared': link:packages/shared expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-clipboard: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 56.0.3(expo@56.0.12) @@ -15308,7 +15348,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): + alchemy@https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 @@ -15322,7 +15362,7 @@ snapshots: '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@smithy/node-config-provider': 4.4.6 @@ -15355,12 +15395,39 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' - '@types/node' - '@types/react' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw - pg-native + - publint - react-devtools-core + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun - utf-8-validate + - vitest - workerd alien-signals@2.0.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22757702fc8..5b02480a65d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,8 @@ packages: - packages/* - scripts +enableGlobalVirtualStore: true + # Successor to onlyBuiltDependencies/ignoredBuiltDependencies (pnpm 11). # true = allowed to run build scripts; false mirrors the pnpm 10 behavior # where anything outside onlyBuiltDependencies was silently not built. @@ -95,9 +97,11 @@ packageExtensions: "@clerk/expo@*": dependencies: "@expo/config-plugins": 56.0.9 - "@effect/vitest@*": + # Wildcard semver excludes prereleases. + "@effect/vitest@4.0.0-beta.78": dependencies: - vite-plus: "catalog:" + # The patched package imports vite-plus inside the shared graph. + vite-plus: 0.2.2 peerDependenciesMeta: vitest: optional: true