diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index f76d1aaf9d22..e2199c2098da 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -73,6 +73,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), OPENCODE_EXPERIMENTAL_SCOUT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SCOUT"), OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), + OPENCODE_EXPERIMENTAL_SYSTEM_PROMPT_SPLIT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SYSTEM_PROMPT_SPLIT"), OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], @@ -86,6 +87,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), OPENCODE_EXPERIMENTAL_SESSION_SWITCHING: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_SESSION_SWITCHING"), + OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 6629ce67bc9f..716e809c67f9 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -36,7 +36,7 @@ function extract(messages: MessageV2.WithParts[]) { export interface Interface { readonly clear: (messageID: MessageID) => Effect.Effect readonly systemPaths: () => Effect.Effect, AppFileSystem.Error> - readonly system: () => Effect.Effect + readonly system: () => Effect.Effect<{ global: string[]; project: string[] }, AppFileSystem.Error> readonly find: (dir: string) => Effect.Effect readonly resolve: ( messages: MessageV2.WithParts[], @@ -103,17 +103,21 @@ export const layer: Layer.Layer< s.claims.delete(messageID) }) - const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { - const config = yield* cfg.get() - const ctx = yield* InstanceState.context + const globalInstructionPaths = Effect.fnUntraced(function* () { const paths = new Set() - for (const file of globalFiles) { if (yield* fs.existsSafe(file)) { paths.add(path.resolve(file)) break } } + return paths + }) + + const projectInstructionPaths = Effect.fnUntraced(function* () { + const config = yield* cfg.get() + const ctx = yield* InstanceState.context + const paths = new Set() // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor. if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { @@ -146,20 +150,39 @@ export const layer: Layer.Layer< return paths }) + const systemPaths = Effect.fn("Instruction.systemPaths")(function* () { + const [global, project] = yield* Effect.all([globalInstructionPaths(), projectInstructionPaths()]) + return new Set([...global, ...project]) + }) + const system = Effect.fn("Instruction.system")(function* () { const config = yield* cfg.get() - const paths = yield* systemPaths() const urls = (config.instructions ?? []).filter( (item) => item.startsWith("https://") || item.startsWith("http://"), ) - const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 }) - const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 }) - - return [ - ...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])), - ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), - ] + const [global, project, remote] = yield* Effect.all([ + globalInstructionPaths(), + projectInstructionPaths(), + Effect.forEach(urls, fetch, { concurrency: 4 }), + ]) + + const [globalFiles, projectFiles] = yield* Effect.all([ + Effect.forEach(Array.from(global), read, { concurrency: 8 }), + Effect.forEach(Array.from(project), read, { concurrency: 8 }), + ]) + + return { + global: Array.from(global).flatMap((item, i) => + globalFiles[i] ? [`Instructions from: ${item}\n${globalFiles[i]}`] : [], + ), + project: [ + ...Array.from(project).flatMap((item, i) => + projectFiles[i] ? [`Instructions from: ${item}\n${projectFiles[i]}`] : [], + ), + ...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])), + ], + } }) const find = Effect.fn("Instruction.find")(function* (dir: string) { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 116254a81e75..4ec4dd1e2fe9 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -32,6 +32,10 @@ type Result = Awaited> const mergeOptions = (target: Record, source: Record | undefined): Record => mergeDeep(target, source ?? {}) as Record +export type SystemSplit = + | string[] + | { stable: string[]; dynamic: string[] } + export type StreamInput = { user: MessageV2.User sessionID: string @@ -39,7 +43,7 @@ export type StreamInput = { model: Provider.Model agent: Agent.Info permission?: Permission.Ruleset - system: string[] + system: SystemSplit messages: ModelMessage[] small?: boolean tools: Record @@ -101,18 +105,47 @@ const live: Layer.Layer< const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" const system: string[] = [] - system.push( - [ - // use agent prompt otherwise provider prompt - ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), - // any custom prompt passed into this call - ...input.system, - // any custom prompt from last user message - ...(input.user.system ? [input.user.system] : []), - ] - .filter((x) => x) - .join("\n"), - ) + + if (!Array.isArray(input.system)) { + const shouldSplit = item.options?.["splitSystemPrompt"] !== false + if (shouldSplit) { + const stable = [ + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + ...input.system.stable, + ] + .filter((x) => x) + .join("\n") + const dynamic = [ + ...input.system.dynamic, + ...(input.user.system ? [input.user.system] : []), + ] + .filter((x) => x) + .join("\n") + system.push(stable) + system.push(dynamic) + } else { + system.push( + [ + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + ...input.system.stable, + ...input.system.dynamic, + ...(input.user.system ? [input.user.system] : []), + ] + .filter((x) => x) + .join("\n"), + ) + } + } else { + system.push( + [ + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + ...input.system, + ...(input.user.system ? [input.user.system] : []), + ] + .filter((x) => x) + .join("\n"), + ) + } const header = system[0] yield* plugin.trigger( @@ -121,7 +154,7 @@ const live: Layer.Layer< { system }, ) // rejoin to maintain 2-part structure for caching if header unchanged - if (system.length > 2 && system[0] === header) { + if (Array.isArray(input.system) && system.length > 2 && system[0] === header) { const rest = system.slice(1) system.length = 0 system.push(header, rest.join("\n")) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bc58fbdf356b..fe5c9bee47db 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1800,9 +1800,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the instruction.system().pipe(Effect.orDie), MessageV2.toModelMessagesEffect(msgs, model), ]) - const system = [...env, ...instructions, ...(skills ? [skills] : [])] + const system: string[] | { stable: string[]; dynamic: string[] } = Flag.OPENCODE_EXPERIMENTAL_SYSTEM_PROMPT_SPLIT + ? { + stable: [...instructions.global, ...(skills.global ? [skills.global] : [])], + dynamic: [...env, ...(skills.project ? [skills.project] : []), ...instructions.project], + } + : [...env, ...instructions.global, ...(skills.global ? [skills.global] : []), ...instructions.project, ...(skills.project ? [skills.project] : [])] const format = lastUser.format ?? { type: "text" as const } - if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + if (format.type === "json_schema") { + if (Array.isArray(system)) system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + else system.dynamic.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + } const result = yield* handle.process({ user: lastUser, agent, diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 06c71fa7dbdd..f7497ab3a271 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -34,7 +34,7 @@ export function provider(model: Provider.Model) { export interface Interface { readonly environment: (model: Provider.Model) => Effect.Effect - readonly skills: (agent: Agent.Info) => Effect.Effect + readonly skills: (agent: Agent.Info) => Effect.Effect<{ global?: string; project?: string }> } export class Service extends Context.Service()("@opencode/SystemPrompt") {} @@ -63,17 +63,28 @@ export const layer = Layer.effect( }), skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) { - if (Permission.disabled(["skill"], agent.permission).has("skill")) return + if (Permission.disabled(["skill"], agent.permission).has("skill")) return {} const list = yield* skill.available(agent) + const globalSkills = list.filter((s) => s.scope === "global") + const projectSkills = list.filter((s) => s.scope === "project") - return [ + const preamble = [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", - // the agents seem to ingest the information about skills a bit better if we present a more verbose - // version of them here and a less verbose version in tool description, rather than vice versa. - Skill.fmt(list, { verbose: true }), ].join("\n") + + return { + global: globalSkills.length > 0 + ? [preamble, Skill.fmt(globalSkills, { verbose: true })].join("\n") + : undefined, + project: projectSkills.length > 0 + ? [ + ...(globalSkills.length === 0 ? [preamble] : []), + Skill.fmt(projectSkills, { verbose: true }), + ].join("\n") + : undefined, + } }), }) }), diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 59dfeb0804ed..6426617d4096 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -12,6 +12,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Config } from "@/config/config" import { ConfigMarkdown } from "@/config/markdown" import { Glob } from "@opencode-ai/core/util/glob" +import { withStatics } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } @@ -38,7 +39,8 @@ export const Info = Schema.Struct({ description: Schema.optional(Schema.String), location: Schema.String, content: Schema.String, -}) + scope: Schema.Union([Schema.Literal("global"), Schema.Literal("project")]), +}).pipe(withStatics(() => ({}))) export type Info = Schema.Schema.Type const Issue = Schema.StructWithRest( @@ -75,12 +77,12 @@ type State = { } type DiscoveryState = { - matches: string[] + matches: Array<{ path: string; scope: "global" | "project" }> dirs: string[] } type ScanState = { - matches: Set + matches: Map dirs: Set } @@ -91,7 +93,7 @@ export interface Interface { readonly available: (agent?: Agent.Info) => Effect.Effect } -const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) { +const add = Effect.fnUntraced(function* (state: State, match: string, scope: "global" | "project", bus: Bus.Interface) { const md = yield* Effect.tryPromise({ try: () => ConfigMarkdown.parse(match), catch: (err) => err, @@ -127,6 +129,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.I description: md.data.description, location: match, content: md.content, + scope, } }) @@ -155,7 +158,7 @@ const scan = Effect.fnUntraced(function* ( ) for (const match of matches) { - state.matches.add(match) + state.matches.set(match, (opts?.scope as "global" | "project") ?? "project") state.dirs.add(path.dirname(match)) } }) @@ -168,7 +171,7 @@ const discoverSkills = Effect.fnUntraced(function* ( directory: string, worktree: string, ) { - const state: ScanState = { matches: new Set(), dirs: new Set() } + const state: ScanState = { matches: new Map(), dirs: new Set() } const externalDirs: string[] = [] if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { @@ -192,7 +195,7 @@ const discoverSkills = Effect.fnUntraced(function* ( const configDirs = yield* config.directories() for (const dir of configDirs) { - yield* scan(state, dir, OPENCODE_SKILL_PATTERN) + yield* scan(state, dir, OPENCODE_SKILL_PATTERN, { scope: "global" }) } const cfg = yield* config.get() @@ -215,13 +218,13 @@ const discoverSkills = Effect.fnUntraced(function* ( } return { - matches: Array.from(state.matches), + matches: Array.from(state.matches.entries()).map(([path, scope]) => ({ path, scope })), dirs: Array.from(state.dirs), } }) const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, bus: Bus.Interface) { - yield* Effect.forEach(discovered.matches, (match) => add(state, match, bus), { + yield* Effect.forEach(discovered.matches, (item) => add(state, item.path, item.scope, bus), { concurrency: "unbounded", discard: true, }) @@ -249,11 +252,14 @@ export const layer = Layer.effect( const s: State = { skills: {}, dirs: new Set() } // Register the built-in skill BEFORE disk discovery so a user-disk // skill with the same name can override it. - s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { - name: CUSTOMIZE_OPENCODE_SKILL_NAME, - description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, - location: "", - content: CUSTOMIZE_OPENCODE_SKILL_BODY, + if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) { + s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = { + name: CUSTOMIZE_OPENCODE_SKILL_NAME, + description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION, + location: "", + content: CUSTOMIZE_OPENCODE_SKILL_BODY, + scope: "global", + } } yield* loadSkills(s, yield* InstanceState.get(discovered), bus) return s diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 5d40933954eb..37354eb6f1c0 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -209,9 +209,10 @@ describe("Instruction.system", () => { expect(paths.has(path.join(globalTmp, "AGENTS.md"))).toBe(true) const rules = yield* svc.system() - expect(rules).toHaveLength(2) - expect(rules[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`) - expect(rules[1]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`) + expect(rules.global).toHaveLength(1) + expect(rules.global[0]).toBe(`Instructions from: ${path.join(globalTmp, "AGENTS.md")}\n# Global Instructions`) + expect(rules.project).toHaveLength(1) + expect(rules.project[0]).toBe(`Instructions from: ${path.join(projectTmp, "AGENTS.md")}\n# Project Instructions`) }).pipe(provideInstance(projectTmp), provideInstruction({ home: globalTmp, config: globalTmp })) }), ) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 4a6b1e8b7f04..b45f0172fce7 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1289,4 +1289,219 @@ describe("session.llm.stream", () => { }, }) }) + + test("sends split system as two messages for Anthropic-compatible models", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const source = await loadFixture("anthropic", "claude-opus-4-6") + const model = source.model + const chunks = [ + { + type: "message_start", + message: { + id: "msg-split", + model: model.id, + usage: { input_tokens: 3, cache_creation_input_tokens: null, cache_read_input_tokens: null }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello" }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null, container: null }, + usage: { input_tokens: 3, output_tokens: 2, cache_creation_input_tokens: null, cache_read_input_tokens: null }, + }, + { type: "message_stop" }, + ] + const request = waitRequest("/messages", createEventResponse(chunks)) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["anthropic"], + provider: { + anthropic: { + name: "Anthropic", + env: ["ANTHROPIC_API_KEY"], + npm: "@ai-sdk/anthropic", + api: "https://api.anthropic.com/v1", + models: { [model.id]: model }, + options: { + apiKey: "test-anthropic-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-split-system") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user-split"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id }, + } satisfies MessageV2.User + + await drain({ + user, + sessionID, + model: resolved, + agent, + system: { stable: ["Global instructions"], dynamic: ["Project instructions"] }, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + const capture = await request + const body = capture.body + const system = body.system as Array<{ text: string }> | undefined + + expect(capture.url.pathname.endsWith("/messages")).toBe(true) + expect(Array.isArray(system)).toBe(true) + expect(system?.length).toBe(2) + expect(system?.[0]?.text).toContain("Global instructions") + expect(system?.[1]?.text).toContain("Project instructions") + }, + }) + }) + + test("sends flat system as single message for Anthropic-compatible models", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const source = await loadFixture("anthropic", "claude-opus-4-6") + const model = source.model + const chunks = [ + { + type: "message_start", + message: { + id: "msg-flat", + model: model.id, + usage: { input_tokens: 3, cache_creation_input_tokens: null, cache_read_input_tokens: null }, + }, + }, + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Hello" }, + }, + { type: "content_block_stop", index: 0 }, + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null, container: null }, + usage: { input_tokens: 3, output_tokens: 2, cache_creation_input_tokens: null, cache_read_input_tokens: null }, + }, + { type: "message_stop" }, + ] + const request = waitRequest("/messages", createEventResponse(chunks)) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: ["anthropic"], + provider: { + anthropic: { + name: "Anthropic", + env: ["ANTHROPIC_API_KEY"], + npm: "@ai-sdk/anthropic", + api: "https://api.anthropic.com/v1", + models: { [model.id]: model }, + options: { + apiKey: "test-anthropic-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-flat-system") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user-flat"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id }, + } satisfies MessageV2.User + + await drain({ + user, + sessionID, + model: resolved, + agent, + system: ["Combined instructions"], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + const capture = await request + const body = capture.body + const system = body.system as Array<{ text: string }> | string | undefined + + expect(capture.url.pathname.endsWith("/messages")).toBe(true) + // Flat system should produce a single system message (string or single-element array) + if (Array.isArray(system)) { + expect(system.length).toBe(1) + expect(system[0]?.text).toContain("Combined instructions") + } else { + expect(system).toContain("Combined instructions") + } + }, + }) + }) }) diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 1cf9026725ea..b70659deaeca 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -13,23 +13,27 @@ const skills: Skill.Info[] = [ description: "Zeta skill.", location: "/tmp/zeta-skill/SKILL.md", content: "# zeta-skill", + scope: "project", }, { name: "alpha-skill", description: "Alpha skill.", location: "/tmp/alpha-skill/SKILL.md", content: "# alpha-skill", + scope: "project", }, { name: "middle-skill", description: "Middle skill.", location: "/tmp/middle-skill/SKILL.md", content: "# middle-skill", + scope: "project", }, { name: "manual-skill", location: "/tmp/manual-skill/SKILL.md", content: "# manual-skill", + scope: "project", }, ] @@ -62,9 +66,10 @@ describe("session.system", () => { const prompt = yield* SystemPrompt.Service const first = yield* prompt.skills(build) const second = yield* prompt.skills(build) - const output = first ?? (yield* Effect.fail(new NamedError.Unknown({ message: "missing skills output" }))) - expect(first).toBe(second) + expect(first).toEqual(second) + + const output = first.project ?? (yield* Effect.fail(new NamedError.Unknown({ message: "missing skills output" }))) const alpha = output.indexOf("alpha-skill") const middle = output.indexOf("middle-skill")