Skip to content
Open
39 changes: 39 additions & 0 deletions packages/client-runtime/src/providerSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,45 @@ describe("workspace provider snapshots", () => {
expect(resolveProvidersForCwd(providers, "/workspace/project-a")[0]).toBe(provider);
expect(resolveProvidersForCwd(providers, "/workspace/project-b")).toBe(providers);
});

it("applies a workspace agent descriptor to that cwd's models", () => {
const agentDescriptor = {
id: "agent",
label: "Agent",
type: "select" as const,
options: [
{ id: "build", label: "Build", isDefault: true },
{ id: "reviewer", label: "Reviewer" },
],
currentValue: "build",
};
const projectModel = {
slug: "uni/medium-gpt",
name: "Medium GPT",
isCustom: false,
capabilities: {
optionDescriptors: [
{ id: "variant", label: "Reasoning", type: "select" as const, options: [] },
],
},
} satisfies ServerProvider["models"][number];
const withDescriptor = {
...provider,
workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({
...snapshot,
models: [projectModel],
optionDescriptors: [agentDescriptor],
})),
} satisfies ServerProvider;

const providers: ReadonlyArray<ServerProvider> = [provider, withDescriptor];
const resolved = resolveProvidersForCwd(providers, "/workspace/project-a");
expect(resolved[0]).toBe(provider);
expect(resolved[1]?.models[0]?.capabilities?.optionDescriptors).toEqual([
...(projectModel.capabilities?.optionDescriptors ?? []),
agentDescriptor,
]);
});
});

describe("provider workspace snapshot refresh atom", () => {
Expand Down
23 changes: 21 additions & 2 deletions packages/client-runtime/src/providerSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,31 @@ export function resolveProviderSlashCommandsForCwd(
return resolveProviderWorkspaceSnapshot(provider, cwd)?.slashCommands ?? provider.slashCommands;
}

/** A project's own provider config can add models that only its workspace snapshot lists. */
/**
* A project's own provider config can add models that only its workspace
* snapshot lists, and its agents are published as option descriptors.
*/
function resolveProviderModelsForCwd(
provider: ServerProvider,
cwd: string | null | undefined,
): ServerProvider["models"] {
return resolveProviderWorkspaceSnapshot(provider, cwd)?.models ?? provider.models;
const snapshot = resolveProviderWorkspaceSnapshot(provider, cwd);
const models = snapshot?.models ?? provider.models;
const descriptors = snapshot?.optionDescriptors;
if (descriptors === undefined) return models;
const overriddenIds = new Set(descriptors.map((descriptor) => descriptor.id));
return models.map((model) => ({
...model,
capabilities: {
...model.capabilities,
optionDescriptors: [
...(model.capabilities?.optionDescriptors ?? []).filter(
(descriptor) => !overriddenIds.has(descriptor.id),
),
...descriptors,
],
},
}));
}

/** Providers keep their identity unless the cwd has its own catalog. */
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-opencode/src/server/OpenCodeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ const loadOpenCodeProviders = (client: OpencodeClient) =>
),
);

const loadOpenCodeAgents = (client: OpencodeClient) =>
export const loadOpenCodeAgents = (client: OpencodeClient) =>
runOpenCodeSdk("app.agents", (signal) => client.app.agents(undefined, { signal })).pipe(
Effect.map((result): ReadonlyArray<Agent> => result.data ?? []),
);
Expand Down
135 changes: 134 additions & 1 deletion packages/provider-opencode/src/server/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,62 @@ const lazyMiseShim = {
} as unknown as OpenCodeRuntime.OpenCodeRuntime["Service"];

it.layer(layer)("OpenCodeDriver runtime selection", (it) => {
it.effect("lists each OpenCode 2 workspace's own agents", () =>
Effect.gen(function* () {
serverStarts.length = 0;
const requested: Array<string> = [];
const server = HttpClient.make((request, url) => {
const directory = url.searchParams.get("location[directory]");
const responses = {
...OPENCODE_2_RESPONSES,
...OPENCODE_2_WORKSPACE_RESPONSES,
"/api/agent": {
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory },
data: [
{ id: "build", mode: "primary", hidden: false },
{
id: directory === "/work" ? "reviewer" : "writer",
mode: "all",
hidden: false,
},
{ id: "internal", mode: "primary", hidden: true },
].map((agent) => ({
...agent,
name: agent.id,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
})),
}),
},
};
return replayOpenCodeServer(responses, "secret", requested).execute(request);
});
const instance = yield* create(
{ serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" },
server,
);

const workAgent = (yield* instance.snapshotForCwd!("/work")).optionDescriptors?.find(
(option) => option.id === "agent",
);
assert.deepStrictEqual(
workAgent?.type === "select" ? workAgent.options.map((option) => option.id) : undefined,
["build", "reviewer"],
);
const otherAgent = (yield* instance.snapshotForCwd!("/other")).optionDescriptors?.find(
(option) => option.id === "agent",
);
assert.deepStrictEqual(
otherAgent?.type === "select" ? otherAgent.options.map((option) => option.id) : undefined,
["build", "writer"],
);
assert.deepStrictEqual(serverStarts, []);
}).pipe(Effect.scoped),
);

it.effect("never starts a 1.x server for an OpenCode 2 instance", () =>
Effect.gen(function* () {
serverStarts.length = 0;
Expand Down Expand Up @@ -537,6 +593,50 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => {
}).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
);

it.effect("keeps workspace skills and commands when the agent endpoint stalls", () =>
Effect.gen(function* () {
const answers: Record<string, string> = {
"/api/info": OPENCODE_2_RESPONSES["/api/info"].body,
"/api/debug/location": "[]",
"/api/model": '{"location":{"directory":"/work"},"data":[]}',
"/api/provider": '{"location":{"directory":"/work"},"data":[]}',
"/api/command": OPENCODE_2_WORKSPACE_RESPONSES["/api/command"].body,
"/api/skill": OPENCODE_2_WORKSPACE_RESPONSES["/api/skill"].body,
};
const http = HttpClient.make((request) => {
const path = new URL(request.url).pathname;
if (path === "/api/agent") return Effect.never;
if (path === "/api/event")
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(new ReadableStream(), {
headers: { "content-type": "text/event-stream" },
}),
),
);
const body = answers[path];
return body === undefined
? Effect.die(`Unexpected request: ${path}`)
: Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(JSON.parse(body))));
});
const instance = yield* create({ serverUrl: "http://127.0.0.1:4096" }, http);

const snapshot = yield* instance.snapshotForCwd!("/work").pipe(Effect.forkChild);
yield* TestClock.adjust("30 seconds");
const { skills, slashCommands, optionDescriptors } = yield* Fiber.join(snapshot);
assert.include(
skills.map((skill) => skill.name),
"plum",
);
assert.include(
slashCommands.map((command) => command.name),
"review",
);
assert.isUndefined(optionDescriptors?.find((option) => option.id === "agent"));
}).pipe(Effect.scoped, Effect.provide(TestClock.layer())),
);

it.effect.each(["probe", "error", "empty"] as const)(
"recovers discovery without a manual refresh after %s",
(initialCatalog) =>
Expand Down Expand Up @@ -1022,6 +1122,7 @@ const openCode1Provider = (id: string, models: ReadonlyArray<string>) => ({
});
const openCode1Project = "/work/project-with-config";
const openCode1UnreadableProject = "/work/unreadable-project";
const openCode1StalledAgents = "/work/stalled-agents";
const openCode1Providers = (directory: string) => {
const all = [
openCode1Provider("opencode", ["big-pickle"]),
Expand All @@ -1045,7 +1146,14 @@ const openCode1Runtime = {
? Promise.reject(new Error("provider list failed"))
: Promise.resolve({ data: openCode1Providers(directory) }),
},
app: { agents: () => Promise.resolve({ data: [] }) },
app: {
agents: (_input: unknown, options?: { readonly signal?: AbortSignal }) =>
directory === openCode1StalledAgents
? new Promise((_resolve, reject) => {
options?.signal?.addEventListener("abort", () => reject(new Error("aborted")));
})
: Promise.resolve({ data: [] }),
},
command: { list: () => Promise.resolve({ data: [] }) },
}),
loadOpenCodeInventory: () =>
Expand Down Expand Up @@ -1083,6 +1191,31 @@ it.layer(Layer.mergeAll(layer, Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, op
assert.strictEqual(unreadable.workspaceModelsPending, true);
}).pipe(Effect.scoped),
);

it.effect("keeps commands and skills when OpenCode 1 agent discovery stalls", () =>
Effect.gen(function* () {
const instance = yield* create({}, noHttp);
const ready = yield* instance.snapshot.streamChanges.pipe(
Stream.filter((snapshot) => snapshot.status === "ready"),
Stream.take(1),
Stream.runCollect,
Effect.forkChild,
);
yield* Fiber.join(ready);

const snapshotFiber = yield* instance.snapshotForCwd!(openCode1StalledAgents).pipe(
Effect.forkChild,
);
yield* TestClock.adjust("10 seconds");
const snapshot = yield* Fiber.join(snapshotFiber);
assert.deepStrictEqual(snapshot.skills, []);
assert.include(
snapshot.slashCommands.map((command) => command.name),
"compact",
);
assert.isUndefined(snapshot.optionDescriptors);
}).pipe(Effect.scoped),
);
},
);

Expand Down
68 changes: 51 additions & 17 deletions packages/provider-opencode/src/server/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { loadOpenCode2Catalog, loadOpenCode2DirectoryCatalog } from "./openCode2
import {
checkOpenCodeProviderStatus,
loadOpenCode2Workspace,
openCodeAgentOptionDescriptor,
makeOpenCode2ModelLoader,
makePendingOpenCodeProvider,
openCode2CommandsToServerProviderSlashCommands,
Expand Down Expand Up @@ -424,6 +425,11 @@ export const OpenCodeDriver: ProviderDriver<
return yield* loadOpenCode2Workspace(
Effect.all(
{
agents: client.agent.list({ location }).pipe(
Effect.map((list) => list.data),
Effect.timeout("3 seconds"),
Effect.catchCause(() => Effect.undefined),
),
skills: client.skill.list({ location }).pipe(Effect.map((list) => list.data)),
commands: client.command.list({ location }).pipe(Effect.map((list) => list.data)),
},
Expand Down Expand Up @@ -513,6 +519,10 @@ export const OpenCodeDriver: ProviderDriver<
) =>
Effect.all(
{
agents: OpenCodeRuntime.loadOpenCodeAgents(client).pipe(
Effect.timeout("3 seconds"),
Effect.catchCause(() => Effect.undefined),
),
Comment thread
soares-daniel marked this conversation as resolved.
Resolved
skills: openCodeRuntime.loadOpenCodeSkills(client),
commandInventory: OpenCodeRuntime.loadOpenCodeCommands(client).pipe(
Effect.timeoutOption("10 seconds"),
Expand All @@ -539,7 +549,8 @@ export const OpenCodeDriver: ProviderDriver<
},
{ concurrency: "unbounded" },
).pipe(
Effect.map(({ skills, commandInventory, models }) => ({
Effect.map(({ agents, skills, commandInventory, models }) => ({
agents,
skills,
models,
...commandInventory,
Expand Down Expand Up @@ -701,28 +712,51 @@ export const OpenCodeDriver: ProviderDriver<
],
{ concurrency: "unbounded" },
).pipe(
Effect.map(([machineSnapshot, cwdModels, { skills, commands }]) => ({
...machineSnapshot,
...workspaceModelsFor(machineSnapshot.models, cwdModels),
skills: openCode2SkillsToServerProviderSkills(skills),
slashCommands: openCode2CommandsToServerProviderSlashCommands(commands),
// A finished scan lists at least the built-ins. None means it
// timed out, so the registry decides from the provider's health.
slashCommandsPending: commands.length > 0 ? false : undefined,
})),
Effect.map(([machineSnapshot, cwdModels, { agents, skills, commands }]) => {
const agentDescriptor =
agents === undefined || agents.length === 0
? undefined
: openCodeAgentOptionDescriptor(
agents.map((agent) => ({ ...agent, name: agent.id })),
);
return {
...machineSnapshot,
...workspaceModelsFor(machineSnapshot.models, cwdModels),
...(agentDescriptor === undefined || agentDescriptor.options.length === 0
? {}
: { optionDescriptors: [agentDescriptor] }),
skills: openCode2SkillsToServerProviderSkills(skills),
slashCommands: openCode2CommandsToServerProviderSlashCommands(commands),
// A finished scan lists at least the built-ins. None means it
// timed out, so the registry decides from the provider's health.
slashCommandsPending: commands.length > 0 ? false : undefined,
};
}),
),
v1: Effect.all([
snapshot.getSnapshot,
loadWorkspaceForCwd(cwd).pipe(Effect.timeout("20 seconds")),
]).pipe(
Effect.map(
([machineSnapshot, { skills, models, commands, slashCommandsPending }]) => ({
...machineSnapshot,
...workspaceModelsFor(machineSnapshot.models, models),
skills: openCodeSkillsToServerProviderSkills(skills),
slashCommands: openCodeCommandsToServerProviderSlashCommands(commands),
slashCommandsPending,
}),
([
machineSnapshot,
{ agents, skills, models, commands, slashCommandsPending },
]) => {
const agentDescriptor =
agents === undefined || agents.length === 0
? undefined
: openCodeAgentOptionDescriptor(agents);
return {
...machineSnapshot,
...workspaceModelsFor(machineSnapshot.models, models),
...(agentDescriptor === undefined || agentDescriptor.options.length === 0
? {}
: { optionDescriptors: [agentDescriptor] }),
skills: openCodeSkillsToServerProviderSkills(skills),
slashCommands: openCodeCommandsToServerProviderSlashCommands(commands),
slashCommandsPending,
};
},
),
Effect.mapError(
(cause) =>
Expand Down
5 changes: 3 additions & 2 deletions packages/provider-opencode/src/server/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,7 @@ const builtinSkills = [
{ id: "report", name: "Report", path: "/builtin/report.md" },
];
const scanned: OpenCode2Workspace = {
agents: [],
commands: [...builtinCommands, { name: "hello", description: "Say hello to the workspace" }],
skills: [
...builtinSkills,
Expand All @@ -1117,7 +1118,7 @@ it.effect(
"reads a loaded OpenCode 2 directory listing no commands again once its scan has ended",
() =>
Effect.gen(function* () {
const reads: Array<OpenCode2Workspace> = [{ commands: [], skills: [] }, scanned];
const reads: Array<OpenCode2Workspace> = [{ agents: [], commands: [], skills: [] }, scanned];
const scanEnded = yield* Deferred.make<void>();
const load = yield* loadOpenCode2Workspace(
Effect.sync(() => reads.shift()!),
Expand All @@ -1135,7 +1136,7 @@ it.effect("waits for the scan of a cold OpenCode 2 directory that first lists on
Effect.gen(function* () {
const calls: Array<string> = [];
const reads: Array<OpenCode2Workspace> = [
{ commands: builtinCommands, skills: builtinSkills },
{ agents: [], commands: builtinCommands, skills: builtinSkills },
scanned,
];
const workspace = yield* loadOpenCode2Workspace(
Expand Down
Loading
Loading