Skip to content
Merged
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
206 changes: 206 additions & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
return { data: null };
},
messages: options?.messages ?? (async () => ({ data: [] })),
get: async () => ({ data: { directory: process.cwd(), ...(options?.session ?? {}) } }),

Check warning on line 153 in apps/server/src/provider/Layers/OpenCodeAdapter.test.ts

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

eslint-plugin-unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
revert: async () => ({ data: null }),
summarize: async () => ({ data: null }),
fork: async () => ({ data: { id: "forked-session-1" } }),
Expand Down Expand Up @@ -969,6 +969,212 @@
});

describe("OpenCodeAdapter runtime lifecycle", () => {
it("advertises OpenCode skill discovery capabilities", async () => {
const runtime = createMockOpenCodeRuntime();

const result = await Effect.runPromise(
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const getComposerCapabilities = adapter.getComposerCapabilities;
if (!getComposerCapabilities) {
throw new Error("Expected OpenCode adapter to expose composer capabilities.");
}
return yield* getComposerCapabilities();
}).pipe(
Effect.provide(
makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }),
),
Layer.provideMerge(NodeServices.layer),
),
),
),
);

expect(result.supportsSkillDiscovery).toBe(true);
expect(result.supportsSkillMentions).toBe(true);
});

it("lists OpenCode skills from runtime console metadata", async () => {
const runtime = createMockOpenCodeRuntime({
inventory: {
providerList: { connected: [], all: [], default: {} },
agents: [],
consoleState: {
skills: [
{
name: "security-review",
path: "/home/test/.opencode/skills/security-review/SKILL.md",
description: "Security code review for vulnerabilities.",
interface: {
displayName: "Security Review",
shortDescription: "Review code for security issues",
},
scope: "user",
},
{
id: "legacy-skill",
source: { path: "/home/test/.opencode/skills/legacy/SKILL.md" },
summary: "Legacy metadata shape.",
disabled: true,
},
],
} as never,
},
});

const result = await Effect.runPromise(
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const listSkills = adapter.listSkills;
if (!listSkills) {
throw new Error("Expected OpenCode adapter to support skill listing.");
}
return yield* listSkills({
provider: "opencode",
cwd: process.cwd(),
});
}).pipe(
Effect.provide(
makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }),
),
Layer.provideMerge(NodeServices.layer),
),
),
),
);

expect(result).toEqual({
skills: [
{
name: "security-review",
path: "/home/test/.opencode/skills/security-review/SKILL.md",
enabled: true,
description: "Security code review for vulnerabilities.",
scope: "user",
interface: {
displayName: "Security Review",
shortDescription: "Review code for security issues",
},
},
{
name: "legacy-skill",
path: "/home/test/.opencode/skills/legacy/SKILL.md",
enabled: false,
description: "Legacy metadata shape.",
interface: {
shortDescription: "Legacy metadata shape.",
},
},
],
source: "opencode-runtime",
cached: false,
});
});

it("normalizes keyed and string OpenCode skill metadata", async () => {
const runtime = createMockOpenCodeRuntime({
inventory: {
providerList: { connected: [], all: [], default: {} },
agents: [],
consoleState: {
skills: {
"code-review": {
description: "Review code changes for correctness.",
file: "/home/test/.opencode/skills/code-review/SKILL.md",
title: "Code Review",
},
quickstart: true,
write: "write-a-skill",
},
} as never,
},
});

const result = await Effect.runPromise(
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const listSkills = adapter.listSkills;
if (!listSkills) {
throw new Error("Expected OpenCode adapter to support skill listing.");
}
return yield* listSkills({
provider: "opencode",
cwd: process.cwd(),
});
}).pipe(
Effect.provide(
makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }),
),
Layer.provideMerge(NodeServices.layer),
),
),
),
);

expect(result.skills).toEqual([
{
name: "code-review",
path: "/home/test/.opencode/skills/code-review/SKILL.md",
enabled: true,
description: "Review code changes for correctness.",
interface: {
displayName: "Code Review",
shortDescription: "Review code changes for correctness.",
},
},
{
name: "write-a-skill",
path: "opencode://skill/write-a-skill",
enabled: true,
},
]);
});

it("returns an empty OpenCode skill list for missing metadata", async () => {
const runtime = createMockOpenCodeRuntime({
inventory: {
providerList: { connected: [], all: [], default: {} },
agents: [],
consoleState: null,
},
});

const result = await Effect.runPromise(
Effect.gen(function* () {
const adapter = yield* OpenCodeAdapter;
const listSkills = adapter.listSkills;
if (!listSkills) {
throw new Error("Expected OpenCode adapter to support skill listing.");
}
return yield* listSkills({
provider: "opencode",
cwd: process.cwd(),
});
}).pipe(
Effect.provide(
makeOpenCodeAdapterLive({ runtime: runtime.runtime }).pipe(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "opencode-adapter-test-" }),
),
Layer.provideMerge(NodeServices.layer),
),
),
),
);

expect(result).toEqual({
skills: [],
source: "opencode-runtime",
cached: false,
});
});

it("lists OpenCode models from the CLI before falling back to server inventory", async () => {
const runtime = createMockOpenCodeRuntime({
cliModels: [
Expand Down
122 changes: 120 additions & 2 deletions apps/server/src/provider/Layers/OpenCodeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ProviderComposerCapabilities,
type ProviderListAgentsResult,
type ProviderListModelsResult,
type ProviderListSkillsResult,
type ProviderRuntimeEvent,
type ProviderSession,
RuntimeItemId,
Expand Down Expand Up @@ -831,11 +832,13 @@ type OpenCodeModelInventory = {
};
readonly consoleState?: {
readonly consoleManagedProviders: ReadonlyArray<string>;
readonly skills?: unknown;
} | null;
};

type OpenCodeInventoryProvider = OpenCodeModelInventory["providerList"]["all"][number];
type OpenCodeModelDescriptor = ProviderListModelsResult["models"][number];
type OpenCodeSkillDescriptor = ProviderListSkillsResult["skills"][number];

function asNonNegativeInteger(value: unknown): number | undefined {
return typeof value === "number" &&
Expand Down Expand Up @@ -1120,6 +1123,111 @@ function trimNonEmptyString(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}

function asPlainRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}

function readFirstNonEmptyString(
record: Record<string, unknown> | undefined,
keys: readonly string[],
): string | undefined {
if (!record) return undefined;
for (const key of keys) {
const value = trimNonEmptyString(record[key]);
if (value) return value;
}
return undefined;
}

function readOpenCodeSkillPath(record: Record<string, unknown> | undefined): string | undefined {
const directPath = readFirstNonEmptyString(record, ["path", "file", "filename"]);
if (directPath) return directPath;
return readFirstNonEmptyString(asPlainRecord(record?.source), ["path", "file", "filename"]);
}

function readOpenCodeSkillEnabled(record: Record<string, unknown> | undefined): boolean {
if (record?.enabled === false || record?.disabled === true) {
return false;
}
return true;
}

function normalizeOpenCodeSkillDescriptor(
value: unknown,
fallbackName?: string,
): OpenCodeSkillDescriptor | undefined {
if (typeof value === "boolean" || value == null) {
return undefined;
}

if (typeof value === "string") {
const name = trimNonEmptyString(value);
return name
? { name, path: `opencode://skill/${encodeURIComponent(name)}`, enabled: true }
: undefined;
}

const record = asPlainRecord(value);
if (!record) return undefined;
const interfaceRecord = asPlainRecord(record.interface);
const name =
readFirstNonEmptyString(record, ["name", "id", "key"]) ?? trimNonEmptyString(fallbackName);
if (!name) return undefined;

const description = readFirstNonEmptyString(record, [
"description",
"shortDescription",
"summary",
]);
const displayName =
readFirstNonEmptyString(interfaceRecord, ["displayName"]) ??
readFirstNonEmptyString(record, ["displayName", "title"]);
const shortDescription =
readFirstNonEmptyString(interfaceRecord, ["shortDescription"]) ??
readFirstNonEmptyString(record, ["shortDescription", "summary"]);
const skillInterface =
displayName || shortDescription || description
? {
...(displayName ? { displayName } : {}),
...((shortDescription ?? description)
? { shortDescription: shortDescription ?? description }
: {}),
}
: undefined;
const scope = readFirstNonEmptyString(record, ["scope"]);

return {
name,
path: readOpenCodeSkillPath(record) ?? `opencode://skill/${encodeURIComponent(name)}`,
enabled: readOpenCodeSkillEnabled(record),
...(description ? { description } : {}),
...(scope ? { scope } : {}),
...(skillInterface ? { interface: skillInterface } : {}),
};
}

function normalizeOpenCodeSkillDescriptors(
inventory: OpenCodeInventory,
): OpenCodeSkillDescriptor[] {
const consoleState = asPlainRecord(inventory.consoleState);
const skills = consoleState?.skills;
if (Array.isArray(skills)) {
return skills.flatMap((skill) => {
const descriptor = normalizeOpenCodeSkillDescriptor(skill);
return descriptor ? [descriptor] : [];
});
}

const skillRecord = asPlainRecord(skills);
if (!skillRecord) return [];
return Object.entries(skillRecord).flatMap(([key, value]) => {
const descriptor = normalizeOpenCodeSkillDescriptor(value, key);
return descriptor ? [descriptor] : [];
});
}

function readOpenCodeInventoryVariantValue(
variantKey: string,
variant: Record<string, unknown>,
Expand Down Expand Up @@ -4032,6 +4140,15 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
);
});

const listSkills: NonNullable<OpenCodeAdapterShape["listSkills"]> = () =>
withDiscoveryInventory({}, ({ inventory }) =>
Effect.succeed({
skills: normalizeOpenCodeSkillDescriptors(inventory),
source: "opencode-runtime",
cached: false,
} satisfies ProviderListSkillsResult),
);

const listModels: NonNullable<OpenCodeAdapterShape["listModels"]> = (input) => {
const binaryPath = input.binaryPath?.trim() || adapterConfig.defaultBinaryPath;
const freeOnlyProviderID = adapterConfig.provider === "kilo" ? "kilo" : undefined;
Expand Down Expand Up @@ -4118,8 +4235,8 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
> = () =>
Effect.succeed({
provider,
supportsSkillMentions: false,
supportsSkillDiscovery: false,
supportsSkillMentions: provider === "opencode",
supportsSkillDiscovery: provider === "opencode",
supportsNativeSlashCommandDiscovery: false,
supportsPluginMentions: false,
supportsPluginDiscovery: false,
Expand Down Expand Up @@ -4159,6 +4276,7 @@ export function makeOpenCodeAdapterLive(options?: OpenCodeAdapterLiveOptions) {
compactThread,
forkThread,
stopAll,
listSkills,
listModels,
listAgents,
getComposerCapabilities,
Expand Down
Loading
Loading