Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts";
import {
checkCodexProviderStatus,
listCodexProviderSkills,
makePendingCodexProvider,
} from "../Layers/CodexProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts";
Expand Down Expand Up @@ -199,6 +203,28 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
),
);

const listSkills = (cwd: string) =>
listCodexProviderSkills({
binaryPath: effectiveConfig.binaryPath,
homePath: effectiveConfig.homePath,
cwd,
environment: processEnv,
}).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.scoped,
Effect.timeout(Duration.seconds(10)),
Effect.catch((error) =>
Effect.logWarning("Codex workspace skill discovery failed; using global skills.", {
cwd,
error: String(error),
instanceId,
}).pipe(
Effect.andThen(snapshot.getSnapshot),
Effect.map((provider) => provider.skills),
),
),
);

return {
instanceId,
driverKind: DRIVER_KIND,
Expand All @@ -209,6 +235,7 @@ export const CodexDriver: ProviderDriver<CodexSettings, CodexDriverEnv> = {
snapshot,
adapter,
textGeneration,
listSkills,
} satisfies ProviderInstance;
}),
};
43 changes: 29 additions & 14 deletions apps/server/src/provider/Layers/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,13 +286,16 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams {
};
}

const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: {
interface CodexAppServerProbeInput {
readonly binaryPath: string;
readonly homePath?: string;
readonly cwd: string;
readonly customModels?: ReadonlyArray<string>;
readonly environment?: NodeJS.ProcessEnv;
}) {
}

const openCodexAppServerProbe = Effect.fn("openCodexAppServerProbe")(function* (
input: CodexAppServerProbeInput,
) {
// `~` is not shell-expanded when env vars are set via `child_process.spawn`,
// so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip
// "CODEX_HOME points to '~/.codex_work', but that path does not exist".
Expand Down Expand Up @@ -331,22 +334,32 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
Effect.provide(clientContext),
);

const initialize = yield* client.request("initialize", {
clientInfo: {
name: "t3code_desktop",
title: "T3 Code Desktop",
version: "0.1.0",
},
capabilities: {
experimentalApi: true,
},
});
const initialize = yield* client.request("initialize", buildCodexInitializeParams());
yield* client.notify("initialized", undefined);

// Extract the version string after the first '/' in userAgent, up to the next space or the end
const versionMatch = initialize.userAgent.match(/\/([^\s]+)/);
const version = versionMatch ? versionMatch[1] : undefined;

return { client, version };
});

export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(function* (input: {
readonly binaryPath: string;
readonly homePath?: string;
readonly cwd: string;
readonly environment?: NodeJS.ProcessEnv;
}) {
const { client } = yield* openCodexAppServerProbe(input);
const response = yield* client.request("skills/list", { cwds: [input.cwd] });
return parseCodexSkillsListResponse(response, input.cwd);
});

const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (
input: CodexAppServerProbeInput & { readonly customModels?: ReadonlyArray<string> },
) {
const { client, version } = yield* openCodexAppServerProbe(input);

const accountResponse = yield* client.request("account/read", {});
if (!accountResponse.account && accountResponse.requiresOpenaiAuth) {
return {
Expand All @@ -371,7 +384,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
account: accountResponse,
version,
models: appendCustomCodexModels(models, input.customModels ?? []),
skills: parseCodexSkillsListResponse(skillsResponse, input.cwd),
skills: parseCodexSkillsListResponse(skillsResponse, input.cwd).filter(
(skill) => skill.scope !== "repo",
),
} satisfies CodexAppServerProviderSnapshot;
});

Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
]);
});

it.effect("does not run provider probes during layer construction", () =>
it.effect("does not probe at construction and routes cwd-scoped skill discovery", () =>
Effect.gen(function* () {
const codexDriver = ProviderDriverKind.make("codex");
const codexInstanceId = ProviderInstanceId.make("codex");
Expand All @@ -613,6 +613,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
skills: [],
} as const satisfies ServerProvider;
const refreshCalls = yield* Ref.make(0);
const skillCwds = yield* Ref.make<ReadonlyArray<string>>([]);
const workspaceSkill = {
name: "project-review",
path: "/tmp/project/.agents/skills/project-review/SKILL.md",
scope: "repo",
enabled: true,
} as const;
const instance = {
instanceId: codexInstanceId,
driverKind: codexDriver,
Expand All @@ -635,6 +642,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
},
adapter: {} as ProviderInstance["adapter"],
textGeneration: {} as ProviderInstance["textGeneration"],
listSkills: (cwd: string) =>
Ref.update(skillCwds, (cwds) => [...cwds, cwd]).pipe(Effect.as([workspaceSkill])),
} satisfies ProviderInstance;
const instanceRegistryLayer = Layer.succeed(
ProviderInstanceRegistry.ProviderInstanceRegistry,
Expand Down Expand Up @@ -664,6 +673,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
const registry = yield* ProviderRegistry.ProviderRegistry;
assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]);
assert.strictEqual(yield* Ref.get(refreshCalls), 0);
assert.deepStrictEqual(
yield* registry.listSkills({ instanceId: codexInstanceId, cwd: "/tmp/project" }),
[workspaceSkill],
);
assert.deepStrictEqual(yield* Ref.get(skillCwds), ["/tmp/project"]);
}).pipe(Effect.provide(runtimeServices));
}),
);
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,20 @@ export const ProviderRegistryLive = Layer.effect(
return yield* refreshOneSource(providerSource);
});

const listSkills = Effect.fn("listSkills")(function* (input: {
readonly instanceId: ProviderInstanceId;
readonly cwd: string;
}) {
const instance = yield* instanceRegistry.getInstance(input.instanceId);
if (instance === undefined) {
return [];
}
if (instance.listSkills !== undefined) {
return yield* instance.listSkills(input.cwd);
}
return (yield* instance.snapshot.getSnapshot).skills;
});

const getProviderMaintenanceCapabilitiesForInstance = Effect.fn(
"getProviderMaintenanceCapabilitiesForInstance",
)(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) {
Expand Down Expand Up @@ -688,6 +702,7 @@ export const ProviderRegistryLive = Layer.effect(
refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)),
refreshInstance: (instanceId: ProviderInstanceId) =>
refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)),
listSkills,
getProviderMaintenanceCapabilitiesForInstance,
setProviderMaintenanceActionState,
get streamChanges() {
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/provider/ProviderDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type {
ProviderDriverKind,
ProviderInstanceEnvironment,
ProviderInstanceId,
ServerProviderSkill,
} from "@t3tools/contracts";
import type * as Effect from "effect/Effect";
import type * as Schema from "effect/Schema";
Expand Down Expand Up @@ -71,6 +72,8 @@ export interface ProviderInstance {
readonly snapshot: ServerProviderShape;
readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
readonly textGeneration: TextGeneration.TextGeneration["Service"];
/** Resolve the skills visible from a workspace cwd. */
readonly listSkills?: (cwd: string) => Effect.Effect<ReadonlyArray<ServerProviderSkill>>;
}

export interface ProviderContinuationIdentity {
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/provider/Services/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
ProviderInstanceId,
ProviderDriverKind,
ServerProvider,
ServerProviderListSkillsInput,
ServerProviderSkill,
ServerProviderUpdateState,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -48,6 +50,11 @@ export interface ProviderRegistryShape {
instanceId: ProviderInstanceId,
) => Effect.Effect<ReadonlyArray<ServerProvider>>;

/** Resolve provider skills using the active workspace cwd. */
readonly listSkills: (
input: ServerProviderListSkillsInput,
) => Effect.Effect<ReadonlyArray<ServerProviderSkill>>;

/**
* Resolve the maintenance capabilities owned by one live provider instance.
* Falls back to manual-only capabilities when the instance is not live.
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/providerMaintenanceRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ function makeRegistry(

const registry: ProviderRegistryShape = {
getProviders: Ref.get(providersRef),
listSkills: () => Effect.succeed([]),
refresh: () => Ref.get(providersRef),
refreshInstance: () => Ref.get(providersRef),
getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/testUtils/providerRegistryMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const makeProviderRegistryMock = (
providers: ReadonlyArray<ServerProvider> = [],
): ProviderRegistryShape => ({
getProviders: Effect.succeed(providers),
listSkills: () => Effect.succeed([]),
refresh: () => Effect.succeed(providers),
refreshInstance: () => Effect.succeed(providers),
getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) =>
Expand Down
41 changes: 41 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4093,6 +4093,47 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket rpc server.listProviderSkills with workspace cwd", () =>
Effect.gen(function* () {
const instanceId = ProviderInstanceId.make("codex_work");
const skills = [
{
name: "project-review",
path: "/tmp/project/.agents/skills/project-review/SKILL.md",
scope: "repo",
enabled: true,
},
] as const;
let receivedInput: { readonly instanceId: ProviderInstanceId; readonly cwd: string } | null =
null;

yield* buildAppUnderTest({
layers: {
providerRegistry: {
listSkills: (input) =>
Effect.sync(() => {
receivedInput = input;
return skills;
}),
},
},
});

const wsUrl = yield* getWsServerUrl("/ws");
const response = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.serverListProviderSkills]({
instanceId,
cwd: "/tmp/project",
}),
),
);

assert.deepEqual(receivedInput, { instanceId, cwd: "/tmp/project" });
assert.deepEqual(response.skills, skills);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket rpc server.removeKeybinding", () =>
Effect.gen(function* () {
const rule: KeybindingRule = {
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope],
[WS_METHODS.serverGetConfig, AuthOrchestrationReadScope],
[WS_METHODS.serverListProviderSkills, AuthOrchestrationReadScope],
[WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope],
[WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope],
[WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope],
Expand Down Expand Up @@ -1251,6 +1252,12 @@ const makeWsRpcLayer = (
).pipe(Effect.map((providers) => ({ providers }))),
{ "rpc.aggregate": "server" },
),
[WS_METHODS.serverListProviderSkills]: (input) =>
observeRpcEffect(
WS_METHODS.serverListProviderSkills,
providerRegistry.listSkills(input).pipe(Effect.map((skills) => ({ skills }))),
{ "rpc.aggregate": "server" },
),
[WS_METHODS.serverUpdateProvider]: (input) =>
observeRpcEffect(
WS_METHODS.serverUpdateProvider,
Expand Down
44 changes: 29 additions & 15 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
removeInlineTerminalContextPlaceholder,
} from "../../lib/terminalContext";
import { useComposerPathSearch } from "../../lib/composerPathSearchState";
import { useProviderSkills } from "../../state/queries";
import { type ElementContextDraft } from "../../lib/elementContext";
import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts";
import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";
Expand Down Expand Up @@ -785,6 +786,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
() => selectedProviderEntry?.models ?? [],
[selectedProviderEntry],
);
const providerSkills = useProviderSkills({
environmentId,
instanceId: selectedInstanceId,
cwd: gitCwd,
});
const selectedProviderSkills =
providerSkills.data?.skills ??
selectedProviderStatus?.skills.filter((skill) => skill.scope !== "repo") ??
[];

const composerPromptInjectionState = useMemo(
() => getComposerPromptInjectionState(prompt),
Expand Down Expand Up @@ -990,22 +1000,26 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
return searchSlashCommandItems(slashCommandItems, query);
}
if (composerTrigger.kind === "skill") {
return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map(
(skill) => ({
id: `skill:${selectedProvider}:${skill.name}`,
type: "skill" as const,
provider: selectedProvider,
skill,
label: formatProviderSkillDisplayName(skill),
description:
skill.shortDescription ??
skill.description ??
(skill.scope ? `${skill.scope} skill` : "Run provider skill"),
}),
);
return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({
id: `skill:${selectedProvider}:${skill.name}`,
type: "skill" as const,
provider: selectedProvider,
skill,
label: formatProviderSkillDisplayName(skill),
description:
skill.shortDescription ??
skill.description ??
(skill.scope ? `${skill.scope} skill` : "Run provider skill"),
}));
}
return [];
}, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]);
}, [
composerTrigger,
selectedProvider,
selectedProviderSkills,
selectedProviderStatus,
workspaceEntries.entries,
]);

const composerMenuOpen = Boolean(composerTrigger);
const composerMenuSearchKey = composerTrigger
Expand Down Expand Up @@ -2396,7 +2410,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
? composerTerminalContexts
: []
}
skills={selectedProviderStatus?.skills ?? []}
skills={selectedProviderSkills}
{...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})}
onRemoveTerminalContext={removeComposerTerminalContextFromDraft}
onChange={onPromptChange}
Expand Down
Loading
Loading