Skip to content
Closed
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
10 changes: 8 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -651,8 +651,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
? undefined
: currentModelSelection.instanceId;
const modelOptions = useMemo(
() => buildModelOptions(props.serverConfig, currentModelSelection, lockedProviderInstanceId),
[props.serverConfig, currentModelSelection, lockedProviderInstanceId],
() =>
buildModelOptions(
props.serverConfig,
currentModelSelection,
lockedProviderInstanceId,
props.projectCwd,
),
[props.serverConfig, currentModelSelection, lockedProviderInstanceId, props.projectCwd],
);
const threadProviderGroups = useMemo(() => groupByProvider(modelOptions), [modelOptions]);
const currentModelOption =
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/src/features/threads/new-task-flow-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -578,14 +578,21 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
selectedEnvironmentServerConfig,
storedStickyModelSelection,
);
const modelCwd =
(workspaceMode === "worktree"
? selectedProject?.workspaceRoot
: (selectedWorktreePath ?? selectedProject?.workspaceRoot)) || null;
const modelOptions = useMemo(
() =>
buildModelOptions(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
selectedEnvironmentServerConfig,
draftModelSelection ?? projectDefaultModelSelection ?? stickyModelSelection,
undefined,
modelCwd,
),
[
selectedEnvironmentServerConfig,
modelCwd,
draftModelSelection,
projectDefaultModelSelection,
stickyModelSelection,
Expand Down
5 changes: 4 additions & 1 deletion apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { resolveProviderForCwd } from "@t3tools/client-runtime/providerSkills";
import type { MenuAction } from "@react-native-menu/menu";
import type {
ModelCapabilities,
Expand Down Expand Up @@ -167,10 +168,12 @@ export function buildModelOptions(
config: T3ServerConfig | null | undefined,
fallbackModelSelection: ModelSelection | null,
providerInstanceId?: ModelSelection["instanceId"],
cwd?: string | null,
): ReadonlyArray<ModelOption> {
const options = new Map<string, ModelOption>();

for (const provider of config?.providers ?? []) {
for (const snapshot of config?.providers ?? []) {
const provider = resolveProviderForCwd(snapshot, cwd);
if (
(providerInstanceId !== undefined && provider.instanceId !== providerInstanceId) ||
!provider.enabled ||
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/provider/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ it.layer(
});

describe("ProviderRegistry.layer", () => {
it("stores workspace skills and commands without changing machine metadata", () => {
it("stores workspace skills, commands and options without changing machine metadata", () => {
const provider = {
instanceId: ProviderInstanceId.make("codex"),
driver: ProviderDriverKind.make("codex"),
Expand All @@ -639,7 +639,15 @@ it.layer(
checkedAt: "2026-03-25T00:01:00.000Z",
slashCommands: [{ name: "project" }],
skills: [{ name: "project", path: "/project/SKILL.md", enabled: true }],
} satisfies ServerProvider;
optionDescriptors: [
{
id: "agent",
label: "Agent",
type: "select",
options: [{ id: "readonly", label: "Readonly" }],
},
],
} satisfies ProviderWorkspaceSnapshot;

const result = ProviderRegistry.upsertProviderWorkspaceSnapshot(
provider,
Expand All @@ -649,12 +657,14 @@ it.layer(

assert.deepStrictEqual(result.slashCommands, provider.slashCommands);
assert.deepStrictEqual(result.skills, provider.skills);
assert.strictEqual(result.models, provider.models);
assert.deepStrictEqual(result.workspaceSnapshots, [
{
cwd: "/project",
checkedAt: scopedSnapshot.checkedAt,
slashCommands: scopedSnapshot.slashCommands,
skills: scopedSnapshot.skills,
optionDescriptors: scopedSnapshot.optionDescriptors,
},
]);

Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/provider/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ export function upsertProviderWorkspaceSnapshot(
: scopedSnapshot.slashCommands,
...(scopedSnapshot.slashCommandsPending ? { slashCommandsPending: true } : {}),
skills: scopedSnapshot.skills,
...(scopedSnapshot.optionDescriptors === undefined
? {}
: { optionDescriptors: scopedSnapshot.optionDescriptors }),
} satisfies NonNullable<ServerProvider["workspaceSnapshots"]>[number];
return {
...provider,
Expand Down
26 changes: 17 additions & 9 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,10 @@ import {
import { terminalEnvironment } from "../state/terminal";
import { threadEnvironment } from "../state/threads";
import { workspacePreparationRetryRunIds } from "@t3tools/client-runtime/state/turn-item-presentation";
import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
import {
resolveProviderForCwd,
resolveProviderSkillsForCwd,
} from "@t3tools/client-runtime/providerSkills";
import { vcsEnvironment } from "../state/vcs";
import { sourceControlEnvironment } from "../state/sourceControl";
import { useProjectClone } from "../state/projectClones";
Expand Down Expand Up @@ -3245,12 +3248,23 @@ export default function ChatView(props: ChatViewProps) {
versionMismatchThreadContinuation,
versionMismatchServerLabel,
]);
const gitCwd = activeProject
? projectScriptCwd({
project: { cwd: activeProject.workspaceRoot },
worktreePath: activeThread?.worktreePath ?? null,
})
: null;
const providerInstanceEntries = useMemo(
() =>
sortProviderInstanceEntries(
applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings),
applyProviderInstanceSettings(
deriveProviderInstanceEntries(
providerStatuses.map((provider) => resolveProviderForCwd(provider, gitCwd)),
),
settings,
),
),
[providerStatuses, settings],
[providerStatuses, settings, gitCwd],
);
const { selectedProviderEntry, requestedDriverKind } = useMemo(
() =>
Expand Down Expand Up @@ -4117,12 +4131,6 @@ export default function ChatView(props: ChatViewProps) {
: JSON.stringify([itemId, latestCheckpointCompletedAt]);
}, [serverVisibleTurnItems, turnDiffSummaries]);

const gitCwd = activeProject
? projectScriptCwd({
project: { cwd: activeProject.workspaceRoot },
worktreePath: activeThread?.worktreePath ?? null,
})
: null;
const gitStatusCwd = activeThread?.worktreePath ?? gitCwd;
const gitStatusQuery = useEnvironmentQuery(
gitStatusCwd === null
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1130,6 +1130,7 @@ import {
getProviderSkillsForSlashMenu,
hasCompleteProviderWorkspaceSnapshot,
hasCurrentProviderWorkspaceSnapshot,
resolveProviderForCwd,
resolveProviderSkillsForCwd,
resolveProviderSlashCommandsForCwd,
} from "@t3tools/client-runtime/providerSkills";
Expand Down Expand Up @@ -2122,12 +2123,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
// Instance-aware projection of the wire provider list. One entry per
// configured instance (default built-in + any custom `providerInstances.*`),
// sorted default-first per driver kind for a stable picker order.
const workspaceProviders = useMemo(
() => providerStatuses.map((provider) => resolveProviderForCwd(provider, gitCwd)),
[providerStatuses, gitCwd],
);
const providerInstanceEntries = useMemo<ReadonlyArray<ProviderInstanceEntry>>(
() =>
sortProviderInstanceEntries(
applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings),
applyProviderInstanceSettings(deriveProviderInstanceEntries(workspaceProviders), settings),
),
[providerStatuses, settings],
[workspaceProviders, settings],
);
const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
const {
Expand Down Expand Up @@ -2197,7 +2202,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)

const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
threadRef: composerDraftTarget,
providers: providerStatuses,
providers: workspaceProviders,
selectedProvider,
selectedInstanceId,
threadModelSelection: activeThreadModelSelection,
Expand Down
62 changes: 62 additions & 0 deletions packages/client-runtime/src/providerSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getProviderSkillsForSlashMenu,
hasCompleteProviderWorkspaceSnapshot,
hasCurrentProviderWorkspaceSnapshot,
resolveProviderForCwd,
resolveProviderSkillsForCwd,
resolveProviderSlashCommandsForCwd,
resolveProviderSkillSourceKind,
Expand Down Expand Up @@ -304,3 +305,64 @@ describe("workspace provider snapshots", () => {
);
});
});

describe("resolveProviderForCwd", () => {
it("replaces only workspace option ids and leaves the global catalog untouched", () => {
const variant = {
id: "variant",
label: "Reasoning",
type: "select" as const,
options: [{ id: "high", label: "High" }],
};
const globalAgent = {
id: "agent",
label: "Agent",
type: "select" as const,
options: [{ id: "build", label: "Build" }],
};
const catalog: ServerProvider = {
...provider,
models: [
{
slug: "openai/gpt-5.4",
name: "GPT-5.4",
isCustom: false,
capabilities: { optionDescriptors: [variant, globalAgent] },
},
{ slug: "custom/model", name: "Custom", isCustom: true, capabilities: null },
],
workspaceSnapshots: ["readonly", "audit"].map((name) => ({
...provider.workspaceSnapshots[0]!,
cwd: "/workspace/" + name,
optionDescriptors: [{ ...globalAgent, options: [{ id: name, label: name }] }],
})),
};
for (const name of ["readonly", "audit"]) {
const scoped = resolveProviderForCwd(catalog, "/workspace/" + name);
expect(scoped.models[0]?.capabilities?.optionDescriptors).toEqual([
variant,
{ ...globalAgent, options: [{ id: name, label: name }] },
]);
expect(scoped.models[1]?.capabilities?.optionDescriptors).toEqual([
{ ...globalAgent, options: [{ id: name, label: name }] },
]);
}
expect(catalog.models[0]?.capabilities?.optionDescriptors).toEqual([variant, globalAgent]);
expect(resolveProviderForCwd(catalog, "/workspace/unknown")).toBe(catalog);
expect(resolveProviderForCwd(catalog, null)).toBe(catalog);
expect(resolveProviderForCwd(provider, "/workspace/project-a")).toBe(provider);
const emptyAgents: ServerProvider = {
...catalog,
workspaceSnapshots: [
{
...provider.workspaceSnapshots[0]!,
optionDescriptors: [{ ...globalAgent, options: [] }],
},
],
};
expect(
resolveProviderForCwd(emptyAgents, "/workspace/project-a").models[0]?.capabilities
?.optionDescriptors,
).toEqual([variant, { ...globalAgent, options: [] }]);
});
});
25 changes: 25 additions & 0 deletions packages/client-runtime/src/providerSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,31 @@ export function hasCurrentProviderWorkspaceSnapshot(
);
}

/** Applies workspace options without changing the provider's machine-wide catalog. */
export function resolveProviderForCwd(
provider: ServerProvider,
cwd: string | null | undefined,
): ServerProvider {
const descriptors = resolveProviderWorkspaceSnapshot(provider, cwd)?.optionDescriptors;
if (descriptors === undefined) return provider;
const overridden = new Set(descriptors.map((descriptor) => descriptor.id));
return {
...provider,
models: provider.models.map((model) => ({
...model,
capabilities: {
...model.capabilities,
optionDescriptors: [
...(model.capabilities?.optionDescriptors ?? []).filter(
(descriptor) => !overridden.has(descriptor.id),
),
...descriptors,
],
},
})),
};
}

export function resolveProviderSkillsForCwd(
provider: ServerProvider,
cwd: string | null | undefined,
Expand Down
4 changes: 3 additions & 1 deletion packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
ResolvedKeybindingsConfig,
} from "./keybindings.ts";
import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts";
import { ModelCapabilities } from "./model.ts";
import { ModelCapabilities, ProviderOptionDescriptor } from "./model.ts";
import { RuntimeMode } from "./providerPolicy.ts";
import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts";
import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts";
Expand Down Expand Up @@ -141,6 +141,8 @@ export const ServerProviderWorkspaceSnapshot = Schema.Struct({
/** Skills are available, but command discovery still needs a retry. */
slashCommandsPending: Schema.optional(Schema.Boolean),
skills: Schema.Array(ServerProviderSkill),
/** Workspace options replace descriptors with the same id in the model catalog. */
optionDescriptors: Schema.optional(Schema.Array(ProviderOptionDescriptor)),
});
export type ServerProviderWorkspaceSnapshot = typeof ServerProviderWorkspaceSnapshot.Type;

Expand Down
2 changes: 1 addition & 1 deletion packages/provider-core/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export interface ProviderDriverMetadata {
}

export type ProviderWorkspaceSnapshot = ServerProvider &
Pick<ServerProviderWorkspaceSnapshot, "slashCommandsPending">;
Pick<ServerProviderWorkspaceSnapshot, "slashCommandsPending" | "optionDescriptors">;

/**
* One materialized provider instance. Held by the registry, looked up by
Expand Down
14 changes: 7 additions & 7 deletions packages/provider-opencode/src/server/OpenCodeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ export const loadOpenCodeCommands = (client: OpencodeClient) =>
),
);

/** Lists agents in the SDK client's directory, including project-local definitions. */
export const loadOpenCodeAgents = (client: OpencodeClient) =>
runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe(
Effect.map((result) => result.data ?? []),
);

export interface ParsedOpenCodeModelSlug {
readonly providerID: string;
readonly modelID: string;
Expand Down Expand Up @@ -939,12 +945,6 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
),
);

const loadAgents = (client: OpencodeClient) =>
runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe(
Effect.map((result) => result.data ?? []),
Effect.orElseSucceed((): ReadonlyArray<Agent> => []),
);

const loadOpenCodeSkills: OpenCodeRuntime["Service"]["loadOpenCodeSkills"] = (client) =>
runOpenCodeSdk("app.skills", (signal) => client.app.skills(undefined, { signal })).pipe(
Effect.map((result) =>
Expand All @@ -962,7 +962,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
Effect.all(
[
loadProviders(client),
loadAgents(client),
loadOpenCodeAgents(client).pipe(Effect.orElseSucceed((): ReadonlyArray<Agent> => [])),
loadSkills(client),
loadOpenCodeCommands(client).pipe(Effect.orElseSucceed(() => [])),
],
Expand Down
Loading
Loading