From 7cba420bce19b2b3da4b66693f3e98e60b16689b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 8 Aug 2026 04:09:49 +0000 Subject: [PATCH] chore(core): format package --- packages/core/schema.json | 157 +-- packages/core/script/benchmark-location.ts | 4 +- packages/core/src/bus.ts | 981 +++++++------- packages/core/src/catalog.ts | 6 +- packages/core/src/codemode/catalog.ts | 4 +- packages/core/src/codemode/tool.ts | 13 +- packages/core/src/command.ts | 205 +-- packages/core/src/config.ts | 519 ++++---- packages/core/src/config/plugin/agent.ts | 4 +- packages/core/src/database/v1-migration.ts | 22 +- packages/core/src/event-logger.ts | 7 +- packages/core/src/filesystem/watcher.ts | 4 +- packages/core/src/form.ts | 6 +- packages/core/src/formatter.ts | 4 +- packages/core/src/formatter/builtins.ts | 4 +- packages/core/src/instruction-discovery.ts | 128 +- packages/core/src/instructions/builtins.ts | 4 +- packages/core/src/mcp/client.ts | 15 +- packages/core/src/mcp/index.ts | 1141 ++++++++--------- packages/core/src/mcp/oauth.ts | 14 +- packages/core/src/mime.ts | 3 +- packages/core/src/models-dev.ts | 210 +-- packages/core/src/permission.ts | 11 +- packages/core/src/permission/saved.ts | 7 +- packages/core/src/plugin/host.ts | 20 +- .../src/plugin/provider/github-copilot.ts | 191 +-- packages/core/src/plugin/provider/openai.ts | 226 ++-- packages/core/src/plugin/provider/xai.ts | 65 +- packages/core/src/project.ts | 7 +- packages/core/src/pty.ts | 425 +++--- packages/core/src/session.ts | 4 +- .../core/src/session/instruction-state.ts | 10 +- packages/core/src/session/model-request.ts | 7 +- packages/core/src/session/pending.ts | 10 +- packages/core/src/session/runner/llm.ts | 6 +- .../src/session/runner/publish-llm-event.ts | 9 +- packages/core/src/session/transfer.ts | 12 +- packages/core/src/shell/parser-wasm.node.ts | 3 +- packages/core/src/snapshot.ts | 2 +- packages/core/src/tool-output.ts | 10 +- packages/core/src/tool/mcp.ts | 94 +- packages/core/src/tool/plugin/question.ts | 111 +- packages/core/src/tool/plugin/skill.ts | 86 +- packages/core/src/tool/plugin/subagent.ts | 235 ++-- packages/core/src/tool/plugin/webfetch.ts | 108 +- packages/core/src/tool/runtime.ts | 3 +- packages/core/src/vcs.ts | 2 +- packages/core/src/vcs/git.ts | 5 +- packages/core/test/agent.test.ts | 26 +- packages/core/test/app.test.ts | 4 +- packages/core/test/bus.test.ts | 9 +- packages/core/test/codemode.test.ts | 12 +- packages/core/test/codemode/catalog.test.ts | 5 +- .../core/test/codemode/instructions.test.ts | 17 +- packages/core/test/config/config.test.ts | 8 +- .../core/test/effect/observability.test.ts | 1 - packages/core/test/formatter.test.ts | 5 +- packages/core/test/instruction-state.test.ts | 7 +- packages/core/test/lib/tool.ts | 6 +- packages/core/test/mcp.test.ts | 22 +- packages/core/test/patch.test.ts | 88 +- packages/core/test/permission.test.ts | 9 +- packages/core/test/plugin.test.ts | 58 +- packages/core/test/plugin/host.ts | 7 +- .../test/plugin/provider-cerebras.test.ts | 15 +- .../core/test/plugin/provider-openai.test.ts | 9 +- .../test/plugin/provider-openrouter.test.ts | 3 +- .../core/test/plugin/system-prompt.test.ts | 4 +- .../core/test/plugin/websearch-fixture.ts | 12 +- packages/core/test/project.test.ts | 5 +- packages/core/test/pty/pty-session.test.ts | 4 +- packages/core/test/ripgrep.test.ts | 4 +- packages/core/test/session-prompt.test.ts | 38 +- .../test/session-runner-tool-events.test.ts | 8 +- .../test/session-runner-tool-registry.test.ts | 150 +-- packages/core/test/session-runner.test.ts | 5 +- packages/core/test/shell-parse.test.ts | 6 +- packages/core/test/snapshot.test.ts | 4 +- packages/core/test/tool-execute.test.ts | 25 +- packages/core/test/tool-output.test.ts | 5 +- packages/core/test/tool-schema.test.ts | 12 +- packages/core/test/tool-shell.test.ts | 135 +- packages/core/test/tool-skill.test.ts | 13 +- packages/core/test/util/effect-flock.test.ts | 6 +- packages/core/test/vcs-hg.test.ts | 13 +- packages/core/test/vcs.test.ts | 8 +- 86 files changed, 2875 insertions(+), 3017 deletions(-) diff --git a/packages/core/schema.json b/packages/core/schema.json index fe843eb9272d..01a080c9de0e 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "2d214a71-3b0a-48c1-a667-741952c4e188", - "prevIds": [ - "f14a9b18-8207-487e-a3d3-227e629ba9ad" - ], + "prevIds": ["f14a9b18-8207-487e-a3d3-227e629ba9ad"], "ddl": [ { "name": "workspace", @@ -1385,13 +1383,9 @@ "table": "session_v2" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1400,13 +1394,9 @@ "table": "workspace" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1415,13 +1405,9 @@ "table": "account_state" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1430,13 +1416,9 @@ "table": "event" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1445,13 +1427,9 @@ "table": "permission" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1460,13 +1438,9 @@ "table": "project_directory" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session_v2", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1475,13 +1449,9 @@ "table": "instruction_entry" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session_v2", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1490,13 +1460,9 @@ "table": "instruction_state" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session_v2", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1505,13 +1471,9 @@ "table": "session_message" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session_v2", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1520,13 +1482,9 @@ "table": "session_pending" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1535,156 +1493,119 @@ "table": "session_v2" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "project_id", - "directory" - ], + "columns": ["project_id", "directory"], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": [ - "session_id", - "key" - ], + "columns": ["session_id", "key"], "nameExplicit": false, "name": "instruction_entry_pk", "entityType": "pks", "table": "instruction_entry" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "credential_pk", "table": "credential", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": [ - "key" - ], + "columns": ["key"], "nameExplicit": false, "name": "kv_pk", "table": "kv", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "hash" - ], + "columns": ["hash"], "nameExplicit": false, "name": "instruction_blob_pk", "table": "instruction_blob", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "instruction_state_pk", "table": "instruction_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pending_pk", "table": "session_pending", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_v2_pk", "table": "session_v2", @@ -1940,4 +1861,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/core/script/benchmark-location.ts b/packages/core/script/benchmark-location.ts index d29fed63b558..60390db77259 100644 --- a/packages/core/script/benchmark-location.ts +++ b/packages/core/script/benchmark-location.ts @@ -20,9 +20,7 @@ if (!Number.isInteger(iterations) || iterations < 1) { } const ref = Location.Ref.make({ directory: AbsolutePath.make(path.resolve(directory)) }) -const layer = AppNodeBuilder.build( - LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), -) +const layer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node])) const measure = (effect: Effect.Effect) => Effect.gen(function* () { diff --git a/packages/core/src/bus.ts b/packages/core/src/bus.ts index fc4bf9c1cda5..01182f1c82d5 100644 --- a/packages/core/src/bus.ts +++ b/packages/core/src/bus.ts @@ -110,10 +110,13 @@ export interface Subscribe { */ (): Stream.Stream (definition: D): Stream.Stream> - (definitions: D): Stream.Stream> + ( + definitions: D, + ): Stream.Stream> } -const isDefinition = (input: Event.Definition | readonly Event.Definition[]): input is Event.Definition => !Array.isArray(input) +const isDefinition = (input: Event.Definition | readonly Event.Definition[]): input is Event.Definition => + !Array.isArray(input) export interface Interface { readonly publish: ( @@ -149,7 +152,6 @@ export interface Interface { readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect } - export class Service extends Context.Service()("@opencode/Bus") {} interface Options { @@ -164,534 +166,543 @@ export function configured(options?: Options) { return makeGlobalNode({ service: Service, deps: [Database.node], - layer: Layer.effect(Service, Effect.gen(function* () { - const pubsub = { - live: yield* PubSub.unbounded(), - durable: new Map>>(), - typed: new Map>(), - } - const projectors = new Map() - const listeners = new Array() - const { db } = yield* Database.Service - const logReadPageSize = options?.logReadPageSize ?? 512 - const persist = options?.persist ?? false - - const getOrCreate = (definition: Event.Definition) => - Effect.gen(function* () { - const existing = pubsub.typed.get(definition.type) - if (existing) return existing - const created = yield* PubSub.unbounded() - pubsub.typed.set(definition.type, created) - return created - }) + layer: Layer.effect( + Service, + Effect.gen(function* () { + const pubsub = { + live: yield* PubSub.unbounded(), + durable: new Map>>(), + typed: new Map>(), + } + const projectors = new Map() + const listeners = new Array() + const { db } = yield* Database.Service + const logReadPageSize = options?.logReadPageSize ?? 512 + const persist = options?.persist ?? false - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - yield* PubSub.shutdown(pubsub.live) - yield* Effect.forEach( - pubsub.durable.values(), - (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }), - { discard: true }, - ) - yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true }) - }), - ) - - function commitDurableEvent( - definition: Event.Definition, - event: Event.Payload, - input?: { - readonly seq: number - readonly aggregateID: string - readonly ownerID?: string - readonly strictOwner?: boolean - }, - commit?: (seq: number) => Effect.Effect, - ) { - return Effect.gen(function* () { - const durable = definition?.durable - if (durable) { - const aggregateID = (event.data as Record)[durable.aggregate] - if (typeof aggregateID !== "string") { - yield* Effect.die( - new InvalidDurableEventError({ - type: event.type, - message: `Expected string aggregate field ${durable.aggregate}`, - }), - ) - } else { - if (input && input.aggregateID !== aggregateID) { + const getOrCreate = (definition: Event.Definition) => + Effect.gen(function* () { + const existing = pubsub.typed.get(definition.type) + if (existing) return existing + const created = yield* PubSub.unbounded() + pubsub.typed.set(definition.type, created) + return created + }) + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + yield* PubSub.shutdown(pubsub.live) + yield* Effect.forEach( + pubsub.durable.values(), + (pubsubs) => Effect.forEach(pubsubs, PubSub.shutdown, { discard: true }), + { discard: true }, + ) + yield* Effect.forEach(pubsub.typed.values(), PubSub.shutdown, { discard: true }) + }), + ) + + function commitDurableEvent( + definition: Event.Definition, + event: Event.Payload, + input?: { + readonly seq: number + readonly aggregateID: string + readonly ownerID?: string + readonly strictOwner?: boolean + }, + commit?: (seq: number) => Effect.Effect, + ) { + return Effect.gen(function* () { + const durable = definition?.durable + if (durable) { + const aggregateID = (event.data as Record)[durable.aggregate] + if (typeof aggregateID !== "string") { yield* Effect.die( new InvalidDurableEventError({ type: event.type, - message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`, + message: `Expected string aggregate field ${durable.aggregate}`, }), ) - } - const list = projectors.get(versionedType(definition.type, durable.version)) ?? [] - return yield* Effect.uninterruptible( - Effect.gen(function* () { - const committed = yield* db - .transaction( - () => - Effect.gen(function* () { - const row = yield* db - .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, aggregateID)) - .get() - .pipe(Effect.orDie) - const latest = row?.seq ?? -1 - const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record< - string, - unknown - > - if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { - yield* Effect.die( - new InvalidDurableEventError({ - type: event.type, - message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, - }), - ) - } - if (input && input.seq <= latest) { - if (!persist) return - const stored = yield* db - .select() - .from(EventTable) - .where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq))) + } else { + if (input && input.aggregateID !== aggregateID) { + yield* Effect.die( + new InvalidDurableEventError({ + type: event.type, + message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`, + }), + ) + } + const list = projectors.get(versionedType(definition.type, durable.version)) ?? [] + return yield* Effect.uninterruptible( + Effect.gen(function* () { + const committed = yield* db + .transaction( + () => + Effect.gen(function* () { + const row = yield* db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) .get() .pipe(Effect.orDie) - if ( - stored?.id === event.id && - stored.type === versionedType(definition.type, durable.version) && - stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) && - isDeepStrictEqual(stored.data, encoded) - ) { - if (input.ownerID && row?.ownerID == null) { - yield* db - .update(EventSequenceTable) - .set({ owner_id: input.ownerID }) - .where(eq(EventSequenceTable.aggregate_id, aggregateID)) - .run() - .pipe(Effect.orDie) + const latest = row?.seq ?? -1 + const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record< + string, + unknown + > + if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) { + yield* Effect.die( + new InvalidDurableEventError({ + type: event.type, + message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`, + }), + ) + } + if (input && input.seq <= latest) { + if (!persist) return + const stored = yield* db + .select() + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq))) + .get() + .pipe(Effect.orDie) + if ( + stored?.id === event.id && + stored.type === versionedType(definition.type, durable.version) && + stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) && + isDeepStrictEqual(stored.data, encoded) + ) { + if (input.ownerID && row?.ownerID == null) { + yield* db + .update(EventSequenceTable) + .set({ owner_id: input.ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run() + .pipe(Effect.orDie) + } + return } + yield* Effect.die( + new InvalidDurableEventError({ + type: event.type, + message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`, + }), + ) + } + if (input && row?.ownerID && row.ownerID !== input.ownerID) { return } - yield* Effect.die( - new InvalidDurableEventError({ - type: event.type, - message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`, - }), - ) - } - if (input && row?.ownerID && row.ownerID !== input.ownerID) { - return - } - const seq = input?.seq ?? latest + 1 - if (input && seq !== latest + 1) { - yield* Effect.die( - new InvalidDurableEventError({ - type: event.type, - message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`, - }), - ) - } - if (persist) { - const stored = yield* db - .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq }) - .from(EventTable) - .where(eq(EventTable.id, event.id)) - .get() - .pipe(Effect.orDie) - if (stored) + const seq = input?.seq ?? latest + 1 + if (input && seq !== latest + 1) { yield* Effect.die( new InvalidDurableEventError({ type: event.type, - message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, + message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`, }), ) - } - const committed = { - ...event, - durable: { aggregateID, seq, version: durable.version }, - } as Event.Payload - for (const projector of list) { - yield* projector(committed) - } - if (commit) yield* commit(seq) - yield* db - .insert(EventSequenceTable) - .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) - .onConflictDoUpdate({ - target: EventSequenceTable.aggregate_id, - set: { - seq: sql`max(${EventSequenceTable.seq}, ${seq})`, - ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}), - }, - }) - .run() - .pipe(Effect.orDie) - if (persist) + } + if (persist) { + const stored = yield* db + .select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.id, event.id)) + .get() + .pipe(Effect.orDie) + if (stored) + yield* Effect.die( + new InvalidDurableEventError({ + type: event.type, + message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`, + }), + ) + } + const committed = { + ...event, + durable: { aggregateID, seq, version: durable.version }, + } as Event.Payload + for (const projector of list) { + yield* projector(committed) + } + if (commit) yield* commit(seq) yield* db - .insert(EventTable) - .values([ - { - id: event.id, - aggregate_id: aggregateID, - seq, - created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)), - type: versionedType(definition.type, durable.version), - data: encoded, + .insert(EventSequenceTable) + .values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }]) + .onConflictDoUpdate({ + target: EventSequenceTable.aggregate_id, + set: { + seq: sql`max(${EventSequenceTable.seq}, ${seq})`, + ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}), }, - ]) + }) .run() .pipe(Effect.orDie) - return { aggregateID, seq } - }), - { behavior: "immediate" }, - ) - .pipe(Effect.orDie) - if (committed) { - yield* Effect.forEach( - pubsub.durable.get(committed.aggregateID) ?? [], - (wake) => PubSub.publish(wake, undefined), - { discard: true }, - ) - } - return committed + if (persist) + yield* db + .insert(EventTable) + .values([ + { + id: event.id, + aggregate_id: aggregateID, + seq, + created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)), + type: versionedType(definition.type, durable.version), + data: encoded, + }, + ]) + .run() + .pipe(Effect.orDie) + return { aggregateID, seq } + }), + { behavior: "immediate" }, + ) + .pipe(Effect.orDie) + if (committed) { + yield* Effect.forEach( + pubsub.durable.get(committed.aggregateID) ?? [], + (wake) => PubSub.publish(wake, undefined), + { discard: true }, + ) + } + return committed + }), + ) + } + } + }) + } + + function publishEvent( + definition: D, + event: Event.Payload, + commit?: PublishOptions["commit"], + ) { + return Effect.gen(function* () { + if (!definition?.durable && commit) + return yield* Effect.die( + new InvalidDurableEventError({ + type: event.type, + message: "Local commit hooks require a durable event", }), ) - } - } - }) - } - - function publishEvent(definition: D, event: Event.Payload, commit?: PublishOptions["commit"]) { - return Effect.gen(function* () { - if (!definition?.durable && commit) - return yield* Effect.die( - new InvalidDurableEventError({ - type: event.type, - message: "Local commit hooks require a durable event", - }), - ) - if (definition?.durable) { - const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit) - if (committed) { - event = { - ...event, - durable: envelope(committed.aggregateID, committed.seq, definition.durable.version), + if (definition?.durable) { + const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit) + if (committed) { + event = { + ...event, + durable: envelope(committed.aggregateID, committed.seq, definition.durable.version), + } + yield* notify(event as Event.Payload, true) + return event } - yield* notify(event as Event.Payload, true) - return event } - } - yield* notify(event as Event.Payload, false) - return event - }) - } - - const observe = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect) => - Effect.suspend(() => observer(event)).pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterrupts(cause), - (cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }), - ), - ) + yield* notify(event as Event.Payload, false) + return event + }) + } - function notify(event: Event.Payload, isolateListeners: boolean) { - return Effect.gen(function* () { - yield* Effect.forEach( - listeners, - (listener) => (isolateListeners ? observe(event, listener) : listener(event)), - { discard: true }, - ) - const typed = pubsub.typed.get(event.type) - if (typed) yield* PubSub.publish(typed, event) - yield* PubSub.publish(pubsub.live, event) - }) - } - - function publish(definition: D, data: Event.Data, options?: PublishOptions) { - return Effect.gen(function* () { - const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) - const location = - options?.location ?? - (serviceLocation - ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } - : undefined) - return yield* publishEvent( - definition, - { - id: options?.id ?? Event.ID.create(), - created: yield* DateTime.now, - ...(options?.metadata ? { metadata: options.metadata } : {}), - type: definition.type, - ...(location ? { location } : {}), - data, - } as Event.Payload, - options?.commit, + const observe = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect) => + Effect.suspend(() => observer(event)).pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.logError("Event listener failed", { eventID: event.id, eventType: event.type, cause }), + ), ) - }) - } - - function replay( - event: SerializedEvent, - options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, - ) { - return Effect.gen(function* () { - const definition = Durable.get(event.type) - if (!definition?.durable) { - yield* Effect.die( - new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }), + + function notify(event: Event.Payload, isolateListeners: boolean) { + return Effect.gen(function* () { + yield* Effect.forEach( + listeners, + (listener) => (isolateListeners ? observe(event, listener) : listener(event)), + { discard: true }, ) - } else { - const payload = { - id: event.id, - created: event.created ?? DateTime.makeUnsafe(0), - type: definition.type, - data: Schema.decodeUnknownSync(definition.data)(event.data), - } as Event.Payload - const committed = yield* commitDurableEvent(definition, payload, { - seq: event.seq, - aggregateID: event.aggregateID, - ownerID: options?.ownerID, - strictOwner: options?.strictOwner, - }) - if (committed && options?.publish) { - yield* notify( - { - ...payload, - durable: envelope(committed.aggregateID, committed.seq, definition.durable.version), - }, - true, + const typed = pubsub.typed.get(event.type) + if (typed) yield* PubSub.publish(typed, event) + yield* PubSub.publish(pubsub.live, event) + }) + } + + function publish(definition: D, data: Event.Data, options?: PublishOptions) { + return Effect.gen(function* () { + const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service)) + const location = + options?.location ?? + (serviceLocation + ? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID } + : undefined) + return yield* publishEvent( + definition, + { + id: options?.id ?? Event.ID.create(), + created: yield* DateTime.now, + ...(options?.metadata ? { metadata: options.metadata } : {}), + type: definition.type, + ...(location ? { location } : {}), + data, + } as Event.Payload, + options?.commit, + ) + }) + } + + function replay( + event: SerializedEvent, + options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, + ) { + return Effect.gen(function* () { + const definition = Durable.get(event.type) + if (!definition?.durable) { + yield* Effect.die( + new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }), ) + } else { + const payload = { + id: event.id, + created: event.created ?? DateTime.makeUnsafe(0), + type: definition.type, + data: Schema.decodeUnknownSync(definition.data)(event.data), + } as Event.Payload + const committed = yield* commitDurableEvent(definition, payload, { + seq: event.seq, + aggregateID: event.aggregateID, + ownerID: options?.ownerID, + strictOwner: options?.strictOwner, + }) + if (committed && options?.publish) { + yield* notify( + { + ...payload, + durable: envelope(committed.aggregateID, committed.seq, definition.durable.version), + }, + true, + ) + } } - } - }) - } - - function replayAll( - events: SerializedEvent[], - options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, - ) { - return Effect.gen(function* () { - const source = events[0]?.aggregateID - if (!source) return undefined - if (events.some((event) => event.aggregateID !== source)) { - yield* Effect.die( - new InvalidDurableEventError({ - type: events[0]?.type ?? "unknown", - message: "Replay events must belong to the same aggregate", + }) + } + + function replayAll( + events: SerializedEvent[], + options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean }, + ) { + return Effect.gen(function* () { + const source = events[0]?.aggregateID + if (!source) return undefined + if (events.some((event) => event.aggregateID !== source)) { + yield* Effect.die( + new InvalidDurableEventError({ + type: events[0]?.type ?? "unknown", + message: "Replay events must belong to the same aggregate", + }), + ) + } + for (const event of events) { + yield* replay(event, options) + } + return source + }) + } + + function remove(aggregateID: string) { + return db + .transaction(() => + Effect.gen(function* () { + yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() + yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() }), ) - } - for (const event of events) { - yield* replay(event, options) - } - return source - }) - } + .pipe(Effect.orDie) + } - function remove(aggregateID: string) { - return db - .transaction(() => - Effect.gen(function* () { - yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run() - yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run() - }), - ) - .pipe(Effect.orDie) - } - - function claim(aggregateID: string, ownerID: string) { - return db - .update(EventSequenceTable) - .set({ owner_id: ownerID }) - .where(eq(EventSequenceTable.aggregate_id, aggregateID)) - .run() - .pipe(Effect.orDie) - } - - const local = (stream: Stream.Stream) => - Stream.unwrap( - Effect.serviceOption(Location.Service).pipe( - Effect.map((location) => - Option.match(location, { - onNone: () => stream, - onSome: (location) => - stream.pipe( - Stream.filter( - (event) => - !event.location || - (event.location.directory === location.directory && - event.location.workspaceID === location.workspaceID), + function claim(aggregateID: string, ownerID: string) { + return db + .update(EventSequenceTable) + .set({ owner_id: ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run() + .pipe(Effect.orDie) + } + + const local = (stream: Stream.Stream) => + Stream.unwrap( + Effect.serviceOption(Location.Service).pipe( + Effect.map((location) => + Option.match(location, { + onNone: () => stream, + onSome: (location) => + stream.pipe( + Stream.filter( + (event) => + !event.location || + (event.location.directory === location.directory && + event.location.workspaceID === location.workspaceID), + ), ), - ), - }), + }), + ), ), - ), - ) + ) - function subscribe(): Stream.Stream - function subscribe(definition: D): Stream.Stream> - function subscribe( - definitions: D, - ): Stream.Stream> - function subscribe(input?: Event.Definition | readonly Event.Definition[]): Stream.Stream { - if (input === undefined) return streamLive() - if (isDefinition(input)) { - return local(Stream.unwrap(getOrCreate(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))) + function subscribe(): Stream.Stream + function subscribe(definition: D): Stream.Stream> + function subscribe( + definitions: D, + ): Stream.Stream> + function subscribe(input?: Event.Definition | readonly Event.Definition[]): Stream.Stream { + if (input === undefined) return streamLive() + if (isDefinition(input)) { + return local(Stream.unwrap(getOrCreate(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))) + } + const types = new Set(input.map((definition) => definition.type)) + return streamLive().pipe(Stream.filter((event) => types.has(event.type))) } - const types = new Set(input.map((definition) => definition.type)) - return streamLive().pipe(Stream.filter((event) => types.has(event.type))) - } - - const streamLive = (): Stream.Stream => local(Stream.fromPubSub(pubsub.live)) - - const readAfter = ( - aggregateID: string, - after: number, - input: { readonly through: number; readonly limit: number }, - ) => - (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe( - Effect.andThen( - Effect.suspend(() => { - const query = db - .select() - .from(EventTable) - .where( - and( - eq(EventTable.aggregate_id, aggregateID), - gt(EventTable.seq, after), - lte(EventTable.seq, input.through), - ), - ) - .orderBy(asc(EventTable.seq)) - return query.limit(input.limit).all() - }), - ), - Effect.orDie, - // Skip types missing from the durable manifest instead of failing the - // read: the aggregate may hold events this process cannot decode. The - // raw tail seq keeps cursors advancing across the resulting gaps. - Effect.map((rows) => ({ - seq: rows.at(-1)?.seq, - events: rows.flatMap((event) => { - if (!Durable.get(event.type)?.durable) return [] - return [ - decodeSerializedEvent({ - id: event.id, - created: DateTime.makeUnsafe(event.created), - aggregateID: event.aggregate_id, - seq: event.seq, - type: event.type, - data: event.data, - }), - ] - }), - })), - ) - const subscribeDurable = (aggregateID: string) => - Effect.gen(function* () { - const wake = yield* PubSub.sliding(1) - const subscription = yield* PubSub.subscribe(wake) - yield* Effect.acquireRelease( - Effect.sync(() => { - const wakes = pubsub.durable.get(aggregateID) ?? new Set() - wakes.add(wake) - pubsub.durable.set(aggregateID, wakes) - }), - () => - Effect.sync(() => { - const wakes = pubsub.durable.get(aggregateID) - wakes?.delete(wake) - if (wakes?.size === 0) pubsub.durable.delete(aggregateID) - }).pipe(Effect.andThen(PubSub.shutdown(wake))), + const streamLive = (): Stream.Stream => local(Stream.fromPubSub(pubsub.live)) + + const readAfter = ( + aggregateID: string, + after: number, + input: { readonly through: number; readonly limit: number }, + ) => + (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe( + Effect.andThen( + Effect.suspend(() => { + const query = db + .select() + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, aggregateID), + gt(EventTable.seq, after), + lte(EventTable.seq, input.through), + ), + ) + .orderBy(asc(EventTable.seq)) + return query.limit(input.limit).all() + }), + ), + Effect.orDie, + // Skip types missing from the durable manifest instead of failing the + // read: the aggregate may hold events this process cannot decode. The + // raw tail seq keeps cursors advancing across the resulting gaps. + Effect.map((rows) => ({ + seq: rows.at(-1)?.seq, + events: rows.flatMap((event) => { + if (!Durable.get(event.type)?.durable) return [] + return [ + decodeSerializedEvent({ + id: event.id, + created: DateTime.makeUnsafe(event.created), + aggregateID: event.aggregate_id, + seq: event.seq, + type: event.type, + data: event.data, + }), + ] + }), + })), ) - return subscription - }) - const log = (input: { - readonly aggregateID: string - readonly after?: number - readonly follow?: boolean - }): Stream.Stream => - Stream.unwrap( + const subscribeDurable = (aggregateID: string) => Effect.gen(function* () { - let sequence = input.after ?? -1 - const readThrough = (through: number): Stream.Stream => - Stream.paginate(sequence, (cursor) => - readAfter(input.aggregateID, cursor, { through, limit: logReadPageSize }).pipe( - Effect.tap((page) => - Effect.sync(() => { - sequence = page.seq ?? sequence - }), - ), - Effect.map( - (page) => - [ - page.events, - page.seq !== undefined && page.seq < through ? Option.some(page.seq) : Option.none(), - ] as const, + const wake = yield* PubSub.sliding(1) + const subscription = yield* PubSub.subscribe(wake) + yield* Effect.acquireRelease( + Effect.sync(() => { + const wakes = pubsub.durable.get(aggregateID) ?? new Set() + wakes.add(wake) + pubsub.durable.set(aggregateID, wakes) + }), + () => + Effect.sync(() => { + const wakes = pubsub.durable.get(aggregateID) + wakes?.delete(wake) + if (wakes?.size === 0) pubsub.durable.delete(aggregateID) + }).pipe(Effect.andThen(PubSub.shutdown(wake))), + ) + return subscription + }) + + const log = (input: { + readonly aggregateID: string + readonly after?: number + readonly follow?: boolean + }): Stream.Stream => + Stream.unwrap( + Effect.gen(function* () { + let sequence = input.after ?? -1 + const readThrough = (through: number): Stream.Stream => + Stream.paginate(sequence, (cursor) => + readAfter(input.aggregateID, cursor, { through, limit: logReadPageSize }).pipe( + Effect.tap((page) => + Effect.sync(() => { + sequence = page.seq ?? sequence + }), + ), + Effect.map( + (page) => + [ + page.events, + page.seq !== undefined && page.seq < through ? Option.some(page.seq) : Option.none(), + ] as const, + ), ), - ), + ) + // Subscribing before the historical read means events committed during + // replay either appear in the read or arrive through a post-marker wake. + const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined + const target = yield* latestSequence(db, input.aggregateID) + const marker: EventLog.Synced = { + type: "log.synced", + aggregateID: input.aggregateID, + ...(target >= 0 ? { seq: Event.Seq.make(target) } : {}), + } + const replay: Stream.Stream = readThrough(target).pipe( + Stream.map((event): LogItem => event), + Stream.concat(Stream.make(marker)), ) - // Subscribing before the historical read means events committed during - // replay either appear in the read or arrive through a post-marker wake. - const wakes = input.follow ? yield* subscribeDurable(input.aggregateID) : undefined - const target = yield* latestSequence(db, input.aggregateID) - const marker: EventLog.Synced = { - type: "log.synced", - aggregateID: input.aggregateID, - ...(target >= 0 ? { seq: Event.Seq.make(target) } : {}), - } - const replay: Stream.Stream = readThrough(target).pipe( - Stream.map((event): LogItem => event), - Stream.concat(Stream.make(marker)), - ) - if (!wakes) return replay - const live: Stream.Stream = Stream.fromSubscription(wakes).pipe( - Stream.mapEffect(() => latestSequence(db, input.aggregateID)), - Stream.filter((target) => target > sequence), - Stream.flatMap((target) => readThrough(target)), - Stream.map((event): LogItem => event), - ) - return Stream.concat(replay, live) - }), - ) + if (!wakes) return replay + const live: Stream.Stream = Stream.fromSubscription(wakes).pipe( + Stream.mapEffect(() => latestSequence(db, input.aggregateID)), + Stream.filter((target) => target > sequence), + Stream.flatMap((target) => readThrough(target)), + Stream.map((event): LogItem => event), + ) + return Stream.concat(replay, live) + }), + ) - const listen = (listener: Subscriber): Effect.Effect => - Effect.sync(() => { - listeners.push(listener) - return Effect.sync(() => { - const index = listeners.indexOf(listener) - if (index >= 0) listeners.splice(index, 1) + const listen = (listener: Subscriber): Effect.Effect => + Effect.sync(() => { + listeners.push(listener) + return Effect.sync(() => { + const index = listeners.indexOf(listener) + if (index >= 0) listeners.splice(index, 1) + }) }) - }) - const project = (definition: D, projector: Subscriber): Effect.Effect => - Effect.sync(() => { - const key = definition.durable ? versionedType(definition.type, definition.durable.version) : definition.type - const list = projectors.get(key) ?? [] - list.push((event) => projector(event as Event.Payload)) - projectors.set(key, list) - }) + const project = (definition: D, projector: Subscriber): Effect.Effect => + Effect.sync(() => { + const key = definition.durable + ? versionedType(definition.type, definition.durable.version) + : definition.type + const list = projectors.get(key) ?? [] + list.push((event) => projector(event as Event.Payload)) + projectors.set(key, list) + }) - return Service.of({ - publish, - subscribe, - log, - listen, - project, - replay, - replayAll, - remove, - claim, - }) - })), + return Service.of({ + publish, + subscribe, + log, + listen, + project, + replay, + replayAll, + remove, + claim, + }) + }), + ), }) } diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 3314769d04fc..c1dfd5765e86 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -115,8 +115,7 @@ const layer = Layer.effect( } draft.providers.set(providerID, record) } - const model = - record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo) + const model = record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo) if (!record.models.has(modelID)) record.models.set(modelID, model) fn(model) model.id = modelID @@ -249,8 +248,7 @@ const layer = Layer.effect( items, Order.mapInput( Order.Number, - (item: (typeof candidates)[number]) => - (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, + (item: (typeof candidates)[number]) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, ), ) return projectModel(selected.model, provider) diff --git a/packages/core/src/codemode/catalog.ts b/packages/core/src/codemode/catalog.ts index 00d1b71e2d97..6442749abd6c 100644 --- a/packages/core/src/codemode/catalog.ts +++ b/packages/core/src/codemode/catalog.ts @@ -46,9 +46,7 @@ export function summarize(entries: ReadonlyArray, budget = INLINE_BUDGET) .map((entry) => { const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? "" const description = - firstLine.length > DESCRIPTION_LIMIT - ? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..." - : firstLine + firstLine.length > DESCRIPTION_LIMIT ? firstLine.slice(0, DESCRIPTION_LIMIT - 3) + "..." : firstLine const suffix = description.length === 0 ? "" : ` // ${description}` return { path: entry.path, line: ` - ${entry.signature}${suffix}` } }) diff --git a/packages/core/src/codemode/tool.ts b/packages/core/src/codemode/tool.ts index b0a13ac58ff1..db4c067345c3 100644 --- a/packages/core/src/codemode/tool.ts +++ b/packages/core/src/codemode/tool.ts @@ -43,14 +43,9 @@ const description = [ export const create = ( registrations: ReadonlyMap, - executeTool: ( - name: string, - tool: Info, - input: unknown, - context: Context, - ) => Effect.Effect, + executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect, ) => { - return ({ + return { name: "execute", description, input: CodeMode.Input, @@ -76,7 +71,7 @@ export const create = ( const content = typeof executed.content === "string" ? [{ type: "text" as const, text: executed.content }] - : executed.content ?? [] + : (executed.content ?? []) const outputFileParts = outputFiles(content) if (outputFileParts.length > 0) yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }]) @@ -134,7 +129,7 @@ export const create = ( metadata, } }), - }) satisfies Info + } satisfies Info } export const catalog = (registrations: ReadonlyMap) => { diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 7ce432af8db6..d635d9ebb6b7 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -52,103 +52,108 @@ export interface Interface extends State.Transformable { export class Service extends Context.Service()("@opencode/Command") {} -export const layer = (options?: ShellSelect.Options) => Layer.effect( - Service, - Effect.gen(function* () { - const mcp = yield* MCP.Service - const bus = yield* Bus.Service - const processes = yield* AppProcess.Service - const config = yield* Config.Service - const location = yield* Location.Service - const state = State.create({ - name: "command", - initial: () => ({ commands: new Map() }), - draft: (draft) => ({ - list: () => Array.from(draft.commands.values()) as Info[], - get: (name) => draft.commands.get(name), - update: (name, update) => { - const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable) - if (!draft.commands.has(name)) draft.commands.set(name, current) - update(current) - current.name = name - }, - remove: (name) => { - draft.commands.delete(name) - }, - }), - finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid), - }) - const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined - const mcpCommands = Effect.fnUntraced(function* () { - return (yield* mcp.prompts()).map((prompt) => - Info.make({ - name: mcpCommandName(prompt.server, prompt.name), - template: "", - description: prompt.description, +export const layer = (options?: ShellSelect.Options) => + Layer.effect( + Service, + Effect.gen(function* () { + const mcp = yield* MCP.Service + const bus = yield* Bus.Service + const processes = yield* AppProcess.Service + const config = yield* Config.Service + const location = yield* Location.Service + const state = State.create({ + name: "command", + initial: () => ({ commands: new Map() }), + draft: (draft) => ({ + list: () => Array.from(draft.commands.values()) as Info[], + get: (name) => draft.commands.get(name), + update: (name, update) => { + const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable) + if (!draft.commands.has(name)) draft.commands.set(name, current) + update(current) + current.name = name + }, + remove: (name) => { + draft.commands.delete(name) + }, }), - ) - }) - - return Service.of({ - reload: state.reload, - transform: state.transform, - get: Effect.fn("Command.get")(function* (name) { - const command = staticCommand(name) - if (command) return command - return (yield* mcpCommands()).find((command) => command.name === name) - }), - list: Effect.fn("Command.list")(function* () { - const commands = Array.from(state.get().commands.values()) as Info[] - const names = new Set(commands.map((command) => command.name)) - return [ - ...commands, - ...(yield* mcpCommands()).filter((command) => !names.has(command.name)), - ] - }), - evaluate: Effect.fn("Command.evaluate")(function* (input) { - const command = staticCommand(input.name) - if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", { - config, - location, - processes, - shell: options, - }) - - const prompt = (yield* mcp.prompts()).find((prompt) => mcpCommandName(prompt.server, prompt.name) === input.name) - if (!prompt) return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` }) - const result = yield* mcp - .prompt({ - server: prompt.server, - name: prompt.name, - args: Object.fromEntries( - (prompt.arguments ?? []).map((argument, index) => [ - argument.name, - parseArguments(input.arguments ?? "")[index] ?? "", - ]), - ), - }) - .pipe( - Effect.catchTag( - "MCP.NotFoundError", - () => + finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid), + }) + const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined + const mcpCommands = Effect.fnUntraced(function* () { + return (yield* mcp.prompts()).map((prompt) => + Info.make({ + name: mcpCommandName(prompt.server, prompt.name), + template: "", + description: prompt.description, + }), + ) + }) + + return Service.of({ + reload: state.reload, + transform: state.transform, + get: Effect.fn("Command.get")(function* (name) { + const command = staticCommand(name) + if (command) return command + return (yield* mcpCommands()).find((command) => command.name === name) + }), + list: Effect.fn("Command.list")(function* () { + const commands = Array.from(state.get().commands.values()) as Info[] + const names = new Set(commands.map((command) => command.name)) + return [...commands, ...(yield* mcpCommands()).filter((command) => !names.has(command.name))] + }), + evaluate: Effect.fn("Command.evaluate")(function* (input) { + const command = staticCommand(input.name) + if (command) + return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", { + config, + location, + processes, + shell: options, + }) + + const prompt = (yield* mcp.prompts()).find( + (prompt) => mcpCommandName(prompt.server, prompt.name) === input.name, + ) + if (!prompt) + return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` }) + const result = yield* mcp + .prompt({ + server: prompt.server, + name: prompt.name, + args: Object.fromEntries( + (prompt.arguments ?? []).map((argument, index) => [ + argument.name, + parseArguments(input.arguments ?? "")[index] ?? "", + ]), + ), + }) + .pipe( + Effect.catchTag("MCP.NotFoundError", () => Effect.fail( new EvaluationError({ command: input.name, message: `MCP server could not be found while evaluating prompt: ${prompt.server}`, }), ), - ), - ) - if (!result) - return yield* new EvaluationError({ - command: input.name, - message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`, - }) - return { text: result.messages.map((message) => promptMessageText(message.content)).join("\n").trim() } - }), - }) - }), -) + ), + ) + if (!result) + return yield* new EvaluationError({ + command: input.name, + message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`, + }) + return { + text: result.messages + .map((message) => promptMessageText(message.content)) + .join("\n") + .trim(), + } + }), + }) + }), + ) function evaluateTemplate( command: string, @@ -179,7 +184,8 @@ function evaluateArguments(template: string, input: string) { return args[argIndex] }) const withArguments = expanded.replaceAll("$ARGUMENTS", input) - if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()) return `${withArguments}\n\n${input}`.trim() + if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()) + return `${withArguments}\n\n${input}`.trim() return withArguments.trim() } @@ -201,14 +207,23 @@ const evaluateShell = Effect.fnUntraced(function* ( (match) => { const source = match[1] ?? "" return services.processes - .run(ChildProcess.make(shell, ShellSelect.args(shell, source), { cwd: services.location.directory, stdin: "ignore" }), { - combineOutput: true, - }) + .run( + ChildProcess.make(shell, ShellSelect.args(shell, source), { + cwd: services.location.directory, + stdin: "ignore", + }), + { + combineOutput: true, + }, + ) .pipe( Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")), Effect.mapError( (error) => - new EvaluationError({ command, message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}` }), + new EvaluationError({ + command, + message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`, + }), ), ) }, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 6072acb9cbec..d932181be75e 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -78,287 +78,292 @@ export const testLayer = (initial: Entry[] = []) => }), ) -export const layer = (options?: Options) => Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const location = yield* Location.Service - const watcher = yield* Watcher.Service - const bus = yield* Bus.Service - const credentials = yield* Credential.Service - const wellknown = yield* WellKnown.Service - const names = ["opencode.json", "opencode.jsonc"] - const reloadLock = Semaphore.makeUnsafe(1) - const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const - const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) - const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) { - const errors: ParseError[] = [] - const input: unknown = parse(text, errors, { allowTrailingComma: true }) - if (errors.length) { +export const layer = (options?: Options) => + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const location = yield* Location.Service + const watcher = yield* Watcher.Service + const bus = yield* Bus.Service + const credentials = yield* Credential.Service + const wellknown = yield* WellKnown.Service + const names = ["opencode.json", "opencode.jsonc"] + const reloadLock = Semaphore.makeUnsafe(1) + const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const + const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) + const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) { + const errors: ParseError[] = [] + const input: unknown = parse(text, errors, { allowTrailingComma: true }) + if (errors.length) { + yield* Effect.logWarning("configuration normalization diagnostic", { + source, + path: "$", + kind: "invalid", + action: "rejected malformed JSON or JSONC document", + }) + return + } + const result = ConfigNormalize.normalize(input) + yield* Effect.forEach(result.diagnostics, (diagnostic) => + Effect.logWarning("configuration normalization diagnostic", { + source, + path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`, + kind: diagnostic.kind, + action: diagnostic.message, + }), + ) + if (result.type === "rejected") return + const info = Option.getOrUndefined(decodeInfo(result.encoded)) + if (info) return info yield* Effect.logWarning("configuration normalization diagnostic", { source, path: "$", kind: "invalid", - action: "rejected malformed JSON or JSONC document", + action: "rejected canonical configuration after final validation", }) - return - } - const result = ConfigNormalize.normalize(input) - yield* Effect.forEach(result.diagnostics, (diagnostic) => - Effect.logWarning("configuration normalization diagnostic", { - source, - path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`, - kind: diagnostic.kind, - action: diagnostic.message, - }), - ) - if (result.type === "rejected") return - const info = Option.getOrUndefined(decodeInfo(result.encoded)) - if (info) return info - yield* Effect.logWarning("configuration normalization diagnostic", { - source, - path: "$", - kind: "invalid", - action: "rejected canonical configuration after final validation", }) - }) - const loadFile = Effect.fnUntraced(function* (filepath: string) { - const text = yield* fs.readFileStringSafe(filepath) - if (text === undefined) return - const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) - const info = yield* parseInfo(substituted, filepath) - if (!info) return - return new Document({ type: "document", path: filepath, info }) - }) + const loadFile = Effect.fnUntraced(function* (filepath: string) { + const text = yield* fs.readFileStringSafe(filepath) + if (text === undefined) return + const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text }) + const info = yield* parseInfo(substituted, filepath) + if (!info) return + return new Document({ type: "document", path: filepath, info }) + }) - const loadWellknown = Effect.fn("Config.loadWellknown")(function* () { - const entries = yield* wellknown - .entries() - .pipe( - Effect.catch((error) => - Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)), - ), - ) - return yield* Effect.forEach(entries, (entry) => - Effect.gen(function* () { - const auth = entry.manifest.auth - if (!auth) return [] - const credential = (yield* credentials.list(entry.integrationID)).findLast( - (credential) => credential.value.type === "key", - ) - if (!credential || credential.value.type !== "key") return [] - const variables = { [auth.env]: credential.value.key } - const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie) - return yield* Effect.forEach(configs, (config) => - ConfigVariable.substitute({ - type: "virtual", - source: entry.origin, - dir: entry.origin, - text: JSON.stringify(config), - env: variables, - }).pipe( - Effect.flatMap((text) => parseInfo(text, entry.origin)), - Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)), + const loadWellknown = Effect.fn("Config.loadWellknown")(function* () { + const entries = yield* wellknown + .entries() + .pipe( + Effect.catch((error) => + Effect.logWarning("failed to discover wellknown config", { error }).pipe(Effect.as([] as const)), ), - ).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined))) - }), - ).pipe(Effect.map((documents) => documents.flat())) - }) - - const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { - return [ - ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( - Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), - )), - new Directory({ type: "directory", path: directory }), - ] - }) + ) + return yield* Effect.forEach(entries, (entry) => + Effect.gen(function* () { + const auth = entry.manifest.auth + if (!auth) return [] + const credential = (yield* credentials.list(entry.integrationID)).findLast( + (credential) => credential.value.type === "key", + ) + if (!credential || credential.value.type !== "key") return [] + const variables = { [auth.env]: credential.value.key } + const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie) + return yield* Effect.forEach(configs, (config) => + ConfigVariable.substitute({ + type: "virtual", + source: entry.origin, + dir: entry.origin, + text: JSON.stringify(config), + env: variables, + }).pipe( + Effect.flatMap((text) => parseInfo(text, entry.origin)), + Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)), + ), + ).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined))) + }), + ).pipe(Effect.map((documents) => documents.flat())) + }) - const discover = Effect.fn("Config.discover")(function* () { - const globalDirectory = AbsolutePath.make(global.config) - const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents")) - const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude")) - const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) - const discovered = locationIsGlobal || options?.project === false - ? [] - : yield* fs - .up({ - targets: [".opencode", ".claude", ".agents", ...names.toReversed()], - start: location.directory, - }) - .pipe(Effect.orDie) + const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) { + return [ + ...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe( + Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), + )), + new Directory({ type: "directory", path: directory }), + ] + }) - // We load certain files from a few other folders in the ecosystem - const claude = [ - ...new Set([ - ...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []), - ...discovered.filter((item) => path.basename(item) === ".claude"), - ]), - ].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })) - const agents = [ - ...new Set([ - ...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []), - ...discovered.filter((item) => path.basename(item) === ".agents"), - ]), - ].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })) + const discover = Effect.fn("Config.discover")(function* () { + const globalDirectory = AbsolutePath.make(global.config) + const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents")) + const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude")) + const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) + const discovered = + locationIsGlobal || options?.project === false + ? [] + : yield* fs + .up({ + targets: [".opencode", ".claude", ".agents", ...names.toReversed()], + start: location.directory, + }) + .pipe(Effect.orDie) - const directories = [ - globalDirectory, - ...discovered - .filter((item) => path.basename(item) === ".opencode") - .toReversed() - .map((directory) => AbsolutePath.make(directory)), - ] - const directPaths = discovered - .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))) - .toReversed() - const direct = yield* Effect.forEach(directPaths, (filepath) => - loadFile(filepath).pipe( - Effect.map((config) => [ - ...(config ? [config] : []), - new File({ type: "file", path: AbsolutePath.make(filepath) }), + // We load certain files from a few other folders in the ecosystem + const claude = [ + ...new Set([ + ...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []), + ...discovered.filter((item) => path.basename(item) === ".claude"), ]), - ), - ).pipe( - Effect.orDie, - Effect.map((entries) => entries.flat()), - ) + ].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })) + const agents = [ + ...new Set([ + ...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []), + ...discovered.filter((item) => path.basename(item) === ".agents"), + ]), + ].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })) - const file = options?.file - const explicit = file - ? yield* loadFile(path.resolve(file)).pipe( + const directories = [ + globalDirectory, + ...discovered + .filter((item) => path.basename(item) === ".opencode") + .toReversed() + .map((directory) => AbsolutePath.make(directory)), + ] + const directPaths = discovered + .filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))) + .toReversed() + const direct = yield* Effect.forEach(directPaths, (filepath) => + loadFile(filepath).pipe( Effect.map((config) => [ ...(config ? [config] : []), - new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }), + new File({ type: "file", path: AbsolutePath.make(filepath) }), ]), - Effect.orDie, - ) - : [] - const content = options?.content !== undefined - ? yield* ConfigVariable.substitute({ - type: "virtual", - source: "OPENCODE_CONFIG_CONTENT", - dir: location.directory, - text: options.content, - }).pipe( - Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")), - Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])), - Effect.orDie, - ) - : [] + ), + ).pipe( + Effect.orDie, + Effect.map((entries) => entries.flat()), + ) + + const file = options?.file + const explicit = file + ? yield* loadFile(path.resolve(file)).pipe( + Effect.map((config) => [ + ...(config ? [config] : []), + new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }), + ]), + Effect.orDie, + ) + : [] + const content = + options?.content !== undefined + ? yield* ConfigVariable.substitute({ + type: "virtual", + source: "OPENCODE_CONFIG_CONTENT", + dir: location.directory, + text: options.content, + }).pipe( + Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")), + Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])), + Effect.orDie, + ) + : [] - const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) - return [ - ...(yield* loadWellknown().pipe(Effect.orDie)), - ...claude, - ...agents, - ...(supplementary[0] ?? []), - ...explicit, - ...direct, - ...supplementary.slice(1).flat(), - ...content, - ] - }) + const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) + return [ + ...(yield* loadWellknown().pipe(Effect.orDie)), + ...claude, + ...agents, + ...(supplementary[0] ?? []), + ...explicit, + ...direct, + ...supplementary.slice(1).flat(), + ...content, + ] + }) - const initial = yield* discover() - let configs = initial - const updates = yield* PubSub.unbounded() - // Vendored trees inside config roots (a plugin's node_modules, a nested - // .git) produce event blizzards that can never change discovery output. - const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"] - // Watch-once: roots leave discovery only by deletion, so a stale watch is - // inert, bounded, and dies with this layer — and keeping a deleted root's - // watch alive is exactly what makes its recreation observable. - const watched = new Set() - const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) { - const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) - const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : [])) - const targets = [ - ...directories.map((path) => ({ path, type: "directory" as const, ignore })), - ...files - .filter((file) => !directories.some((directory) => FSUtil.contains(directory, file))) - .map((path) => ({ path, type: "file" as const })), - ] - for (const target of targets) { - const key = JSON.stringify(target) - if (watched.has(key)) continue - watched.add(key) - const stream = yield* watcher.subscribe(target) - yield* stream.pipe( - Stream.runForEach((update) => PubSub.publish(updates, update)), - Effect.forkScoped({ startImmediately: true }), - ) - } - }) + const initial = yield* discover() + let configs = initial + const updates = yield* PubSub.unbounded() + // Vendored trees inside config roots (a plugin's node_modules, a nested + // .git) produce event blizzards that can never change discovery output. + const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"] + // Watch-once: roots leave discovery only by deletion, so a stale watch is + // inert, bounded, and dies with this layer — and keeping a deleted root's + // watch alive is exactly what makes its recreation observable. + const watched = new Set() + const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) { + const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : [])) + const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : [])) + const targets = [ + ...directories.map((path) => ({ path, type: "directory" as const, ignore })), + ...files + .filter((file) => !directories.some((directory) => FSUtil.contains(directory, file))) + .map((path) => ({ path, type: "file" as const })), + ] + for (const target of targets) { + const key = JSON.stringify(target) + if (watched.has(key)) continue + watched.add(key) + const stream = yield* watcher.subscribe(target) + yield* stream.pipe( + Stream.runForEach((update) => PubSub.publish(updates, update)), + Effect.forkScoped({ startImmediately: true }), + ) + } + }) - const reload = Effect.fn("Config.reload")(() => - reloadLock.withPermit( - Effect.gen(function* () { - const next = yield* discover() - if (isDeepStrictEqual(configs, next)) return - configs = next - yield* reconcile(next) - yield* bus.publish(Event.Updated, {}) - }), - ), - ) + const reload = Effect.fn("Config.reload")(() => + reloadLock.withPermit( + Effect.gen(function* () { + const next = yield* discover() + if (isDeepStrictEqual(configs, next)) return + configs = next + yield* reconcile(next) + yield* bus.publish(Event.Updated, {}) + }), + ), + ) - yield* Stream.fromPubSub(updates).pipe( - Stream.debounce("100 millis"), - Stream.runForEach((update) => - reload().pipe( - Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })), + yield* Stream.fromPubSub(updates).pipe( + Stream.debounce("100 millis"), + Stream.runForEach((update) => + reload().pipe( + Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })), + ), ), - ), - Effect.forkScoped({ startImmediately: true }), - ) - yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe( - Stream.filterEffect((event) => - wellknown.entries().pipe( - Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)), - Effect.catch(() => Effect.succeed(false)), + Effect.forkScoped({ startImmediately: true }), + ) + yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filterEffect((event) => + wellknown.entries().pipe( + Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)), + Effect.catch(() => Effect.succeed(false)), + ), ), - ), - Stream.runForEach(() => - reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))), - ), - Effect.forkScoped({ startImmediately: true }), - ) - yield* bus.subscribe(WellKnown.Event.Updated).pipe( - Stream.runForEach(() => - reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))), - ), - Effect.forkScoped({ startImmediately: true }), - ) - yield* Effect.sleep("10 minutes").pipe( - Effect.andThen( - Effect.suspend(() => { - if (!wellknown.snapshot().length) return Effect.void - return Effect.gen(function* () { - const changed = yield* wellknown.refresh().pipe( - Effect.catch((error) => - Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)), - ), - ) - if (!changed) yield* reload() - }).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause }))) - }), - ), - Effect.forever, - Effect.forkScoped({ startImmediately: true }), - ) - yield* reconcile(initial) + Stream.runForEach(() => + reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown config", { cause }))), + ), + Effect.forkScoped({ startImmediately: true }), + ) + yield* bus.subscribe(WellKnown.Event.Updated).pipe( + Stream.runForEach(() => + reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))), + ), + Effect.forkScoped({ startImmediately: true }), + ) + yield* Effect.sleep("10 minutes").pipe( + Effect.andThen( + Effect.suspend(() => { + if (!wellknown.snapshot().length) return Effect.void + return Effect.gen(function* () { + const changed = yield* wellknown + .refresh() + .pipe( + Effect.catch((error) => + Effect.logWarning("failed to refresh wellknown manifests", { error }).pipe(Effect.as(false)), + ), + ) + if (!changed) yield* reload() + }).pipe(Effect.catchCause((cause) => Effect.logWarning("failed to refresh wellknown config", { cause }))) + }), + ), + Effect.forever, + Effect.forkScoped({ startImmediately: true }), + ) + yield* reconcile(initial) - return Service.of({ - entries: Effect.fn("Config.entries")(function* () { - return configs - }), - changes: () => Stream.fromPubSub(updates), - }) - }), -) + return Service.of({ + entries: Effect.fn("Config.entries")(function* () { + return configs + }), + changes: () => Stream.fromPubSub(updates), + }) + }), + ) export function configured(options?: Options) { return makeLocationNode({ diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 48b5455f14c9..d0a16e1fdf2e 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -63,9 +63,7 @@ export const Plugin = define({ Effect.catch(() => Effect.succeed(undefined)), ), ).pipe( - Effect.map((documents) => - documents.filter((document): document is Document => document !== undefined), - ), + Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)), ) }) }).pipe(Effect.map((documents) => documents.flat())) diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts index b464a5d01cf9..4aedc2a1fb7b 100644 --- a/packages/core/src/database/v1-migration.ts +++ b/packages/core/src/database/v1-migration.ts @@ -92,9 +92,7 @@ type Options = { readonly nextDatabasePath?: string } -type MigrationState = - | { readonly phase: "sessions"; readonly cursor?: string } - | { readonly phase: "completed" } +type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" } type RuntimeState = | { readonly status: "idle" } @@ -487,7 +485,10 @@ export function run(options: Options = {}): Effect.Effect Effect.gen(function* () { yield* tx.delete(EventTable).run() - yield* tx.insert(KVTable).values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } }).run() + yield* tx + .insert(KVTable) + .values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } }) + .run() }), ) .pipe(Effect.orDie) @@ -505,7 +506,9 @@ export function run(options: Options = {}): Effect.Effect(sql`SELECT id FROM project`)).map((project) => project.id)) + const projects = new Set( + (yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id), + ) while (true) { const state = yield* readState(db) const cursorValue = state?.phase === "sessions" ? state.cursor : undefined @@ -594,7 +597,7 @@ export function run(options: Options = {}): Effect.Effect( - "SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq", - ) + .query< + NextMessage, + [string] + >("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq") .all(session.id) yield* db .transaction((tx) => diff --git a/packages/core/src/event-logger.ts b/packages/core/src/event-logger.ts index eebdb10101ee..b48bb62befb2 100644 --- a/packages/core/src/event-logger.ts +++ b/packages/core/src/event-logger.ts @@ -4,12 +4,7 @@ import { Effect, Layer } from "effect" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { Bus } from "./bus" -const Types = new Set([ - "agent.updated", - "catalog.updated", - "command.updated", - "config.updated", -]) +const Types = new Set(["agent.updated", "catalog.updated", "command.updated", "config.updated"]) export const layer = Layer.effectDiscard( Effect.gen(function* () { diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 7ff3aa5e5c5a..54c1443e8668 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -92,9 +92,7 @@ export const layer = (options?: Options) => const watchers = yield* RcMap.make({ lookup: (key: Key) => Effect.gen(function* () { - const pubsub = yield* Effect.acquireRelease(PubSub.unbounded(), (pubsub) => - PubSub.shutdown(pubsub), - ) + const pubsub = yield* Effect.acquireRelease(PubSub.unbounded(), (pubsub) => PubSub.shutdown(pubsub)) const subscription = yield* Effect.acquireRelease( native.subscribe({ type: key.type, diff --git a/packages/core/src/form.ts b/packages/core/src/form.ts index 0a23ef3efe90..6d0716b12f77 100644 --- a/packages/core/src/form.ts +++ b/packages/core/src/form.ts @@ -183,7 +183,11 @@ export const layer = Layer.effect( const invalid = validateAnswer(entry.form, input.answer) if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid }) const next: TerminalState = { status: "answered", answer: input.answer } - yield* bus.publish(Form.Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer }) + yield* bus.publish(Form.Event.Replied, { + id: input.id, + sessionID: entry.form.sessionID, + answer: input.answer, + }) yield* Cache.set(forms, input.id, { ...entry, state: next }) yield* Deferred.succeed(entry.deferred, next) }), diff --git a/packages/core/src/formatter.ts b/packages/core/src/formatter.ts index ab2474e68d2a..d228f501a6a1 100644 --- a/packages/core/src/formatter.ts +++ b/packages/core/src/formatter.ts @@ -77,9 +77,7 @@ const layer = Layer.effect( const file = Effect.fn("Formatter.file")(function* (filepath: string) { yield* load - const matching = formatters.filter((formatter) => - formatter.extensions.includes(path.extname(filepath)), - ) + const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath))) for (const formatter of matching) { const enabled = yield* command(formatter) diff --git a/packages/core/src/formatter/builtins.ts b/packages/core/src/formatter/builtins.ts index d86d9a3a8088..f2e7a27e56e1 100644 --- a/packages/core/src/formatter/builtins.ts +++ b/packages/core/src/formatter/builtins.ts @@ -213,9 +213,7 @@ export function make(input: { const bin = which("uv") if (!bin) return disabled const output = yield* commandOutput([bin, "format", "--help"]) - return output._tag === "Some" && output.value.exitCode === 0 - ? [bin, "format", "--", "$FILE"] - : disabled + return output._tag === "Some" && output.value.exitCode === 0 ? [bin, "format", "--", "$FILE"] : disabled }), } diff --git a/packages/core/src/instruction-discovery.ts b/packages/core/src/instruction-discovery.ts index d5d0137a2cea..096d34fef4c3 100644 --- a/packages/core/src/instruction-discovery.ts +++ b/packages/core/src/instruction-discovery.ts @@ -28,74 +28,76 @@ export type Options = typeof Options.Type export class Service extends Context.Service()("@opencode/InstructionDiscovery") {} -export const layer = (options?: Options) => Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const location = yield* Location.Service +export const layer = (options?: Options) => + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const global = yield* Global.Service + const location = yield* Location.Service - const source = (value: ReadonlyArray | Instructions.Unavailable | Instructions.Removed) => - Instructions.make>({ - key, - codec: Schema.toCodecJson(Files), - read: Effect.succeed(value), - render: { - initial: render, - changed: (_previous, current) => - `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`, - removed: () => "Previously loaded instructions no longer apply.", - }, - }) + const source = (value: ReadonlyArray | Instructions.Unavailable | Instructions.Removed) => + Instructions.make>({ + key, + codec: Schema.toCodecJson(Files), + read: Effect.succeed(value), + render: { + initial: render, + changed: (_previous, current) => + `These instructions replace all previously loaded ambient instructions.\n\n${render(current)}`, + removed: () => "Previously loaded instructions no longer apply.", + }, + }) - const observe = Effect.fn("InstructionDiscovery.observe")(function* () { - const start = yield* fs.resolve(location.directory) - const stop = yield* fs.resolve(location.project.directory) - const fromProject = relative(stop, start) - const insideProject = - fromProject === "" || (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) - const discovered = new Set( - yield* Effect.forEach( - options?.project === false || !insideProject - ? [] - : yield* fs.up({ - targets: ["AGENTS.md"], - start, - stop, - }), - fs.resolve, - ), - ) - const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered]) - const files = yield* Effect.forEach( - paths, - (path) => - fs - .readFileStringSafe(path) - .pipe( - Effect.map((content) => - content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }), + const observe = Effect.fn("InstructionDiscovery.observe")(function* () { + const start = yield* fs.resolve(location.directory) + const stop = yield* fs.resolve(location.project.directory) + const fromProject = relative(stop, start) + const insideProject = + fromProject === "" || + (fromProject !== ".." && !fromProject.startsWith(`..${sep}`) && !isAbsolute(fromProject)) + const discovered = new Set( + yield* Effect.forEach( + options?.project === false || !insideProject + ? [] + : yield* fs.up({ + targets: ["AGENTS.md"], + start, + stop, + }), + fs.resolve, + ), + ) + const paths = Array.dedupe([yield* fs.resolve(join(global.config, "AGENTS.md")), ...discovered]) + const files = yield* Effect.forEach( + paths, + (path) => + fs + .readFileStringSafe(path) + .pipe( + Effect.map((content) => + content === undefined ? undefined : new File({ path: AbsolutePath.make(path), content }), + ), ), - ), - { concurrency: "unbounded" }, - ) - if (files.some((file, index) => file === undefined && discovered.has(paths[index]))) - return Instructions.unavailable - return files.filter((file): file is File => file !== undefined) - }) + { concurrency: "unbounded" }, + ) + if (files.some((file, index) => file === undefined && discovered.has(paths[index]))) + return Instructions.unavailable + return files.filter((file): file is File => file !== undefined) + }) - return Service.of({ - load: () => - observe().pipe( - Effect.map((files) => - Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files), + return Service.of({ + load: () => + observe().pipe( + Effect.map((files) => + Array.isArray(files) && files.length === 0 ? source(Instructions.removed) : source(files), + ), + Effect.catch(() => Effect.succeed(source(Instructions.unavailable))), + Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))), ), - Effect.catch(() => Effect.succeed(source(Instructions.unavailable))), - Effect.catchDefect(() => Effect.succeed(source(Instructions.unavailable))), - ), - }) - }), -) + }) + }), + ) export function configured(options?: Options) { return makeLocationNode({ diff --git a/packages/core/src/instructions/builtins.ts b/packages/core/src/instructions/builtins.ts index 3259d5a7d12b..fd42bcee160e 100644 --- a/packages/core/src/instructions/builtins.ts +++ b/packages/core/src/instructions/builtins.ts @@ -39,9 +39,7 @@ const layer = Layer.effect( ), render: { initial: (environment) => - ["Here is some useful information about the environment you are running in:", environment].join( - "\n", - ), + ["Here is some useful information about the environment you are running in:", environment].join("\n"), changed: (_previous, environment) => ["The environment you are running in is now:", environment].join("\n"), }, diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index b8f34b1996d4..9c79b4abfc05 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -209,16 +209,13 @@ export const connect = Effect.fnUntraced(function* ( authProvider, }) }) - const client = new Client( - clientInfo, - { - capabilities: { - ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}), - // https://github.com/anomalyco/opencode/issues/2308 - roots: {}, - }, + const client = new Client(clientInfo, { + capabilities: { + ...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}), + // https://github.com/anomalyco/opencode/issues/2308 + roots: {}, }, - ) + }) client.setRequestHandler(ListRootsRequestSchema, () => Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), ) diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 8d853f067376..213a8b4ec7a5 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -166,616 +166,615 @@ export const Options = Schema.Struct({ }) export type Options = typeof Options.Type -export const layer = (options?: Options) => Layer.effect( - Service, - Effect.gen(function* () { - const config = yield* Config.Service - const location = yield* Location.Service - const bus = yield* Bus.Service - const forms = yield* Form.Service - const integration = yield* Integration.Service - const credentials = yield* Credential.Service - const root = yield* Scope.make() - const fork = yield* FiberSet.makeRuntime() - yield* Effect.addFinalizer((exit) => Scope.close(root, exit)) - - const documents = (yield* config.entries()).filter((entry): entry is Document => entry.type === "document") - // Global MCP timeout defaults, later config files overriding earlier ones. - const timeout = Object.assign( - {}, - ...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])), - ) - // Later config files win for duplicate server names; per-server timeout overrides globals. - const runtime = new Map() - // Serializes lifecycle operations per server. Anything taking this lock from a connection - // callback must stay forked: lifecycle operations close scopes while holding it, firing onClose. - const locks = KeyedMutex.makeUnsafe() - const urlElicitations = new Map() - for (const entry of documents) { - for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) { - runtime.set(ServerName.make(name), { - config: { ...server, timeout: { ...timeout, ...server.timeout } }, - status: { status: "pending" }, - startup: Deferred.makeUnsafe(), - }) - } - } - - // Register every remote server as an OAuth integration so credentials live in the global store - // rather than in committed config. Servers that connect anonymously simply never use the method. - const owned = new Set() - const register = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) { - if (entry.config.type !== "remote" || entry.config.oauth === false) return - const remote = entry.config - // Key identity on name + url, not url alone: two configs for the same url under different names are - // distinct logical servers that may hold different accounts, so they must not share a credential row. - const suffix = - "mcp_" + - createHash("sha1") - .update(name + "\u0000" + remote.url) - .digest("hex") - .slice(0, 16) - const integrationID = Integration.ID.make(suffix) - entry.integrationID = integrationID - owned.add(integrationID) - const methodID = Integration.MethodID.make(suffix) - // Each registration gets its own child scope so disposal detaches it from the root scope - // entirely; registering directly on root would accumulate a dead finalizer per replaced or - // removed server for the lifetime of the layer. - const scope = yield* Scope.fork(root) - entry.registration = { dispose: Scope.close(scope, Exit.void) } - yield* integration - .transform((draft) => { - draft.update(integrationID, (ref) => { - ref.name = name - }) - draft.method.update({ - integrationID, - method: { id: methodID, type: "oauth", label: name }, - authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }), +export const layer = (options?: Options) => + Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const location = yield* Location.Service + const bus = yield* Bus.Service + const forms = yield* Form.Service + const integration = yield* Integration.Service + const credentials = yield* Credential.Service + const root = yield* Scope.make() + const fork = yield* FiberSet.makeRuntime() + yield* Effect.addFinalizer((exit) => Scope.close(root, exit)) + + const documents = (yield* config.entries()).filter((entry): entry is Document => entry.type === "document") + // Global MCP timeout defaults, later config files overriding earlier ones. + const timeout = Object.assign( + {}, + ...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])), + ) + // Later config files win for duplicate server names; per-server timeout overrides globals. + const runtime = new Map() + // Serializes lifecycle operations per server. Anything taking this lock from a connection + // callback must stay forked: lifecycle operations close scopes while holding it, firing onClose. + const locks = KeyedMutex.makeUnsafe() + const urlElicitations = new Map() + for (const entry of documents) { + for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) { + runtime.set(ServerName.make(name), { + config: { ...server, timeout: { ...timeout, ...server.timeout } }, + status: { status: "pending" }, + startup: Deferred.makeUnsafe(), }) - }) - .pipe(Scope.provide(scope)) - }) - yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true }) - - const requireServer = Effect.fnUntraced(function* (server: ServerName | string) { - const name = ServerName.make(server) - const entry = runtime.get(name) - if (!entry) return yield* new NotFoundError({ server: name }) - return { name, entry } - }) - - // Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and - // refreshes stored tokens, persisting refreshes back to the same credential row. The provider never - // opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect. - const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) { - if (entry.config.type !== "remote" || !entry.integrationID) return undefined - const remote = entry.config - const oauth = remote.oauth || undefined - const base = { - redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback", - scope: oauth?.scope, - client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined, - // No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser. - onRedirect: () => {}, + } } - const stored = yield* credentials.list(entry.integrationID) - const found = stored.find((credential) => credential.value.type === "oauth") - if (!found || found.value.type !== "oauth") - // No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which - // ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw - // a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are - // unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth(). - return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() }) - const credentialID = found.id - const methodID = found.value.methodID - let current: Credential.OAuth | undefined = found.value - return MCPOAuth.provider({ - ...base, - // Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw - // credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect. - invalidate: async (scope) => { - if (scope === "verifier" || scope === "discovery") return - current = undefined - await Effect.runPromise(credentials.remove(credentialID)) - }, - store: { - tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined), - saveTokens: async (tokens) => { - current = MCPOAuth.toCredential({ - methodID, - serverUrl: remote.url, - tokens, - client: current ? MCPOAuth.clientFromCredential(current) : undefined, + + // Register every remote server as an OAuth integration so credentials live in the global store + // rather than in committed config. Servers that connect anonymously simply never use the method. + const owned = new Set() + const register = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) { + if (entry.config.type !== "remote" || entry.config.oauth === false) return + const remote = entry.config + // Key identity on name + url, not url alone: two configs for the same url under different names are + // distinct logical servers that may hold different accounts, so they must not share a credential row. + const suffix = + "mcp_" + + createHash("sha1") + .update(name + "\u0000" + remote.url) + .digest("hex") + .slice(0, 16) + const integrationID = Integration.ID.make(suffix) + entry.integrationID = integrationID + owned.add(integrationID) + const methodID = Integration.MethodID.make(suffix) + // Each registration gets its own child scope so disposal detaches it from the root scope + // entirely; registering directly on root would accumulate a dead finalizer per replaced or + // removed server for the lifetime of the layer. + const scope = yield* Scope.fork(root) + entry.registration = { dispose: Scope.close(scope, Exit.void) } + yield* integration + .transform((draft) => { + draft.update(integrationID, (ref) => { + ref.name = name + }) + draft.method.update({ + integrationID, + method: { id: methodID, type: "oauth", label: name }, + authorize: () => MCPOAuth.authorize({ name, config: remote, methodID }), }) - await Effect.runPromise(credentials.update(credentialID, { value: current })) + }) + .pipe(Scope.provide(scope)) + }) + yield* Effect.forEach(runtime, ([name, entry]) => register(name, entry), { discard: true }) + + const requireServer = Effect.fnUntraced(function* (server: ServerName | string) { + const name = ServerName.make(server) + const entry = runtime.get(name) + if (!entry) return yield* new NotFoundError({ server: name }) + return { name, entry } + }) + + // Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and + // refreshes stored tokens, persisting refreshes back to the same credential row. The provider never + // opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect. + const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) { + if (entry.config.type !== "remote" || !entry.integrationID) return undefined + const remote = entry.config + const oauth = remote.oauth || undefined + const base = { + redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback", + scope: oauth?.scope, + client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined, + // No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser. + onRedirect: () => {}, + } + const stored = yield* credentials.list(entry.integrationID) + const found = stored.find((credential) => credential.value.type === "oauth") + if (!found || found.value.type !== "oauth") + // No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which + // ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw + // a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are + // unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth(). + return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() }) + const credentialID = found.id + const methodID = found.value.methodID + let current: Credential.OAuth | undefined = found.value + return MCPOAuth.provider({ + ...base, + // Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw + // credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect. + invalidate: async (scope) => { + if (scope === "verifier" || scope === "discovery") return + current = undefined + await Effect.runPromise(credentials.remove(credentialID)) }, - clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined), - saveClientInformation: async () => {}, - codeVerifier: async () => undefined, - saveCodeVerifier: async () => {}, - }, + store: { + tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined), + saveTokens: async (tokens) => { + current = MCPOAuth.toCredential({ + methodID, + serverUrl: remote.url, + tokens, + client: current ? MCPOAuth.clientFromCredential(current) : undefined, + }) + await Effect.runPromise(credentials.update(credentialID, { value: current })) + }, + clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined), + saveClientInformation: async () => {}, + codeVerifier: async () => undefined, + saveCodeVerifier: async () => {}, + }, + }) }) - }) - - const elicitation = { - create: (input: { - readonly server: string - readonly params: MCPClient.ElicitationParams - readonly signal: AbortSignal - }) => - Effect.gen(function* () { - if (input.params.mode === "url") { - const formID = Form.ID.create() - const key = input.server + "\u0000" + input.params.elicitationId - urlElicitations.set(key, formID) + + const elicitation = { + create: (input: { + readonly server: string + readonly params: MCPClient.ElicitationParams + readonly signal: AbortSignal + }) => + Effect.gen(function* () { + if (input.params.mode === "url") { + const formID = Form.ID.create() + const key = input.server + "\u0000" + input.params.elicitationId + urlElicitations.set(key, formID) + return yield* forms + .ask({ + id: formID, + sessionID: GLOBAL_ELICITATION_SESSION_ID, + title: `${input.server} is requesting input`, + metadata: { + kind: "mcp-elicitation", + server: input.server, + elicitationID: input.params.elicitationId, + message: input.params.message, + }, + fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }], + }) + .pipe( + Effect.raceFirst(waitForAbort(input.signal)), + Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))), + Effect.map( + (state): MCPClient.ElicitationResult => ({ + action: state.status === "answered" ? "accept" : "cancel", + }), + ), + ) + } + const params = input.params + const [field, ...fields] = Object.entries(params.requestedSchema.properties).map(([key, property]) => + toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true), + ) + if (!field) return { action: "accept", content: {} } return yield* forms .ask({ - id: formID, sessionID: GLOBAL_ELICITATION_SESSION_ID, title: `${input.server} is requesting input`, - metadata: { - kind: "mcp-elicitation", - server: input.server, - elicitationID: input.params.elicitationId, - message: input.params.message, - }, - fields: [{ key: URL_ELICITATION_FIELD_KEY, type: "external", url: input.params.url }], + metadata: { kind: "mcp-elicitation", server: input.server, message: params.message }, + fields: [field, ...fields], }) .pipe( Effect.raceFirst(waitForAbort(input.signal)), - Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))), - Effect.map( - (state): MCPClient.ElicitationResult => ({ - action: state.status === "answered" ? "accept" : "cancel", - }), - ), - ) - } - const params = input.params - const [field, ...fields] = Object.entries(params.requestedSchema.properties).map(([key, property]) => - toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true), - ) - if (!field) return { action: "accept", content: {} } - return yield* forms - .ask({ - sessionID: GLOBAL_ELICITATION_SESSION_ID, - title: `${input.server} is requesting input`, - metadata: { kind: "mcp-elicitation", server: input.server, message: params.message }, - fields: [field, ...fields], - }) - .pipe( - Effect.raceFirst(waitForAbort(input.signal)), - Effect.map((state): MCPClient.ElicitationResult => { - if (state.status !== "answered") return { action: "cancel" } - return { - action: "accept", - content: Object.fromEntries( - Object.entries(state.answer).map( - ([key, value]): [string, NonNullable[string]] => - typeof value === "object" ? [key, Array.from(value)] : [key, value], + Effect.map((state): MCPClient.ElicitationResult => { + if (state.status !== "answered") return { action: "cancel" } + return { + action: "accept", + content: Object.fromEntries( + Object.entries(state.answer).map( + ([key, value]): [string, NonNullable[string]] => + typeof value === "object" ? [key, Array.from(value)] : [key, value], + ), ), - ), - } + } + }), + ) + }), + complete: (input: { readonly server: string; readonly elicitationID: string }) => + Effect.gen(function* () { + const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID) + if (!formID) return + yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore) + }), + } satisfies MCPClient.ElicitationHandler + + const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) => + new Tool({ + server, + name: def.name, + codemode: entry.config.codemode, + description: def.description, + inputSchema: def.inputSchema, + outputSchema: def.outputSchema, + }) + + const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) => + new Prompt({ + server, + name: def.name, + description: def.description, + arguments: def.arguments?.map( + (argument) => + new PromptArgument({ + name: argument.name, + description: argument.description, + required: argument.required, }), - ) - }), - complete: (input: { readonly server: string; readonly elicitationID: string }) => - Effect.gen(function* () { - const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID) - if (!formID) return - yield* forms.reply({ id: formID, answer: { [URL_ELICITATION_FIELD_KEY]: true } }).pipe(Effect.ignore) - }), - } satisfies MCPClient.ElicitationHandler - - const toTool = (server: ServerName, entry: ServerEntry, def: MCPClient.ToolDefinition) => - new Tool({ - server, - name: def.name, - codemode: entry.config.codemode, - description: def.description, - inputSchema: def.inputSchema, - outputSchema: def.outputSchema, - }) + ), + }) - const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) => - new Prompt({ - server, - name: def.name, - description: def.description, - arguments: def.arguments?.map( - (argument) => - new PromptArgument({ - name: argument.name, - description: argument.description, - required: argument.required, - }), - ), - }) + const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) => + Resource.make({ + server, + name: def.name, + uri: def.uri, + description: def.description, + mimeType: def.mimeType, + }) - const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) => - Resource.make({ - server, - name: def.name, - uri: def.uri, - description: def.description, - mimeType: def.mimeType, - }) + const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) => + ResourceTemplate.make({ + server, + name: def.name, + uriTemplate: def.uriTemplate, + description: def.description, + mimeType: def.mimeType, + }) - const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) => - ResourceTemplate.make({ - server, - name: def.name, - uriTemplate: def.uriTemplate, - description: def.description, - mimeType: def.mimeType, - }) + const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => + connection.tools().pipe( + Effect.map((defs) => { + entry.tools = defs.map((def) => toTool(name, entry, def)) + }), + ) - const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => - connection.tools().pipe( - Effect.map((defs) => { - entry.tools = defs.map((def) => toTool(name, entry, def)) - }), - ) + const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => + connection.prompts().pipe( + Effect.map((defs) => { + entry.prompts = defs.map((def) => toPrompt(name, def)) + }), + Effect.andThen(bus.publish(Command.Event.Updated, {})), + Effect.catch(() => + Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(bus.publish(Command.Event.Updated, {}))), + ), + ) - const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => - connection.prompts().pipe( - Effect.map((defs) => { - entry.prompts = defs.map((def) => toPrompt(name, def)) - }), - Effect.andThen(bus.publish(Command.Event.Updated, {})), - Effect.catch(() => - Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(bus.publish(Command.Event.Updated, {}))), - ), - ) + // Runs a connection callback under the server lock, dropping it if the connection is no longer + // the entry's live client, so late SDK callbacks cannot commit obsolete state. + const whenLive = + (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => + (effect: Effect.Effect) => + fork( + Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe( + locks.withLock(name), + Effect.ignore, + ), + ) - // Runs a connection callback under the server lock, dropping it if the connection is no longer - // the entry's live client, so late SDK callbacks cannot commit obsolete state. - const whenLive = - (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => - (effect: Effect.Effect) => - fork( - Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe( - locks.withLock(name), - Effect.ignore, + const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => { + const live = whenLive(name, entry, connection) + connection.onClose(() => + live( + Effect.gen(function* () { + entry.client = undefined + entry.tools = undefined + entry.prompts = undefined + entry.status = { status: "failed", error: "Connection closed" } + yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) + yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore) + yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore) + yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + }), + ), + ) + connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore))) + connection.onToolsChanged(() => + live( + refreshTools(name, entry, connection).pipe( + Effect.andThen(bus.publish(McpEvent.ToolsChanged, { server: name })), + ), ), ) + connection.onPromptsChanged(() => live(refreshPrompts(name, entry, connection))) + connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name }))) + } - const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => { - const live = whenLive(name, entry, connection) - connection.onClose(() => - live( - Effect.gen(function* () { - entry.client = undefined - entry.tools = undefined - entry.prompts = undefined - entry.status = { status: "failed", error: "Connection closed" } + const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { + const fields = { server, logger: message.logger, level: message.level, data: message.data } + switch (message.level) { + case "debug": + return Effect.logDebug("MCP server log", fields) + case "info": + case "notice": + return Effect.logInfo("MCP server log", fields) + case "warning": + return Effect.logWarning("MCP server log", fields) + case "error": + case "critical": + case "alert": + case "emergency": + return Effect.logError("MCP server log", fields) + } + } + + const startServer = (name: ServerName, entry: ServerEntry) => + Effect.gen(function* () { + // Announce the handshake so connect() and credential reconnects don't show a stale + // disabled/failed status for the duration of the connection attempt. + entry.status = { status: "pending" } + yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + const scope = yield* Scope.fork(root) + entry.scope = scope + const authProvider = yield* connectProvider(entry) + // List tools as part of connect so a failure here marks the server failed rather than + // leaving it connected with a silently empty tool list and no path to recover. + const result = yield* MCPClient.connect( + name, + entry.config, + location.directory, + authProvider, + elicitation, + options?.clientInfo, + ).pipe( + Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), + Scope.provide(scope), + Effect.exit, + ) + if (Exit.isSuccess(result)) { + entry.client = result.value.connection + entry.tools = result.value.tools.map((def) => toTool(name, entry, def)) + entry.prompts = [] + entry.status = { status: "connected" } + watch(name, entry, result.value.connection) + yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length }) + // Announce the new tool set so the tool registry registers it. A server that finishes connecting + // after the initial registration sweep and emits no list-changed notification would otherwise + // stay invisible to the model. yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore) - yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore) yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) - }), - ), - ) - connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore))) - connection.onToolsChanged(() => - live( - refreshTools(name, entry, connection).pipe( - Effect.andThen(bus.publish(McpEvent.ToolsChanged, { server: name })), - ), - ), - ) - connection.onPromptsChanged(() => live(refreshPrompts(name, entry, connection))) - connection.onResourcesChanged(() => live(bus.publish(McpEvent.ResourcesChanged, { server: name }))) - } + whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection)) + return + } + yield* Scope.close(scope, Exit.void) + entry.scope = undefined + const error = Cause.squash(result.cause) + entry.status = + error instanceof MCPClient.NeedsAuthError + ? { status: "needs_auth" } + : { status: "failed", error: error instanceof Error ? error.message : String(error) } + yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status }) + yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + }).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined))) - const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { - const fields = { server, logger: message.logger, level: message.level, data: message.data } - switch (message.level) { - case "debug": - return Effect.logDebug("MCP server log", fields) - case "info": - case "notice": - return Effect.logInfo("MCP server log", fields) - case "warning": - return Effect.logWarning("MCP server log", fields) - case "error": - case "critical": - case "alert": - case "emergency": - return Effect.logError("MCP server log", fields) - } - } + const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) { + const scope = entry.scope + if (!scope) return + entry.scope = undefined + entry.client = undefined + entry.tools = undefined + entry.prompts = undefined + yield* Scope.close(scope, Exit.void) + yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) + yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore) + yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore) + }) - const startServer = (name: ServerName, entry: ServerEntry) => - Effect.gen(function* () { - // Announce the handshake so connect() and credential reconnects don't show a stale - // disabled/failed status for the duration of the connection attempt. - entry.status = { status: "pending" } - yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) - const scope = yield* Scope.fork(root) - entry.scope = scope - const authProvider = yield* connectProvider(entry) - // List tools as part of connect so a failure here marks the server failed rather than - // leaving it connected with a silently empty tool list and no path to recover. - const result = yield* MCPClient.connect( - name, - entry.config, - location.directory, - authProvider, - elicitation, - options?.clientInfo, - ).pipe( - Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), - Scope.provide(scope), - Effect.exit, - ) - if (Exit.isSuccess(result)) { - entry.client = result.value.connection - entry.tools = result.value.tools.map((def) => toTool(name, entry, def)) - entry.prompts = [] - entry.status = { status: "connected" } - watch(name, entry, result.value.connection) - yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length }) - // Announce the new tool set so the tool registry registers it. A server that finishes connecting - // after the initial registration sweep and emits no list-changed notification would otherwise - // stay invisible to the model. - yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) - yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore) - yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) - whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection)) - return + // Disabled servers settle their startup immediately so queries never block on them. + for (const [name, entry] of runtime) { + if (entry.config.disabled) { + entry.status = { status: "disabled" } + Deferred.doneUnsafe(entry.startup, Exit.void) + continue } - yield* Scope.close(scope, Exit.void) - entry.scope = undefined - const error = Cause.squash(result.cause) - entry.status = - error instanceof MCPClient.NeedsAuthError - ? { status: "needs_auth" } - : { status: "failed", error: error instanceof Error ? error.message : String(error) } - yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status }) - yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) - }).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined))) - - const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) { - const scope = entry.scope - if (!scope) return - entry.scope = undefined - entry.client = undefined - entry.tools = undefined - entry.prompts = undefined - yield* Scope.close(scope, Exit.void) - yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) - yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore) - yield* bus.publish(Command.Event.Updated, {}).pipe(Effect.ignore) - }) - - // Disabled servers settle their startup immediately so queries never block on them. - for (const [name, entry] of runtime) { - if (entry.config.disabled) { - entry.status = { status: "disabled" } - Deferred.doneUnsafe(entry.startup, Exit.void) - continue + fork(startServer(name, entry).pipe(locks.withLock(name))) } - fork(startServer(name, entry).pipe(locks.withLock(name))) - } - // Bring a server online (or back to needs_auth) when its integration's credential changes, so an - // OAuth login takes effect without a restart. Only fires for the integrations we registered. - const reconnect = (integrationID: Integration.ID) => - Effect.gen(function* () { - const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID) - if (!match) return - const name = match[0] - yield* Effect.gen(function* () { - // add() or remove() may have replaced or deleted the entry while we waited for the lock. - const entry = runtime.get(name) - if (!entry || entry.integrationID !== integrationID) return - if (entry.status.status === "disabled") return - yield* stopServer(name, entry) - yield* startServer(name, entry) - }).pipe(locks.withLock(name)) - }) - fork( - bus.subscribe(Integration.Event.ConnectionUpdated).pipe( - Stream.filter((event) => owned.has(event.data.integrationID)), - Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))), - Effect.ignore, - ), - ) - - // Suspend so each await sees current entries; a bare Map iterator is exhausted after one run. - const whenAllReady = Effect.suspend(() => - Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), { - concurrency: "unbounded", - discard: true, - }), - ) - return Service.of({ - servers: Effect.fn("MCP.servers")(function* () { - return Array.from(runtime) - .toSorted(([a], [b]) => a.localeCompare(b)) - .map( - ([name, entry]) => - new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID }), - ) - }), - add: Effect.fn("MCP.add")(function* (server, config) { - const name = ServerName.make(server) - yield* Effect.gen(function* () { - const previous = runtime.get(name) - if (previous) { - yield* stopServer(name, previous) - if (previous.integrationID) owned.delete(previous.integrationID) - if (previous.registration) yield* previous.registration.dispose - } - const entry: ServerEntry = { - config: { ...config, timeout: { ...timeout, ...config.timeout } }, - status: { status: "pending" }, - startup: Deferred.makeUnsafe(), - } - runtime.set(name, entry) + // Bring a server online (or back to needs_auth) when its integration's credential changes, so an + // OAuth login takes effect without a restart. Only fires for the integrations we registered. + const reconnect = (integrationID: Integration.ID) => + Effect.gen(function* () { + const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID) + if (!match) return + const name = match[0] yield* Effect.gen(function* () { - yield* register(name, entry) - if (config.disabled) { - entry.status = { status: "disabled" } - yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) - return - } + // add() or remove() may have replaced or deleted the entry while we waited for the lock. + const entry = runtime.get(name) + if (!entry || entry.integrationID !== integrationID) return + if (entry.status.status === "disabled") return + yield* stopServer(name, entry) yield* startServer(name, entry) - }).pipe( - // Settle startup even when register fails or add is interrupted, so an entry that made it - // into runtime can never hang readers awaiting its startup. - Effect.ensuring(Effect.sync(() => Deferred.doneUnsafe(entry.startup, Exit.void))), - ) - }).pipe(locks.withLock(name)) - }), - connect: Effect.fn("MCP.connect")(function* (server) { - const name = ServerName.make(server) - yield* Effect.gen(function* () { - const target = yield* requireServer(name) - yield* stopServer(name, target.entry) - yield* startServer(name, target.entry) - }).pipe(locks.withLock(name)) - }), - disconnect: Effect.fn("MCP.disconnect")(function* (server) { - const name = ServerName.make(server) - yield* Effect.gen(function* () { - const target = yield* requireServer(name) - yield* stopServer(name, target.entry) - target.entry.status = { status: "disabled" } - yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) - }).pipe(locks.withLock(name)) - }), - remove: Effect.fn("MCP.remove")(function* (server) { - const name = ServerName.make(server) - yield* Effect.gen(function* () { - const target = yield* requireServer(name) - yield* stopServer(name, target.entry) - if (target.entry.integrationID) owned.delete(target.entry.integrationID) - if (target.entry.registration) yield* target.entry.registration.dispose - // Credentials are kept: they are keyed by name + url, so re-adding the same server - // reuses them without forcing re-auth, matching add()'s replacement semantics. - runtime.delete(name) - yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) - }).pipe(locks.withLock(name)) - }), - tools: Effect.fn("MCP.tools")(function* () { - yield* whenAllReady - return Array.from(runtime.values()) - .flatMap((entry) => entry.tools ?? []) - .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name)) - }), - callTool: Effect.fn("MCP.callTool")(function* (input) { - const target = yield* requireServer(input.server) - yield* Deferred.await(target.entry.startup) - if (!target.entry.client) - return yield* new ToolCallError({ + }).pipe(locks.withLock(name)) + }) + fork( + bus.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => owned.has(event.data.integrationID)), + Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))), + Effect.ignore, + ), + ) + + // Suspend so each await sees current entries; a bare Map iterator is exhausted after one run. + const whenAllReady = Effect.suspend(() => + Effect.forEach(Array.from(runtime.values()), (entry) => Deferred.await(entry.startup), { + concurrency: "unbounded", + discard: true, + }), + ) + return Service.of({ + servers: Effect.fn("MCP.servers")(function* () { + return Array.from(runtime) + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([name, entry]) => new ServerInfo({ name, status: entry.status, integrationID: entry.integrationID })) + }), + add: Effect.fn("MCP.add")(function* (server, config) { + const name = ServerName.make(server) + yield* Effect.gen(function* () { + const previous = runtime.get(name) + if (previous) { + yield* stopServer(name, previous) + if (previous.integrationID) owned.delete(previous.integrationID) + if (previous.registration) yield* previous.registration.dispose + } + const entry: ServerEntry = { + config: { ...config, timeout: { ...timeout, ...config.timeout } }, + status: { status: "pending" }, + startup: Deferred.makeUnsafe(), + } + runtime.set(name, entry) + yield* Effect.gen(function* () { + yield* register(name, entry) + if (config.disabled) { + entry.status = { status: "disabled" } + yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + return + } + yield* startServer(name, entry) + }).pipe( + // Settle startup even when register fails or add is interrupted, so an entry that made it + // into runtime can never hang readers awaiting its startup. + Effect.ensuring(Effect.sync(() => Deferred.doneUnsafe(entry.startup, Exit.void))), + ) + }).pipe(locks.withLock(name)) + }), + connect: Effect.fn("MCP.connect")(function* (server) { + const name = ServerName.make(server) + yield* Effect.gen(function* () { + const target = yield* requireServer(name) + yield* stopServer(name, target.entry) + yield* startServer(name, target.entry) + }).pipe(locks.withLock(name)) + }), + disconnect: Effect.fn("MCP.disconnect")(function* (server) { + const name = ServerName.make(server) + yield* Effect.gen(function* () { + const target = yield* requireServer(name) + yield* stopServer(name, target.entry) + target.entry.status = { status: "disabled" } + yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + }).pipe(locks.withLock(name)) + }), + remove: Effect.fn("MCP.remove")(function* (server) { + const name = ServerName.make(server) + yield* Effect.gen(function* () { + const target = yield* requireServer(name) + yield* stopServer(name, target.entry) + if (target.entry.integrationID) owned.delete(target.entry.integrationID) + if (target.entry.registration) yield* target.entry.registration.dispose + // Credentials are kept: they are keyed by name + url, so re-adding the same server + // reuses them without forcing re-auth, matching add()'s replacement semantics. + runtime.delete(name) + yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + }).pipe(locks.withLock(name)) + }), + tools: Effect.fn("MCP.tools")(function* () { + yield* whenAllReady + return Array.from(runtime.values()) + .flatMap((entry) => entry.tools ?? []) + .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name)) + }), + callTool: Effect.fn("MCP.callTool")(function* (input) { + const target = yield* requireServer(input.server) + yield* Deferred.await(target.entry.startup) + if (!target.entry.client) + return yield* new ToolCallError({ + server: target.name, + tool: input.name, + message: "MCP server is not connected", + }) + const result = yield* target.entry.client + .callTool({ name: input.name, args: input.args }) + .pipe( + Effect.mapError( + (error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }), + ), + ) + return new ToolResult({ server: target.name, tool: input.name, - message: "MCP server is not connected", + isError: result.isError, + structured: result.structured, + content: result.content, }) - const result = yield* target.entry.client - .callTool({ name: input.name, args: input.args }) - .pipe( - Effect.mapError( - (error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }), + }), + instructions: Effect.fn("MCP.instructions")(function* () { + yield* whenAllReady + return Array.from(runtime) + .flatMap(([server, entry]) => { + const instructions = entry.client?.instructions + if (!instructions) return [] + return [new ServerInstructions({ server, instructions })] + }) + .toSorted((a, b) => a.server.localeCompare(b.server)) + }), + prompts: Effect.fn("MCP.prompts")(function* () { + return Array.from(runtime.values()) + .flatMap((entry) => entry.prompts ?? []) + .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name)) + }), + prompt: Effect.fn("MCP.prompt")(function* (input) { + const target = yield* requireServer(input.server) + yield* Deferred.await(target.entry.startup) + if (!target.entry.client) return undefined + const result = yield* target.entry.client + .prompt({ name: input.name, args: input.args }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!result) return undefined + return new PromptResult({ + server: target.name, + name: input.name, + messages: result.messages.map( + (message) => new PromptMessage({ role: message.role, content: message.content }), ), + }) + }), + resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { + yield* whenAllReady + const catalogs = yield* Effect.forEach( + Array.from(runtime), + ([name, entry]) => { + if (!entry.client) return Effect.succeed({ resources: [], templates: [] }) + return Effect.all( + { + resources: entry.client.resources().pipe(Effect.catch(() => Effect.succeed([]))), + templates: entry.client.resourceTemplates().pipe(Effect.catch(() => Effect.succeed([]))), + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map((catalog) => ({ + resources: catalog.resources.map((def) => toResource(name, def)), + templates: catalog.templates.map((def) => toResourceTemplate(name, def)), + })), + ) + }, + { concurrency: "unbounded" }, ) - return new ToolResult({ - server: target.name, - tool: input.name, - isError: result.isError, - structured: result.structured, - content: result.content, - }) - }), - instructions: Effect.fn("MCP.instructions")(function* () { - yield* whenAllReady - return Array.from(runtime) - .flatMap(([server, entry]) => { - const instructions = entry.client?.instructions - if (!instructions) return [] - return [new ServerInstructions({ server, instructions })] + return ResourceCatalog.make({ + resources: catalogs + .flatMap((catalog) => catalog.resources) + .toSorted( + (a, b) => + a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri), + ), + templates: catalogs + .flatMap((catalog) => catalog.templates) + .toSorted( + (a, b) => + a.server.localeCompare(b.server) || + a.name.localeCompare(b.name) || + a.uriTemplate.localeCompare(b.uriTemplate), + ), }) - .toSorted((a, b) => a.server.localeCompare(b.server)) - }), - prompts: Effect.fn("MCP.prompts")(function* () { - return Array.from(runtime.values()) - .flatMap((entry) => entry.prompts ?? []) - .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name)) - }), - prompt: Effect.fn("MCP.prompt")(function* (input) { - const target = yield* requireServer(input.server) - yield* Deferred.await(target.entry.startup) - if (!target.entry.client) return undefined - const result = yield* target.entry.client - .prompt({ name: input.name, args: input.args }) - .pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!result) return undefined - return new PromptResult({ - server: target.name, - name: input.name, - messages: result.messages.map( - (message) => new PromptMessage({ role: message.role, content: message.content }), - ), - }) - }), - resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { - yield* whenAllReady - const catalogs = yield* Effect.forEach( - Array.from(runtime), - ([name, entry]) => { - if (!entry.client) return Effect.succeed({ resources: [], templates: [] }) - return Effect.all( - { - resources: entry.client.resources().pipe(Effect.catch(() => Effect.succeed([]))), - templates: entry.client.resourceTemplates().pipe(Effect.catch(() => Effect.succeed([]))), - }, - { concurrency: "unbounded" }, - ).pipe( - Effect.map((catalog) => ({ - resources: catalog.resources.map((def) => toResource(name, def)), - templates: catalog.templates.map((def) => toResourceTemplate(name, def)), - })), - ) - }, - { concurrency: "unbounded" }, - ) - return ResourceCatalog.make({ - resources: catalogs - .flatMap((catalog) => catalog.resources) - .toSorted( - (a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri), - ), - templates: catalogs - .flatMap((catalog) => catalog.templates) - .toSorted( - (a, b) => - a.server.localeCompare(b.server) || - a.name.localeCompare(b.name) || - a.uriTemplate.localeCompare(b.uriTemplate), - ), - }) - }), - readResource: Effect.fn("MCP.readResource")(function* (input) { - const target = yield* requireServer(input.server) - yield* Deferred.await(target.entry.startup) - if (!target.entry.client) return undefined - const result = yield* target.entry.client - .readResource({ uri: input.uri }) - .pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!result) return undefined - return ResourceContent.make({ - server: target.name, - uri: input.uri, - contents: result.contents, - }) - }), - }) - }), -) + }), + readResource: Effect.fn("MCP.readResource")(function* (input) { + const target = yield* requireServer(input.server) + yield* Deferred.await(target.entry.startup) + if (!target.entry.client) return undefined + const result = yield* target.entry.client + .readResource({ uri: input.uri }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!result) return undefined + return ResourceContent.make({ + server: target.name, + uri: input.uri, + contents: result.contents, + }) + }), + }) + }), + ) export function configured(options?: Options) { return makeLocationNode({ diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts index 1015e1bd403c..a7164026dbdf 100644 --- a/packages/core/src/mcp/oauth.ts +++ b/packages/core/src/mcp/oauth.ts @@ -160,7 +160,9 @@ export const authorize = (input: { } const fail = (reason: string) => { Effect.runFork(Deferred.fail(code, new Error(reason))) - response.writeHead(400, { "Content-Type": "text/html" }).end(OauthCallbackPage.error(reason, { provider: input.name })) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(reason, { provider: input.name })) } const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") if (error) return fail(error) @@ -216,7 +218,12 @@ export const authorize = (input: { // The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step. if (result === "AUTHORIZED") { - return { url: input.config.url, instructions: `Connected to ${input.name}.`, mode: "auto" as const, callback: finalize } + return { + url: input.config.url, + instructions: `Connected to ${input.name}.`, + mode: "auto" as const, + callback: finalize, + } } if (!authorizationUrl) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`)) @@ -228,7 +235,8 @@ export const authorize = (input: { callback: Deferred.await(code).pipe( Effect.flatMap((value) => Effect.tryPromise({ - try: () => auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }), + try: () => + auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }), ), diff --git a/packages/core/src/mime.ts b/packages/core/src/mime.ts index 6ad8de5a605c..20b469271af5 100644 --- a/packages/core/src/mime.ts +++ b/packages/core/src/mime.ts @@ -10,8 +10,7 @@ export function detect(bytes: Uint8Array) { return "image/webp" if ( startsWith(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) && - (startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) || - startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73])) + (startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) || startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73])) ) return "image/avif" return isText(bytes) ? "text/plain" : "application/octet-stream" diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 252d04f53045..c76eaa641a7f 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -539,122 +539,122 @@ export class Service extends Context.Service()("@opencode/Mo export const layer = (options?: Options) => Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const bus = yield* Bus.Service + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const bus = yield* Bus.Service const app = yield* App.Metadata - const http = HttpClient.filterStatusOk( - (yield* HttpClient.HttpClient).pipe( - HttpClient.retryTransient({ - retryOn: "errors-and-responses", - times: 2, - schedule: Schedule.exponential(200).pipe(Schedule.jittered), - }), - ), - ) + const http = HttpClient.filterStatusOk( + (yield* HttpClient.HttpClient).pipe( + HttpClient.retryTransient({ + retryOn: "errors-and-responses", + times: 2, + schedule: Schedule.exponential(200).pipe(Schedule.jittered), + }), + ), + ) - const source = options?.url || "https://models.dev" - const fetch = options?.fetch ?? true + const source = options?.url || "https://models.dev" + const fetch = options?.fetch ?? true const userAgent = App.useragent(app) - const filepath = path.join( - Global.Path.cache, - source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, - ) - const ttl = Duration.minutes(5) - const lockKey = `models-dev:${filepath}` - - const fresh = Effect.fnUntraced(function* () { - const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!stat) return false - const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime() - return Date.now() - mtime < Duration.toMillis(ttl) - }) - - const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { - return yield* HttpClientRequest.get(`${source}/api.json`).pipe( - HttpClientRequest.setHeader("User-Agent", userAgent), - http.execute, - Effect.flatMap((res) => res.text), - Effect.timeout("10 seconds"), + const filepath = path.join( + Global.Path.cache, + source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, ) - }) + const ttl = Duration.minutes(5) + const lockKey = `models-dev:${filepath}` + + const fresh = Effect.fnUntraced(function* () { + const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stat) return false + const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime() + return Date.now() - mtime < Duration.toMillis(ttl) + }) + + const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { + return yield* HttpClientRequest.get(`${source}/api.json`).pipe( + HttpClientRequest.setHeader("User-Agent", userAgent), + http.execute, + Effect.flatMap((res) => res.text), + Effect.timeout("10 seconds"), + ) + }) - const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe( - Effect.map((input) => input as Record), - Effect.catch((error) => { + const loadFromDisk = fs.readJson(options?.file ?? filepath).pipe( + Effect.map((input) => input as Record), + Effect.catch((error) => { if (options?.file === undefined && error._tag === "FileSystemError" && error.method === "readJson") { - return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined)) - } - return Effect.succeed(undefined) - }), - ) - - const loadSnapshot = Effect.sync(() => - typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, - ) - - const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { - const text = yield* fetchApi() - const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp` - yield* fs.writeWithDirs(tempfile, text).pipe( - Effect.andThen(fs.rename(tempfile, filepath)), - Effect.catch((error) => - Effect.gen(function* () { - yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore) - return yield* Effect.fail(error) - }), - ), - ) - return text - }) - - const populate = Effect.gen(function* () { - const fromDisk = yield* loadFromDisk - if (fromDisk) return normalize(fromDisk) - const bundled = yield* loadSnapshot - if (bundled) return normalize(bundled) - if (!fetch) return [] - // Flock is cross-process: concurrent opencode CLIs can race on this cache file. - const text = yield* Effect.scoped( - Effect.gen(function* () { - yield* Flock.effect(lockKey) - return yield* fetchAndWrite() + return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined)) + } + return Effect.succeed(undefined) }), ) - return normalize(JSON.parse(text) as Record) - }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) - - const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) - - const get = (): Effect.Effect => cachedGet - - const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { - if (!force && (yield* fresh())) return - yield* Effect.scoped( - Effect.gen(function* () { - yield* Flock.effect(lockKey) - // Re-check under the lock: another process may have refreshed between - // our outer check and lock acquisition. - if (!force && (yield* fresh())) return - yield* fetchAndWrite() - yield* invalidate - yield* bus.publish(ModelsDev.Event.Refreshed, {}) - }), - ).pipe( - Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), - Effect.ignore, + + const loadSnapshot = Effect.sync(() => + typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV, ) - }) - if (fetch && !process.argv.includes("--get-yargs-completions")) { - // Schedule.spaced runs the effect once, then waits between completions. - yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced(ttl)), Effect.ignore)) - } + const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { + const text = yield* fetchApi() + const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp` + yield* fs.writeWithDirs(tempfile, text).pipe( + Effect.andThen(fs.rename(tempfile, filepath)), + Effect.catch((error) => + Effect.gen(function* () { + yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore) + return yield* Effect.fail(error) + }), + ), + ) + return text + }) + + const populate = Effect.gen(function* () { + const fromDisk = yield* loadFromDisk + if (fromDisk) return normalize(fromDisk) + const bundled = yield* loadSnapshot + if (bundled) return normalize(bundled) + if (!fetch) return [] + // Flock is cross-process: concurrent opencode CLIs can race on this cache file. + const text = yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + return yield* fetchAndWrite() + }), + ) + return normalize(JSON.parse(text) as Record) + }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) + + const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) + + const get = (): Effect.Effect => cachedGet - return Service.of({ get, refresh }) - }), -) + const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { + if (!force && (yield* fresh())) return + yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + // Re-check under the lock: another process may have refreshed between + // our outer check and lock acquisition. + if (!force && (yield* fresh())) return + yield* fetchAndWrite() + yield* invalidate + yield* bus.publish(ModelsDev.Event.Refreshed, {}) + }), + ).pipe( + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), + Effect.ignore, + ) + }) + + if (fetch && !process.argv.includes("--get-yargs-completions")) { + // Schedule.spaced runs the effect once, then waits between completions. + yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced(ttl)), Effect.ignore)) + } + + return Service.of({ get, refresh }) + }), + ) export function configured(options?: Options) { return makeGlobalNode({ diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index b957a2e763c4..457c7ef7d61d 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -139,14 +139,15 @@ const layer = Layer.effect( const savedRules = Effect.fnUntraced(function* () { return (yield* saved.list({ projectID: location.project.id })).map( - (item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }), + (item): Permission.Rule => ({ + action: item.action, + resource: item.resource, + effect: "allow", + }), ) }) - const configured = Effect.fn("Permission.configured")(function* ( - sessionID: SessionSchema.ID, - agentID?: Agent.ID, - ) { + const configured = Effect.fn("Permission.configured")(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) { const session = yield* sessions.get(sessionID) if (!session) return yield* new SessionErrors.NotFoundError({ sessionID }) const agent = yield* agents.resolve(agentID ?? session.agent) diff --git a/packages/core/src/permission/saved.ts b/packages/core/src/permission/saved.ts index 0c0fe2d067b4..06c4aa304f0c 100644 --- a/packages/core/src/permission/saved.ts +++ b/packages/core/src/permission/saved.ts @@ -47,7 +47,12 @@ const layer = Layer.effect( .all() .pipe(Effect.orDie) return rows.map( - (row): Info => ({ id: row.id, projectID: row.project_id, action: row.action, resource: row.resource }), + (row): Info => ({ + id: row.id, + projectID: row.project_id, + action: row.action, + resource: row.resource, + }), ) }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 9f525cab991f..b273f217d793 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -47,17 +47,13 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p workspaceID: location.workspaceID, project: location.project, }) - const locationRef = (input?: { - readonly location?: { readonly directory?: string; readonly workspace?: string } - }) => + const locationRef = (input?: { readonly location?: { readonly directory?: string; readonly workspace?: string } }) => input?.location === undefined ? undefined : Location.Ref.make({ directory: AbsolutePath.make(input.location.directory ?? location.directory), workspaceID: - input.location.workspace === undefined - ? location.workspaceID - : Workspace.ID.make(input.location.workspace), + input.location.workspace === undefined ? location.workspaceID : Workspace.ID.make(input.location.workspace), }) const isCurrentLocation = (ref: Location.Ref) => ref.directory === location.directory && ref.workspaceID === location.workspaceID @@ -72,9 +68,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p const ref = locationRef(input) const output = ref && !isCurrentLocation(ref) - ? runtime.location.agent - .list(ref) - .pipe(Effect.map((result) => ({ ...result, data: result.data.find((agent) => agent.id === input.agentID) }))) + ? runtime.location.agent.list(ref).pipe( + Effect.map((result) => ({ + ...result, + data: result.data.find((agent) => agent.id === input.agentID), + })), + ) : response(agents.get(input.agentID)) return output.pipe( Effect.flatMap((result) => @@ -162,8 +161,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p mutable(draft.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))), update: (providerID, modelID, update) => draft.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update), - remove: (providerID, modelID) => - draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)), + remove: (providerID, modelID) => draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)), default: { get: draft.model.default.get, set: (providerID, modelID) => diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index e7d27f4cca83..3184f0f98c4d 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -40,106 +40,107 @@ const decodeUser = Schema.decodeUnknownOption(User) const JsonBody = Schema.UnknownFromJsonString const decodeBody = Schema.decodeUnknownOption(JsonBody) -const oauth = (app: App.Info) => ({ - integrationID: Integration.ID.make("github-copilot"), - method: { - id: methodID, - type: "oauth", - label: "Login with GitHub Copilot", - prompts: [ - { - type: "select", - key: "deploymentType", - message: "Select GitHub deployment type", - options: [ - { label: "GitHub.com", value: "github.com", hint: "Public" }, - { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" }, - ], - }, - { - type: "text", - key: "enterpriseUrl", - message: "Enter your GitHub Enterprise URL or domain", - placeholder: "company.ghe.com or https://company.ghe.com", - when: { key: "deploymentType", op: "eq", value: "enterprise" }, - }, - ], - }, - authorize: (inputs) => - Effect.gen(function* () { - const enterprise = inputs.deploymentType === "enterprise" - if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required")) - const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com" - const urls = oauthURLs(domain) - const device = yield* request(urls.device, { - method: "POST", - headers: headers(app), - body: JSON.stringify({ client_id: clientID, scope: "read:user" }), - }).pipe(Effect.map(Schema.decodeUnknownSync(Device))) - const interval = Math.max(device.interval, 1) * 1000 - - const poll = (wait: number): Effect.Effect => - request(urls.token, { +const oauth = (app: App.Info) => + ({ + integrationID: Integration.ID.make("github-copilot"), + method: { + id: methodID, + type: "oauth", + label: "Login with GitHub Copilot", + prompts: [ + { + type: "select", + key: "deploymentType", + message: "Select GitHub deployment type", + options: [ + { label: "GitHub.com", value: "github.com", hint: "Public" }, + { label: "GitHub Enterprise", value: "enterprise", hint: "Data residency or self-hosted" }, + ], + }, + { + type: "text", + key: "enterpriseUrl", + message: "Enter your GitHub Enterprise URL or domain", + placeholder: "company.ghe.com or https://company.ghe.com", + when: { key: "deploymentType", op: "eq", value: "enterprise" }, + }, + ], + }, + authorize: (inputs) => + Effect.gen(function* () { + const enterprise = inputs.deploymentType === "enterprise" + if (enterprise && !inputs.enterpriseUrl) return yield* Effect.fail(new Error("Enterprise URL is required")) + const domain = enterprise ? normalizeDomain(inputs.enterpriseUrl ?? "") : "github.com" + const urls = oauthURLs(domain) + const device = yield* request(urls.device, { method: "POST", headers: headers(app), - body: JSON.stringify({ - client_id: clientID, - device_code: device.device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }).pipe( - Effect.map(Schema.decodeUnknownSync(Token)), - Effect.flatMap((token) => { - if (token.access_token) { - const access = token.access_token - return request( - `${domain === "github.com" ? "https://api.github.com" : `https://api.${domain}`}/copilot_internal/user`, - { - headers: { - Accept: "application/json", - Authorization: `Bearer ${access}`, - "User-Agent": App.useragent(app), - "X-GitHub-Api-Version": userApiVersion, + body: JSON.stringify({ client_id: clientID, scope: "read:user" }), + }).pipe(Effect.map(Schema.decodeUnknownSync(Device))) + const interval = Math.max(device.interval, 1) * 1000 + + const poll = (wait: number): Effect.Effect => + request(urls.token, { + method: "POST", + headers: headers(app), + body: JSON.stringify({ + client_id: clientID, + device_code: device.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }).pipe( + Effect.map(Schema.decodeUnknownSync(Token)), + Effect.flatMap((token) => { + if (token.access_token) { + const access = token.access_token + return request( + `${domain === "github.com" ? "https://api.github.com" : `https://api.${domain}`}/copilot_internal/user`, + { + headers: { + Accept: "application/json", + Authorization: `Bearer ${access}`, + "User-Agent": App.useragent(app), + "X-GitHub-Api-Version": userApiVersion, + }, }, - }, - ).pipe( - Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")), - Effect.catch(() => Effect.succeed(undefined)), - Effect.map((apiEndpoint) => - Credential.OAuth.make({ - type: "oauth", - methodID, - refresh: access, - access, - expires: 0, - ...((enterprise || apiEndpoint) && { - metadata: { - ...(enterprise ? { enterpriseUrl: domain } : {}), - ...(apiEndpoint ? { apiEndpoint } : {}), - }, + ).pipe( + Effect.map((user) => Option.getOrUndefined(decodeUser(user))?.endpoints?.api?.replace(/\/+$/, "")), + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((apiEndpoint) => + Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: access, + access, + expires: 0, + ...((enterprise || apiEndpoint) && { + metadata: { + ...(enterprise ? { enterpriseUrl: domain } : {}), + ...(apiEndpoint ? { apiEndpoint } : {}), + }, + }), }), - }), - ), - ) - } - if (token.error === "authorization_pending") - return Effect.sleep(wait + pollingSafetyMargin).pipe(Effect.andThen(poll(wait))) - if (token.error === "slow_down") { - const next = token.interval && token.interval > 0 ? token.interval * 1000 : wait + 5000 - return Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(poll(next))) - } - return Effect.fail(new Error(`Device authorization failed${token.error ? `: ${token.error}` : ""}`)) - }), - ) + ), + ) + } + if (token.error === "authorization_pending") + return Effect.sleep(wait + pollingSafetyMargin).pipe(Effect.andThen(poll(wait))) + if (token.error === "slow_down") { + const next = token.interval && token.interval > 0 ? token.interval * 1000 : wait + 5000 + return Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(poll(next))) + } + return Effect.fail(new Error(`Device authorization failed${token.error ? `: ${token.error}` : ""}`)) + }), + ) - return { - mode: "auto" as const, - url: device.verification_uri, - instructions: `Enter code: ${device.user_code}`, - callback: poll(interval), - } - }), -}) satisfies IntegrationOAuthMethodRegistration + return { + mode: "auto" as const, + url: device.verification_uri, + instructions: `Enter code: ${device.user_code}`, + callback: poll(interval), + } + }), + }) satisfies IntegrationOAuthMethodRegistration export const GithubCopilotPlugin = define({ id: "opencode.provider.github-copilot", diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 7f168f58c721..6fdd31270875 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -44,122 +44,126 @@ const Claims = Schema.fromJsonString( ) const decodeClaims = Schema.decodeUnknownOption(Claims) -const browser = (app: App.Info) => ({ - integrationID: Integration.ID.make("openai"), - method: { - id: browserMethodID, - type: "oauth", - label: "ChatGPT Pro/Plus (browser)", - }, - authorize: () => - Effect.gen(function* () { - const pkce = yield* Effect.promise(generatePKCE) - const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) - const code = yield* Deferred.make() - const redirect = `http://localhost:${callbackPort}/auth/callback` - const server = createServer((request, response) => { - const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) - if (url.pathname !== "/auth/callback") { - response.writeHead(404).end("Not found") - return - } - const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") - const value = url.searchParams.get("code") - if (error) { - Effect.runFork(Deferred.fail(code, new Error(error))) - response - .writeHead(400, { "Content-Type": "text/html" }) - .end(OauthCallbackPage.error(error, { provider: "ChatGPT" })) - return - } - if (!value || url.searchParams.get("state") !== state) { - const message = value ? "Invalid OAuth state" : "Missing authorization code" - Effect.runFork(Deferred.fail(code, new Error(message))) +const browser = (app: App.Info) => + ({ + integrationID: Integration.ID.make("openai"), + method: { + id: browserMethodID, + type: "oauth", + label: "ChatGPT Pro/Plus (browser)", + }, + authorize: () => + Effect.gen(function* () { + const pkce = yield* Effect.promise(generatePKCE) + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) + const code = yield* Deferred.make() + const redirect = `http://localhost:${callbackPort}/auth/callback` + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) + if (url.pathname !== "/auth/callback") { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "ChatGPT" })) + return + } + if (!value || url.searchParams.get("state") !== state) { + const message = value ? "Invalid OAuth state" : "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "ChatGPT" })) + return + } + Effect.runFork(Deferred.succeed(code, value)) response - .writeHead(400, { "Content-Type": "text/html" }) - .end(OauthCallbackPage.error(message, { provider: "ChatGPT" })) - return + .writeHead(200, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.success({ provider: "ChatGPT" })) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, "localhost", () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + return { + mode: "auto" as const, + url: authorizeURL(redirect, pkce, state), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => exchange(value, redirect, pkce, app)), + Effect.map((tokens) => credential(browserMethodID, tokens)), + ), } - Effect.runFork(Deferred.succeed(code, value)) - response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: "ChatGPT" })) - }) - yield* Effect.callback((resume) => { - server.once("error", (error) => resume(Effect.fail(error))) - server.listen(callbackPort, "localhost", () => resume(Effect.void)) - }) - yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) - return { - mode: "auto" as const, - url: authorizeURL(redirect, pkce, state), - instructions: "Complete authorization in your browser. This window will close automatically.", - callback: Deferred.await(code).pipe( - Effect.flatMap((value) => exchange(value, redirect, pkce, app)), - Effect.map((tokens) => credential(browserMethodID, tokens)), - ), - } - }), - refresh: (value) => refresh(browserMethodID, value, app), -}) satisfies IntegrationOAuthMethodRegistration + }), + refresh: (value) => refresh(browserMethodID, value, app), + }) satisfies IntegrationOAuthMethodRegistration -const headless = (app: App.Info) => ({ - integrationID: Integration.ID.make("openai"), - method: { - id: headlessMethodID, - type: "oauth", - label: "ChatGPT Pro/Plus (headless)", - }, - authorize: () => - Effect.gen(function* () { - const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( - `${issuer}/api/accounts/deviceauth/usercode`, - { - method: "POST", - headers: headers("application/json", app), - body: JSON.stringify({ client_id: clientID }), - }, - ) - const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 - return { - mode: "auto" as const, - url: `${issuer}/codex/device`, - instructions: `Enter code: ${device.user_code}`, - callback: Effect.gen(function* () { - while (true) { - const response = yield* Effect.tryPromise({ - try: (signal) => - fetch(`${issuer}/api/accounts/deviceauth/token`, { - method: "POST", - headers: headers("application/json", app), - body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), - signal, - }), - catch: (cause) => cause, - }) - if (response.ok) { - const data = (yield* Effect.promise(() => response.json())) as { - authorization_code: string - code_verifier: string +const headless = (app: App.Info) => + ({ + integrationID: Integration.ID.make("openai"), + method: { + id: headlessMethodID, + type: "oauth", + label: "ChatGPT Pro/Plus (headless)", + }, + authorize: () => + Effect.gen(function* () { + const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( + `${issuer}/api/accounts/deviceauth/usercode`, + { + method: "POST", + headers: headers("application/json", app), + body: JSON.stringify({ client_id: clientID }), + }, + ) + const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 + return { + mode: "auto" as const, + url: `${issuer}/codex/device`, + instructions: `Enter code: ${device.user_code}`, + callback: Effect.gen(function* () { + while (true) { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetch(`${issuer}/api/accounts/deviceauth/token`, { + method: "POST", + headers: headers("application/json", app), + body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), + signal, + }), + catch: (cause) => cause, + }) + if (response.ok) { + const data = (yield* Effect.promise(() => response.json())) as { + authorization_code: string + code_verifier: string + } + return credential( + headlessMethodID, + yield* exchange( + data.authorization_code, + `${issuer}/deviceauth/callback`, + { verifier: data.code_verifier, challenge: "" }, + app, + ), + ) } - return credential( - headlessMethodID, - yield* exchange( - data.authorization_code, - `${issuer}/deviceauth/callback`, - { verifier: data.code_verifier, challenge: "" }, - app, - ), - ) - } - if (response.status !== 403 && response.status !== 404) { - return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) + if (response.status !== 403 && response.status !== 404) { + return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) + } + yield* Effect.sleep(interval + pollingSafetyMargin) } - yield* Effect.sleep(interval + pollingSafetyMargin) - } - }), - } - }), - refresh: (value) => refresh(headlessMethodID, value, app), -}) satisfies IntegrationOAuthMethodRegistration + }), + } + }), + refresh: (value) => refresh(headlessMethodID, value, app), + }) satisfies IntegrationOAuthMethodRegistration export const OpenAIPlugin = define({ id: "opencode.provider.openai", diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index af5b2202f640..a9b5393da2a7 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -35,40 +35,41 @@ const DeviceError = Schema.Struct({ }) const decodeDeviceError = Schema.decodeUnknownOption(Schema.fromJsonString(DeviceError)) -const device = (app: App.Info) => ({ - integrationID: Integration.ID.make("xai"), - method: { - id: deviceMethodID, - type: "oauth", - label: "SuperGrok Subscription", - }, - authorize: () => - request( - `${issuer}/device/code`, - { - method: "POST", - headers: headers(app), - body: new URLSearchParams({ client_id: clientID, scope, referrer: "opencode" }).toString(), - }, - Device, - ).pipe( - Effect.flatMap((value) => - Clock.currentTimeMillis.pipe( - Effect.map((created) => { - const lifetime = positiveSeconds(value.expires_in, 0) - return { - mode: "auto" as const, - url: value.verification_uri_complete ?? value.verification_uri, - instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`, - ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}), - callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))), - } - }), +const device = (app: App.Info) => + ({ + integrationID: Integration.ID.make("xai"), + method: { + id: deviceMethodID, + type: "oauth", + label: "SuperGrok Subscription", + }, + authorize: () => + request( + `${issuer}/device/code`, + { + method: "POST", + headers: headers(app), + body: new URLSearchParams({ client_id: clientID, scope, referrer: "opencode" }).toString(), + }, + Device, + ).pipe( + Effect.flatMap((value) => + Clock.currentTimeMillis.pipe( + Effect.map((created) => { + const lifetime = positiveSeconds(value.expires_in, 0) + return { + mode: "auto" as const, + url: value.verification_uri_complete ?? value.verification_uri, + instructions: `Open ${value.verification_uri} on any device and enter code: ${value.user_code}`, + ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}), + callback: poll(value, app).pipe(Effect.flatMap((tokens) => credential(deviceMethodID, tokens))), + } + }), + ), ), ), - ), - refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app), -}) satisfies IntegrationOAuthMethodRegistration + refresh: (value) => refresh(deviceMethodID, Credential.OAuth.make({ ...value, methodID: deviceMethodID }), app), + }) satisfies IntegrationOAuthMethodRegistration export const XAIPlugin = define({ id: "opencode.provider.xai", diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index cccbb5b5af74..fece057b455b 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -45,12 +45,9 @@ export interface Resolved { } // Keep this filesystem-only; permission checks use it and should not execute VCS commands. -export const root = Effect.fn("Project.root")(function* ( - fs: FSUtil.Interface, - input: AbsolutePath, -) { +export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, input: AbsolutePath) { return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe( - Effect.map((matches) => matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined), + Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)), Effect.catch(() => Effect.succeed(undefined)), ) }) diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index ad00debe8c05..ffd9fdbaeca5 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -89,229 +89,230 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Pty") {} -export const layer = (options?: ShellSelect.Options) => Layer.effect( - Service, - Effect.gen(function* () { - const bus = yield* Bus.Service - const location = yield* Location.Service - const config = yield* Config.Service - const context = yield* Effect.context() - const runFork = Effect.runForkWith(context) - const sessions = new Map() - const exitOrder: PtyID[] = [] - - function notifyEnd(session: Active, event: { exitCode?: number }) { - for (const subscriber of session.subscribers.values()) { - if (!subscriber.active) { - subscriber.end = event - continue +export const layer = (options?: ShellSelect.Options) => + Layer.effect( + Service, + Effect.gen(function* () { + const bus = yield* Bus.Service + const location = yield* Location.Service + const config = yield* Config.Service + const context = yield* Effect.context() + const runFork = Effect.runForkWith(context) + const sessions = new Map() + const exitOrder: PtyID[] = [] + + function notifyEnd(session: Active, event: { exitCode?: number }) { + for (const subscriber of session.subscribers.values()) { + if (!subscriber.active) { + subscriber.end = event + continue + } + try { + subscriber.onEnd(event) + } catch {} } - try { - subscriber.onEnd(event) - } catch {} - } - session.subscribers.clear() - } - - function teardown(session: Active) { - for (const listener of session.listeners) listener.dispose() - session.listeners.length = 0 - if (session.info.status === "running") { - try { - session.process.kill() - } catch {} - } - notifyEnd(session, {}) - } - - yield* Effect.addFinalizer(() => - Effect.sync(() => { - for (const session of sessions.values()) teardown(session) - sessions.clear() - exitOrder.length = 0 - }), - ) - - const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) { - const session = sessions.get(id) - if (!session) return yield* new NotFoundError({ ptyID: id }) - return session - }) - - const removeSession = Effect.fnUntraced(function* (id: PtyID) { - const session = sessions.get(id) - if (!session) return - sessions.delete(id) - const index = exitOrder.indexOf(id) - if (index !== -1) exitOrder.splice(index, 1) - yield* Effect.logInfo("removing session", { id }) - teardown(session) - yield* bus.publish(Pty.Event.Deleted, { id: session.info.id }) - }) - - const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { - yield* requireSession(id) - yield* removeSession(id) - }) - - const list = Effect.fn("Pty.list")(function* () { - return Array.from(sessions.values()).map((session) => session.info) - }) - - const get = Effect.fn("Pty.get")(function* (id: PtyID) { - return (yield* requireSession(id)).info - }) - - const create = Effect.fn("Pty.create")(function* (input: CreateInput) { - const id = PtyID.ascending() - const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options) - const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])] - const cwd = input.cwd || location.directory - const env = { - ...process.env, - ...input.env, - TERM: "xterm-256color", - OPENCODE_TERMINAL: "1", - } as Record - if (process.platform === "win32") { - env.LC_ALL = "C.UTF-8" - env.LC_CTYPE = "C.UTF-8" - env.LANG = "C.UTF-8" + session.subscribers.clear() } - yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd }) - const { spawn } = yield* Effect.promise(() => pty()) - const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env })) - const info: Info = { - id, - title: input.title || `Terminal ${id.slice(-4)}`, - command, - args, - cwd, - status: "running", - pid: proc.pid, - } - const session: Active = { - info, - process: proc, - buffer: "", - bufferCursor: 0, - cursor: 0, - subscribers: new Map(), - listeners: [], + + function teardown(session: Active) { + for (const listener of session.listeners) listener.dispose() + session.listeners.length = 0 + if (session.info.status === "running") { + try { + session.process.kill() + } catch {} + } + notifyEnd(session, {}) } - sessions.set(id, session) - session.listeners.push( - proc.onData((chunk) => { - session.cursor += chunk.length - for (const [token, subscriber] of session.subscribers.entries()) { - if (!subscriber.active) { - subscriber.pending.push(chunk) - continue + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const session of sessions.values()) teardown(session) + sessions.clear() + exitOrder.length = 0 + }), + ) + + const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) { + const session = sessions.get(id) + if (!session) return yield* new NotFoundError({ ptyID: id }) + return session + }) + + const removeSession = Effect.fnUntraced(function* (id: PtyID) { + const session = sessions.get(id) + if (!session) return + sessions.delete(id) + const index = exitOrder.indexOf(id) + if (index !== -1) exitOrder.splice(index, 1) + yield* Effect.logInfo("removing session", { id }) + teardown(session) + yield* bus.publish(Pty.Event.Deleted, { id: session.info.id }) + }) + + const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { + yield* requireSession(id) + yield* removeSession(id) + }) + + const list = Effect.fn("Pty.list")(function* () { + return Array.from(sessions.values()).map((session) => session.info) + }) + + const get = Effect.fn("Pty.get")(function* (id: PtyID) { + return (yield* requireSession(id)).info + }) + + const create = Effect.fn("Pty.create")(function* (input: CreateInput) { + const id = PtyID.ascending() + const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"), options) + const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])] + const cwd = input.cwd || location.directory + const env = { + ...process.env, + ...input.env, + TERM: "xterm-256color", + OPENCODE_TERMINAL: "1", + } as Record + if (process.platform === "win32") { + env.LC_ALL = "C.UTF-8" + env.LC_CTYPE = "C.UTF-8" + env.LANG = "C.UTF-8" + } + yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd }) + const { spawn } = yield* Effect.promise(() => pty()) + const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env })) + const info: Info = { + id, + title: input.title || `Terminal ${id.slice(-4)}`, + command, + args, + cwd, + status: "running", + pid: proc.pid, + } + const session: Active = { + info, + process: proc, + buffer: "", + bufferCursor: 0, + cursor: 0, + subscribers: new Map(), + listeners: [], + } + sessions.set(id, session) + session.listeners.push( + proc.onData((chunk) => { + session.cursor += chunk.length + for (const [token, subscriber] of session.subscribers.entries()) { + if (!subscriber.active) { + subscriber.pending.push(chunk) + continue + } + try { + subscriber.onData(chunk) + } catch { + session.subscribers.delete(token) + } } + session.buffer += chunk + if (session.buffer.length <= BUFFER_LIMIT) return + const excess = session.buffer.length - BUFFER_LIMIT + session.buffer = session.buffer.slice(excess) + session.bufferCursor += excess + }), + proc.onExit(({ exitCode }) => { + if (session.info.status === "exited") return + session.info.status = "exited" + session.info.exitCode = exitCode + notifyEnd(session, { exitCode }) + exitOrder.push(id) + runFork( + Effect.gen(function* () { + yield* Effect.logInfo("session exited", { id, exitCode }) + yield* bus.publish(Pty.Event.Exited, { id, exitCode }) + while (exitOrder.length > EXITED_LIMIT) { + const oldest = exitOrder[0] + if (!oldest) break + yield* removeSession(oldest) + } + }), + ) + }), + ) + yield* bus.publish(Pty.Event.Created, { info }) + return info + }) + + const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) { + const session = yield* requireSession(id) + if (input.title) session.info.title = input.title + if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows) + yield* bus.publish(Pty.Event.Updated, { info: session.info }) + return session.info + }) + + const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) { + const session = yield* requireSession(id) + if (session.info.status === "running") session.process.write(data) + }) + + const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) { + const session = yield* requireSession(id) + if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id }) + yield* Effect.logInfo("client attached to session", { id, directory: location.directory }) + const token = {} + const subscriber: Subscriber = { + onData: input.onData, + onEnd: input.onEnd, + active: false, + detached: false, + pending: [], + } + session.subscribers.set(token, subscriber) + const start = session.bufferCursor + const end = session.cursor + const from = + input.cursor === -1 + ? end + : typeof input.cursor === "number" && Number.isSafeInteger(input.cursor) + ? Math.max(0, input.cursor) + : 0 + const replay = (() => { + if (!session.buffer || from >= end) return "" + const offset = Math.max(0, from - start) + if (offset >= session.buffer.length) return "" + return session.buffer.slice(offset) + })() + return { + replay, + cursor: end, + write: (data: string) => { + if (session.info.status === "running") session.process.write(data) + }, + activate: () => { + if (subscriber.active || subscriber.detached) return + subscriber.active = true try { - subscriber.onData(chunk) + for (const chunk of subscriber.pending) subscriber.onData(chunk) + subscriber.pending.length = 0 + if (subscriber.end) subscriber.onEnd(subscriber.end) } catch { session.subscribers.delete(token) } - } - session.buffer += chunk - if (session.buffer.length <= BUFFER_LIMIT) return - const excess = session.buffer.length - BUFFER_LIMIT - session.buffer = session.buffer.slice(excess) - session.bufferCursor += excess - }), - proc.onExit(({ exitCode }) => { - if (session.info.status === "exited") return - session.info.status = "exited" - session.info.exitCode = exitCode - notifyEnd(session, { exitCode }) - exitOrder.push(id) - runFork( - Effect.gen(function* () { - yield* Effect.logInfo("session exited", { id, exitCode }) - yield* bus.publish(Pty.Event.Exited, { id, exitCode }) - while (exitOrder.length > EXITED_LIMIT) { - const oldest = exitOrder[0] - if (!oldest) break - yield* removeSession(oldest) - } - }), - ) - }), - ) - yield* bus.publish(Pty.Event.Created, { info }) - return info - }) - - const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) { - const session = yield* requireSession(id) - if (input.title) session.info.title = input.title - if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows) - yield* bus.publish(Pty.Event.Updated, { info: session.info }) - return session.info - }) - - const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) { - const session = yield* requireSession(id) - if (session.info.status === "running") session.process.write(data) - }) - - const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) { - const session = yield* requireSession(id) - if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id }) - yield* Effect.logInfo("client attached to session", { id, directory: location.directory }) - const token = {} - const subscriber: Subscriber = { - onData: input.onData, - onEnd: input.onEnd, - active: false, - detached: false, - pending: [], - } - session.subscribers.set(token, subscriber) - const start = session.bufferCursor - const end = session.cursor - const from = - input.cursor === -1 - ? end - : typeof input.cursor === "number" && Number.isSafeInteger(input.cursor) - ? Math.max(0, input.cursor) - : 0 - const replay = (() => { - if (!session.buffer || from >= end) return "" - const offset = Math.max(0, from - start) - if (offset >= session.buffer.length) return "" - return session.buffer.slice(offset) - })() - return { - replay, - cursor: end, - write: (data: string) => { - if (session.info.status === "running") session.process.write(data) - }, - activate: () => { - if (subscriber.active || subscriber.detached) return - subscriber.active = true - try { - for (const chunk of subscriber.pending) subscriber.onData(chunk) + }, + detach: () => { + subscriber.detached = true subscriber.pending.length = 0 - if (subscriber.end) subscriber.onEnd(subscriber.end) - } catch { + subscriber.end = undefined session.subscribers.delete(token) - } - }, - detach: () => { - subscriber.detached = true - subscriber.pending.length = 0 - subscriber.end = undefined - session.subscribers.delete(token) - }, - } - }) + }, + } + }) - return Service.of({ list, get, create, update, remove, write, attach }) - }), -) + return Service.of({ list, get, create, update, remove, write, attach }) + }), + ) export function configured(options?: ShellSelect.Options) { return makeLocationNode({ service: Service, layer: layer(options), deps: [Bus.node, Location.node, Config.node] }) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 2fcde1c67715..6547ca97730e 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -345,9 +345,7 @@ const layer = Layer.effect( Effect.gen(function* () { yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe( Effect.catchDefect((defect) => - defect instanceof SessionPending.LifecycleConflict - ? pendingConflict(input) - : Effect.die(defect), + defect instanceof SessionPending.LifecycleConflict ? pendingConflict(input) : Effect.die(defect), ), ) if (wake) yield* execution.wake(input.sessionID) diff --git a/packages/core/src/session/instruction-state.ts b/packages/core/src/session/instruction-state.ts index 9415899892ca..0db2ed4d6832 100644 --- a/packages/core/src/session/instruction-state.ts +++ b/packages/core/src/session/instruction-state.ts @@ -66,7 +66,10 @@ const renderUpdateText = Effect.fnUntraced(function* ( observation: Observation, ) { const replaced = Object.entries(observation.previous).filter(([key]) => Object.hasOwn(observation.delta, key)) - const blobs = yield* loadBlobs(db, replaced.map(([, hash]) => hash)) + const blobs = yield* loadBlobs( + db, + replaced.map(([, hash]) => hash), + ) const previous = Object.fromEntries(replaced.map(([key, hash]) => [key, requireBlob(blobs, hash)])) const admitted = new Map( Object.entries(observation.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]), @@ -193,10 +196,7 @@ export const preview = Effect.fn("InstructionState.preview")(function* ( const values = dereference(result.current, observedBlobs) return { initial: Instructions.renderInitial(instructions, values), update: "" } } - const stored = yield* loadBlobs(db, [ - ...Object.values(state.initial_values), - ...Object.values(state.current_values), - ]) + const stored = yield* loadBlobs(db, [...Object.values(state.initial_values), ...Object.values(state.current_values)]) return { initial: Instructions.renderInitial(instructions, dereference(state.initial_values, stored)), update: Instructions.renderUpdate( diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 11106f99f1b9..ed0e7ed86590 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -249,7 +249,9 @@ export const layer = Layer.effect( model: resolved.ref, request: before.request, response: new Response( - [204, 205, 304].includes(response.status) ? null : yield* Stream.toReadableStreamEffect(response.stream), + [204, 205, 304].includes(response.status) + ? null + : yield* Stream.toReadableStreamEffect(response.stream), { status: response.status, headers: response.headers }, ), }) @@ -274,8 +276,7 @@ export const layer = Layer.effect( ) } const executeTool: Prepared["executeTool"] = (input) => { - if (stepLimitReached) - return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" }) + if (stepLimitReached) return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" }) const tool = hooked.get(input.call.name) // A registered tool absent from the hooked set was removed or renamed by a hook. if (!tool && registry.has(input.call.name)) diff --git a/packages/core/src/session/pending.ts b/packages/core/src/session/pending.ts index d656564b5152..9e6dad94d148 100644 --- a/packages/core/src/session/pending.ts +++ b/packages/core/src/session/pending.ts @@ -349,14 +349,12 @@ const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* ( if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) -export const projectSteered = Effect.fn("SessionPending.projectSteered")( - (db: DatabaseService, input: PendingRef) => - projectDelivery(db, { ...input, from: "queue", to: "steer" }), +export const projectSteered = Effect.fn("SessionPending.projectSteered")((db: DatabaseService, input: PendingRef) => + projectDelivery(db, { ...input, from: "queue", to: "steer" }), ) -export const projectQueued = Effect.fn("SessionPending.projectQueued")( - (db: DatabaseService, input: PendingRef) => - projectDelivery(db, { ...input, from: "steer", to: "queue" }), +export const projectQueued = Effect.fn("SessionPending.projectQueued")((db: DatabaseService, input: PendingRef) => + projectDelivery(db, { ...input, from: "steer", to: "queue" }), ) export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* ( diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 2fbd713131c0..581224cbb4f7 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -38,7 +38,11 @@ import { ToolOutput } from "../../tool-output" type CallOutcome = Data.TaggedEnum<{ Completed: { readonly needsContinuation: boolean; readonly step: number } Retry: { readonly step: number } - Continue: { readonly cause: AIError; readonly error: SessionRunnerRetry.RetryableFailure["error"]; readonly step: number } + Continue: { + readonly cause: AIError + readonly error: SessionRunnerRetry.RetryableFailure["error"] + readonly step: number + } Restart: { readonly step: number; readonly recoveredOverflow: boolean } }> const CallOutcome = Data.taggedEnum() diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index cafbefd67a5a..3607a4772053 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -510,8 +510,7 @@ export const createLLMEventPublisher = (bus: Pick, inp const progress = Effect.fnUntraced(function* (id: string, update: Tool.Metadata) { const tool = tools.get(id) - if (!tool?.called || tool.settled) - return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`)) + if (!tool?.called || tool.settled) return yield* Effect.die(new Error(`Tool progress outside running call: ${id}`)) tool.progress = update yield* bus.publish(SessionEvent.Tool.Progress, { sessionID: input.sessionID, @@ -522,11 +521,7 @@ export const createLLMEventPublisher = (bus: Pick, inp }) /** Publishes one canonical terminal event for a locally executed tool call. */ - const toolExecution = Effect.fnUntraced(function* ( - id: string, - name: string, - result: Tool.Result, - ) { + const toolExecution = Effect.fnUntraced(function* (id: string, name: string, result: Tool.Result) { const tool = tools.get(id) if (!tool?.called) return yield* Effect.die(new Error(`Tool execution before call: ${id}`)) if (tool.name !== name) diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index eb3969a5a7e5..8b250bb57c4a 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -33,10 +33,7 @@ export interface Interface { sessionID: Session.ID sanitize?: boolean }) => Effect.Effect - readonly import: (input: { - data: Data - location: Location.Ref - }) => Effect.Effect + readonly import: (input: { data: Data; location: Location.Ref }) => Effect.Effect } export class Service extends Context.Service()("@opencode/SessionTransfer") {} @@ -108,7 +105,9 @@ const layer = Layer.effect( version: app.version, projectID: project.id, location: input.location, - subpath: RelativePath.make(path.relative(project.directory, input.location.directory).replaceAll("\\", "/")), + subpath: RelativePath.make( + path.relative(project.directory, input.location.directory).replaceAll("\\", "/"), + ), title: input.data.info.title, agent: input.data.info.agent, model: input.data.info.model, @@ -230,8 +229,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info { ? undefined : redact("synthetic-description", message.id, message.description), } - if (message.type === "system") - return { ...message, metadata: meta, text: redact("system", message.id, message.text) } + if (message.type === "system") return { ...message, metadata: meta, text: redact("system", message.id, message.text) } if (message.type === "skill") return { ...message, metadata: meta, text: redact("skill", message.id, message.text) } if (message.type === "shell") return { diff --git a/packages/core/src/shell/parser-wasm.node.ts b/packages/core/src/shell/parser-wasm.node.ts index 9b2527c9cc84..d8a9c5d6efc9 100644 --- a/packages/core/src/shell/parser-wasm.node.ts +++ b/packages/core/src/shell/parser-wasm.node.ts @@ -4,8 +4,7 @@ const require = createRequire(import.meta.url) export const shellParserWasm = { runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"), - bash: - process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"), + bash: process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"), powershell: process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ?? require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"), diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 29ebffc589ec..ee6170735632 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -59,7 +59,7 @@ export interface Interface { /** * Restore selected project-relative paths from their associated trees. A path * absent from its selected tree is removed; paths outside the map are untouched. - */ + */ readonly restore: (input: RestoreInput) => Effect.Effect } diff --git a/packages/core/src/tool-output.ts b/packages/core/src/tool-output.ts index fb63a783c331..2090a1d73ba5 100644 --- a/packages/core/src/tool-output.ts +++ b/packages/core/src/tool-output.ts @@ -24,9 +24,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ToolOutput") {} const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, directory: string) { - const cutoff = Identifier.timestamp( - Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)), - ) + const cutoff = Identifier.timestamp(Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION))) const entries = yield* fs.readDirectory(directory).pipe( Effect.map((entries) => entries.filter((entry) => /^tool_[0-9a-f]{12}/.test(entry))), Effect.catch(() => Effect.succeed([])), @@ -122,7 +120,11 @@ const cleanupLayer = Layer.effectDiscard( }), ) -const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: cleanupLayer, deps: [FSUtil.node, Global.node] }) +const cleanupNode = makeGlobalNode({ + name: "tool-output-cleanup", + layer: cleanupLayer, + deps: [FSUtil.node, Global.node], +}) export const node = makeLocationNode({ service: Service, diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts index 8023b5d85b43..51fb8c1e3c8b 100644 --- a/packages/core/src/tool/mcp.ts +++ b/packages/core/src/tool/mcp.ts @@ -49,54 +49,54 @@ export const layer = Layer.effectDiscard( output: (tool.outputSchema ?? {}) as JsonSchema.JsonSchema, execute: (input, context) => Effect.gen(function* () { - yield* permission.assert({ - action: name(tool.server, tool.name), - resources: ["*"], - save: ["*"], - metadata: {}, - sessionID: context.sessionID, - agent: context.agent, - source: { - type: "tool", - messageID: context.messageID, - id: context.id, - }, - }) - const result = yield* mcp - .callTool({ - server: tool.server, - name: tool.name, - args: (input ?? {}) as Record, - }) - .pipe( - Effect.catchTags({ - "MCP.NotFoundError": (error) => - new ToolFailure({ message: `MCP server "${error.server}" is not available` }), - "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), - }), - ) - if (result.isError) - return yield* new ToolFailure({ - message: - result.content - .flatMap((part) => (part.type === "text" ? [part.text] : [])) - .join("\n") - .trim() || "MCP tool returned an error", - }) - const content = result.content.map((part) => - part.type === "text" - ? { type: "text" as const, text: part.text } - : { - type: "file" as const, - uri: `data:${part.mimeType};base64,${part.data}`, - mime: part.mimeType, + yield* permission.assert({ + action: name(tool.server, tool.name), + resources: ["*"], + save: ["*"], + metadata: {}, + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.messageID, + id: context.id, }, - ) - const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") - return { - output: result.structured ?? (text === "" ? null : text), - ...(content.length === 0 ? {} : { content }), - } + }) + const result = yield* mcp + .callTool({ + server: tool.server, + name: tool.name, + args: (input ?? {}) as Record, + }) + .pipe( + Effect.catchTags({ + "MCP.NotFoundError": (error) => + new ToolFailure({ message: `MCP server "${error.server}" is not available` }), + "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), + }), + ) + if (result.isError) + return yield* new ToolFailure({ + message: + result.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n") + .trim() || "MCP tool returned an error", + }) + const content = result.content.map((part) => + part.type === "text" + ? { type: "text" as const, text: part.text } + : { + type: "file" as const, + uri: `data:${part.mimeType};base64,${part.data}`, + mime: part.mimeType, + }, + ) + const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") + return { + output: result.structured ?? (text === "" ? null : text), + ...(content.length === 0 ? {} : { content }), + } }).pipe( Effect.mapError((error) => error instanceof ToolFailure diff --git a/packages/core/src/tool/plugin/question.ts b/packages/core/src/tool/plugin/question.ts index 2a054bf257cb..1ab11db94bc6 100644 --- a/packages/core/src/tool/plugin/question.ts +++ b/packages/core/src/tool/plugin/question.ts @@ -35,10 +35,7 @@ export class CancelledError extends Schema.TaggedErrorClass()("Q } } -export const toModelOutput = ( - questions: ReadonlyArray, - answers: ReadonlyArray, -) => { +export const toModelOutput = (questions: ReadonlyArray, answers: ReadonlyArray) => { const formatted = questions .map( (question, index) => @@ -56,62 +53,60 @@ export const Plugin = { yield* ctx.tool .transform((draft) => - draft.add( - ({ - name, - options: { codemode: false }, - description, - input: Input, - output: Output, - execute: (input, context) => - permission - .assert({ - action: "question", - resources: ["*"], - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.messageID, id: context.id }, - }) - .pipe( - Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })), - Effect.andThen( - forms - .ask({ - sessionID: context.sessionID, - title: "Questions", - metadata: { - kind: "question", - tool: { messageID: context.messageID, id: context.id }, - }, - fields: [ - toField(input.questions[0], 0), - ...input.questions.slice(1).map((question, index) => toField(question, index + 1)), - ], - }) - .pipe(Effect.orDie), - ), - Effect.flatMap((state) => { - // Deliberate defect tunnel (see Permission.assert): a dismissal must dodge - // leaf `mapError` blankets so it never becomes model-facing tool output; it - // resurfaces as a typed failure at SessionModelRequest.executeTool. - if (state.status === "cancelled") return Effect.die(new CancelledError()) - const output = { - answers: input.questions.map((_, index): Question.Answer => { - const value = state.answer[`q${index}`] - if (value === undefined) return [] - if (typeof value === "object") return Array.from(value) - return [String(value)] - }), - } - return Effect.succeed({ - output, - content: toModelOutput(input.questions, output.answers), - metadata: { answers: output.answers }, + draft.add({ + name, + options: { codemode: false }, + description, + input: Input, + output: Output, + execute: (input, context) => + permission + .assert({ + action: "question", + resources: ["*"], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.messageID, id: context.id }, + }) + .pipe( + Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })), + Effect.andThen( + forms + .ask({ + sessionID: context.sessionID, + title: "Questions", + metadata: { + kind: "question", + tool: { messageID: context.messageID, id: context.id }, + }, + fields: [ + toField(input.questions[0], 0), + ...input.questions.slice(1).map((question, index) => toField(question, index + 1)), + ], }) - }), + .pipe(Effect.orDie), ), - }), - ), + Effect.flatMap((state) => { + // Deliberate defect tunnel (see Permission.assert): a dismissal must dodge + // leaf `mapError` blankets so it never becomes model-facing tool output; it + // resurfaces as a typed failure at SessionModelRequest.executeTool. + if (state.status === "cancelled") return Effect.die(new CancelledError()) + const output = { + answers: input.questions.map((_, index): Question.Answer => { + const value = state.answer[`q${index}`] + if (value === undefined) return [] + if (typeof value === "object") return Array.from(value) + return [String(value)] + }), + } + return Effect.succeed({ + output, + content: toModelOutput(input.questions, output.answers), + metadata: { answers: output.answers }, + }) + }), + ), + }), ) .pipe(Effect.orDie) }), diff --git a/packages/core/src/tool/plugin/skill.ts b/packages/core/src/tool/plugin/skill.ts index d24d2a80f206..cacb524f127c 100644 --- a/packages/core/src/tool/plugin/skill.ts +++ b/packages/core/src/tool/plugin/skill.ts @@ -56,50 +56,48 @@ export const Plugin = { const permission = yield* Permission.Service yield* ctx.tool .transform((draft) => - draft.add( - ({ - name, - options: { codemode: false }, - description, - input: Input, - output: Output, - execute: (input, context) => - Effect.gen(function* () { - const current = yield* skills.list() - const skill = current.find((skill) => skill.id === input.id) - if (!skill) return yield* unableToLoad(input.id) - return yield* Effect.gen(function* () { - yield* permission.assert({ - action: name, - resources: [skill.id], - save: [skill.id], - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.messageID, id: context.id }, - }) - const directory = path.dirname(skill.location) - const files = - path.basename(skill.location) === "SKILL.md" - ? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) - .filter((file) => path.basename(file) !== "SKILL.md") - .toSorted() - .slice(0, FILE_LIMIT) - : [] - return { - name: skill.name, - directory, - output: toModelOutput(skill, files), - } - }).pipe(Effect.mapError((error) => unableToLoad(input.id, error))) - }).pipe( - Effect.map((output) => ({ - output, - content: output.output, - metadata: { name: output.name, directory: output.directory }, - })), - ), - }), - ), + draft.add({ + name, + options: { codemode: false }, + description, + input: Input, + output: Output, + execute: (input, context) => + Effect.gen(function* () { + const current = yield* skills.list() + const skill = current.find((skill) => skill.id === input.id) + if (!skill) return yield* unableToLoad(input.id) + return yield* Effect.gen(function* () { + yield* permission.assert({ + action: name, + resources: [skill.id], + save: [skill.id], + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.messageID, id: context.id }, + }) + const directory = path.dirname(skill.location) + const files = + path.basename(skill.location) === "SKILL.md" + ? (yield* fs.scan("**/*", { cwd: directory, absolute: true, include: "file", dot: true })) + .filter((file) => path.basename(file) !== "SKILL.md") + .toSorted() + .slice(0, FILE_LIMIT) + : [] + return { + name: skill.name, + directory, + output: toModelOutput(skill, files), + } + }).pipe(Effect.mapError((error) => unableToLoad(input.id, error))) + }).pipe( + Effect.map((output) => ({ + output, + content: output.output, + metadata: { name: output.name, directory: output.directory }, + })), + ), + }), ) .pipe(Effect.orDie) }), diff --git a/packages/core/src/tool/plugin/subagent.ts b/packages/core/src/tool/plugin/subagent.ts index 0538234ce8ec..79443dfe8d25 100644 --- a/packages/core/src/tool/plugin/subagent.ts +++ b/packages/core/src/tool/plugin/subagent.ts @@ -112,138 +112,135 @@ export const Plugin = { yield* ctx.tool .transform((draft) => - draft.add( - ({ - name, - options: { codemode: false }, - description, - input: Input, - output: Output, - execute: (input, context) => - Effect.gen(function* () { - const parent = yield* runtime.session - .get(context.sessionID) - .pipe( - Effect.mapError( - (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), - ), - ) - let current = parent - let depth = 0 - while (current.parentID) { - depth++ - current = yield* runtime.session - .get(current.parentID) - .pipe( - Effect.mapError( - (error) => new ToolFailure({ message: `Parent session not found: ${current.parentID}`, error }), - ), - ) - } - const limit = Config.latest(yield* config.entries(), "experimental")?.subagent_depth ?? 1 - if (depth >= limit) - return yield* new ToolFailure({ - message: `Subagent depth limit reached (${limit}). Increase "experimental.subagent_depth" to allow nested subagents.`, - }) - const agent = yield* agents.resolve(input.agent) - if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) - if (agent.mode === "primary") - return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` }) - yield* permission - .assert({ - action: name, - resources: [agent.id], - save: [agent.id], - sessionID: context.sessionID, - agent: context.agent, - source: { - type: "tool", - messageID: context.messageID, - id: context.id, - }, - }) - .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }))) - - // Model selection is policy/config/session state, not an LLM-facing tool argument. - const model = agent.model ?? parent.model - const child = yield* runtime.session - .create({ - parentID: context.sessionID, - title: input.description, - agent: Agent.ID.make(input.agent), - model, - // TODO(opencode kkdvxn): derive restricted subagent permissions from the parent - // session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions. - }) + draft.add({ + name, + options: { codemode: false }, + description, + input: Input, + output: Output, + execute: (input, context) => + Effect.gen(function* () { + const parent = yield* runtime.session + .get(context.sessionID) + .pipe( + Effect.mapError( + (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), + ), + ) + let current = parent + let depth = 0 + while (current.parentID) { + depth++ + current = yield* runtime.session + .get(current.parentID) .pipe( Effect.mapError( - (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), + (error) => new ToolFailure({ message: `Parent session not found: ${current.parentID}`, error }), ), ) + } + const limit = Config.latest(yield* config.entries(), "experimental")?.subagent_depth ?? 1 + if (depth >= limit) + return yield* new ToolFailure({ + message: `Subagent depth limit reached (${limit}). Increase "experimental.subagent_depth" to allow nested subagents.`, + }) + const agent = yield* agents.resolve(input.agent) + if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) + if (agent.mode === "primary") + return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` }) + yield* permission + .assert({ + action: name, + resources: [agent.id], + save: [agent.id], + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.messageID, + id: context.id, + }, + }) + .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }))) - const background = input.background === true - yield* context.progress({ - metadata: { sessionID: child.id, status: "running" }, + // Model selection is policy/config/session state, not an LLM-facing tool argument. + const model = agent.model ?? parent.model + const child = yield* runtime.session + .create({ + parentID: context.sessionID, + title: input.description, + agent: Agent.ID.make(input.agent), + model, + // TODO(opencode kkdvxn): derive restricted subagent permissions from the parent + // session (V1 deriveSubagentSessionPermission). MVP uses the agent's own permissions. }) + .pipe( + Effect.mapError( + (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }), + ), + ) - const run = Effect.gen(function* () { - // The child session owns its agent/model (set at create); prompt only admits input. - yield* runtime.session.prompt({ - sessionID: child.id, - text: ["You are a subagent spawned by another session.", input.prompt].join("\n"), - resume: false, - }) - yield* runtime.session.resume(child.id) - return yield* latestAssistantText(child.id) - }).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id))) + const background = input.background === true + yield* context.progress({ + metadata: { sessionID: child.id, status: "running" }, + }) - const info = yield* runtime.job.start({ - id: child.id, - type: name, - title: input.description, - metadata: {}, - run, + const run = Effect.gen(function* () { + // The child session owns its agent/model (set at create); prompt only admits input. + yield* runtime.session.prompt({ + sessionID: child.id, + text: ["You are a subagent spawned by another session.", input.prompt].join("\n"), + resume: false, }) + yield* runtime.session.resume(child.id) + return yield* latestAssistantText(child.id) + }).pipe(Effect.onInterrupt(() => runtime.session.interrupt(child.id))) - if (background) { - yield* runtime.job.background(info.id) - yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description) - return { - sessionID: child.id, - status: "running" as const, - output: backgroundStarted(child.id), - } + const info = yield* runtime.job.start({ + id: child.id, + type: name, + title: input.description, + metadata: {}, + run, + }) + + if (background) { + yield* runtime.job.background(info.id) + yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description) + return { + sessionID: child.id, + status: "running" as const, + output: backgroundStarted(child.id), } + } - const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe( - Effect.onInterrupt(() => - Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], { - discard: true, - }), - ), - ) - if (result?.type === "backgrounded") { - yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description) - return { - sessionID: child.id, - status: "running" as const, - output: backgroundStarted(child.id), - } + const result = yield* runtime.job.block({ id: child.id, sessionID: context.sessionID }).pipe( + Effect.onInterrupt(() => + Effect.all([runtime.session.interrupt(child.id), runtime.job.cancel(child.id)], { + discard: true, + }), + ), + ) + if (result?.type === "backgrounded") { + yield* notifyWhenDone(context.sessionID, child.id, agent.name, input.description) + return { + sessionID: child.id, + status: "running" as const, + output: backgroundStarted(child.id), } - if (result?.info.status === "error") - return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" }) - if (result?.info.status === "cancelled") - return yield* new ToolFailure({ message: "Subagent cancelled" }) - return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT } - }).pipe( - Effect.map((output) => ({ - output, - content: output.output, - metadata: { sessionID: output.sessionID, status: output.status }, - })), - ), - }), - ), + } + if (result?.info.status === "error") + return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" }) + if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" }) + return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT } + }).pipe( + Effect.map((output) => ({ + output, + content: output.output, + metadata: { sessionID: output.sessionID, status: output.status }, + })), + ), + }), ) .pipe(Effect.orDie) diff --git a/packages/core/src/tool/plugin/webfetch.ts b/packages/core/src/tool/plugin/webfetch.ts index f01f59b5fef7..d570e8bf10bc 100644 --- a/packages/core/src/tool/plugin/webfetch.ts +++ b/packages/core/src/tool/plugin/webfetch.ts @@ -118,62 +118,60 @@ export const Plugin = { yield* ctx.tool .transform((draft) => - draft.add( - ({ - name, - options: { codemode: false }, - description, - input: Input, - output: Output, - execute: (input, context) => - Effect.gen(function* () { - yield* Effect.try({ - try: () => assertHttpUrl(new URL(input.url)), - catch: (error) => error, - }) - - yield* permission.assert({ - action: name, - resources: [input.url], - save: ["*"], - metadata: input, - sessionID: context.sessionID, - agent: context.agent, - source: { type: "tool", messageID: context.messageID, id: context.id }, - }) - - const { body, contentType } = yield* Effect.gen(function* () { - const response = yield* execute(http, input.url, input.format).pipe( - Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")), - ) - const contentType = response.headers["content-type"] || "" - const mime = mimeFrom(contentType) - if (isImageAttachment(mime)) - return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`)) - if (!isTextualMime(mime)) - return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`)) - return { body: yield* collectBody(response), contentType } - }).pipe( - Effect.timeoutOrElse({ - duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS), - orElse: () => Effect.fail(new Error("Request timed out")), - }), + draft.add({ + name, + options: { codemode: false }, + description, + input: Input, + output: Output, + execute: (input, context) => + Effect.gen(function* () { + yield* Effect.try({ + try: () => assertHttpUrl(new URL(input.url)), + catch: (error) => error, + }) + + yield* permission.assert({ + action: name, + resources: [input.url], + save: ["*"], + metadata: input, + sessionID: context.sessionID, + agent: context.agent, + source: { type: "tool", messageID: context.messageID, id: context.id }, + }) + + const { body, contentType } = yield* Effect.gen(function* () { + const response = yield* execute(http, input.url, input.format).pipe( + Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")), ) - const content = new TextDecoder().decode(body) - const output = yield* Effect.try({ - try: () => convert(content, contentType, input.format), - catch: (error) => error, - }) - const result = { - url: input.url, - contentType, - format: input.format, - output, - } - return { output: result, content: result.output, metadata: { contentType: result.contentType } } - }).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))), - }), - ), + const contentType = response.headers["content-type"] || "" + const mime = mimeFrom(contentType) + if (isImageAttachment(mime)) + return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`)) + if (!isTextualMime(mime)) + return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`)) + return { body: yield* collectBody(response), contentType } + }).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS), + orElse: () => Effect.fail(new Error("Request timed out")), + }), + ) + const content = new TextDecoder().decode(body) + const output = yield* Effect.try({ + try: () => convert(content, contentType, input.format), + catch: (error) => error, + }) + const result = { + url: input.url, + contentType, + format: input.format, + output, + } + return { output: result, content: result.output, metadata: { contentType: result.contentType } } + }).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))), + }), ) .pipe(Effect.orDie) }), diff --git a/packages/core/src/tool/runtime.ts b/packages/core/src/tool/runtime.ts index c2aad5e2e206..f75dc543a421 100644 --- a/packages/core/src/tool/runtime.ts +++ b/packages/core/src/tool/runtime.ts @@ -44,7 +44,8 @@ const encodeOutput = (schema: Tool.ValueSchema, value: unknown) => { if (Schema.isSchema(schema)) return Schema.encodeEffect(schema)(value).pipe( Effect.mapError( - (error) => new Tool.Error({ message: `Tool returned an invalid value for its output schema: ${error.message}` }), + (error) => + new Tool.Error({ message: `Tool returned an invalid value for its output schema: ${error.message}` }), ), ) if (isStandardSchema(schema)) diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts index 2da6085103b7..db1f95d66124 100644 --- a/packages/core/src/vcs.ts +++ b/packages/core/src/vcs.ts @@ -46,7 +46,7 @@ const layer = Layer.effect( const bus = yield* Bus.Service const impl = adapter(proc, fs, location) const vcs = location.vcs - const state = { info: impl ? yield* impl.info() : { branch: {} } satisfies Info } + const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) } if (vcs && impl) { const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store))) diff --git a/packages/core/src/vcs/git.ts b/packages/core/src/vcs/git.ts index 415bbf12873c..60f84742069f 100644 --- a/packages/core/src/vcs/git.ts +++ b/packages/core/src/vcs/git.ts @@ -181,7 +181,10 @@ function makeGit(proc: AppProcess.Interface) { if (remote) { const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd }) if (head.exitCode === 0) { - const ref = head.text().trim().replace(/^refs\/remotes\//, "") + const ref = head + .text() + .trim() + .replace(/^refs\/remotes\//, "") const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : "" if (name) return { name, ref } satisfies Base } diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 50e484b3c98e..928040002444 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -130,18 +130,20 @@ describe("Agent", () => { expect(info?.permissions.slice(0, Agent.Info.default(id).permissions.length)).toEqual( Agent.Info.default(id).permissions, ) - expect(Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? []).effect).toBe( - "allow", - ) - expect(Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? []).effect).toBe( - "allow", - ) - expect(Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect).toBe( - "allow", - ) - expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect).toBe( - "allow", - ) + expect( + Permission.evaluate("external_directory", path.join(global.data, "shell", "*", "*"), info?.permissions ?? []) + .effect, + ).toBe("allow") + expect( + Permission.evaluate("external_directory", path.join(global.data, "tool-output", "*"), info?.permissions ?? []) + .effect, + ).toBe("allow") + expect( + Permission.evaluate("external_directory", path.join(global.config, "*"), info?.permissions ?? []).effect, + ).toBe("allow") + expect( + Permission.evaluate("external_directory", path.join(global.tmp, "*"), info?.permissions ?? []).effect, + ).toBe("allow") yield* agent.transform((editor) => editor.remove(id)) expect(yield* agent.get(id)).toBeUndefined() diff --git a/packages/core/test/app.test.ts b/packages/core/test/app.test.ts index 8d62f001412f..06bac015e7a2 100644 --- a/packages/core/test/app.test.ts +++ b/packages/core/test/app.test.ts @@ -2,7 +2,5 @@ import { expect, test } from "bun:test" import { App } from "@opencode-ai/core/app" test("formats app metadata as a user agent", () => { - expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe( - "opencode/beta/1.2.3/sdk", - ) + expect(App.useragent(App.make({ name: "sdk", version: "1.2.3", channel: "beta" }))).toBe("opencode/beta/1.2.3/sdk") }) diff --git a/packages/core/test/bus.test.ts b/packages/core/test/bus.test.ts index f9563f9b7d6b..f1f3997962ed 100644 --- a/packages/core/test/bus.test.ts +++ b/packages/core/test/bus.test.ts @@ -105,13 +105,9 @@ const it = testEffect( ]), ) const itWithoutLocation = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [ - [Bus.node, Bus.configured({ persist: true })], - ]), -) -const itWithoutPersistence = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])), + AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]), ) +const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]))) describe("Bus", () => { it.effect("subscribes to multiple event definitions with a discriminated payload union", () => @@ -1341,5 +1337,4 @@ describe("Bus", () => { }).pipe(Effect.provide(eventLayer)) }), ) - }) diff --git a/packages/core/test/codemode.test.ts b/packages/core/test/codemode.test.ts index 25a5f87941d8..6525f17af1f0 100644 --- a/packages/core/test/codemode.test.ts +++ b/packages/core/test/codemode.test.ts @@ -12,12 +12,12 @@ describe("CodeMode", () => { const tools = yield* Tool.Service yield* tools.transform((draft) => draft.add({ - name: "echo", - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.String, - options: { pinned: true }, - execute: ({ text }) => Effect.succeed({ output: text }), + name: "echo", + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.String, + options: { pinned: true }, + execute: ({ text }) => Effect.succeed({ output: text }), }), ) diff --git a/packages/core/test/codemode/catalog.test.ts b/packages/core/test/codemode/catalog.test.ts index afa1f063895f..308c15b16276 100644 --- a/packages/core/test/codemode/catalog.test.ts +++ b/packages/core/test/codemode/catalog.test.ts @@ -48,10 +48,7 @@ describe("CodeModeCatalog.summarize", () => { }) test("always retains pinned tools beyond the inline budget", () => { - const pinned = [ - entry("alpha.first", "First", undefined, true), - entry("beta.second", "Second", undefined, true), - ] + const pinned = [entry("alpha.first", "First", undefined, true), entry("beta.second", "Second", undefined, true)] const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0) expect(catalog.shown).toBe(2) diff --git a/packages/core/test/codemode/instructions.test.ts b/packages/core/test/codemode/instructions.test.ts index f902e647dd87..29f1250223e3 100644 --- a/packages/core/test/codemode/instructions.test.ts +++ b/packages/core/test/codemode/instructions.test.ts @@ -70,20 +70,20 @@ describe("CodeModeInstructions", () => { ) it.effect("stores a canonical sorted snapshot so registration order does not churn history", () => { - const alpha = ({ + const alpha = { name: "alpha", description: "Alpha tool", input: Schema.Struct({}), output: Schema.String, execute: () => Effect.succeed({ output: "alpha" }), - }) - const zeta = ({ + } + const zeta = { name: "zeta", description: "Zeta tool", input: Schema.Struct({}), output: Schema.String, execute: () => Effect.succeed({ output: "zeta" }), - }) + } const layer = AppNodeBuilder.build(Tool.node, [ [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], ]) @@ -96,9 +96,7 @@ describe("CodeModeInstructions", () => { draft.add({ ...zeta, options: { namespace: "tools" } }) draft.add({ ...alpha, options: { namespace: "tools" } }) }) - return yield* readInitial( - CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog), - ) + return yield* readInitial(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog)) }), ) const reordered = yield* Effect.scoped( @@ -107,10 +105,7 @@ describe("CodeModeInstructions", () => { draft.add({ ...alpha, options: { namespace: "tools" } }) draft.add({ ...zeta, options: { namespace: "tools" } }) }) - return yield* readUpdate( - CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog), - initialized, - ) + return yield* readUpdate(CodeModeInstructions.make((yield* tools.snapshot()).codeModeCatalog), initialized) }), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index b2f449fbc74d..346b64517fa5 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -373,9 +373,7 @@ describe("Config", () => { const initial = yield* config.entries() expect(Config.latest(initial, "shell")).toBe("project") expect( - initial.flatMap((entry) => - entry.type === "document" && entry.info.shell ? [entry.info.shell] : [], - ), + initial.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])), ).toEqual(["secret", "global", "project"]) const updated = yield* bus .subscribe(Event.Updated) @@ -387,9 +385,7 @@ describe("Config", () => { const refreshed = yield* config.entries() expect(Config.latest(refreshed, "shell")).toBe("project") expect( - refreshed.flatMap((entry) => - entry.type === "document" && entry.info.shell ? [entry.info.shell] : [], - ), + refreshed.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])), ).toEqual(["next", "global", "project"]) }).pipe( Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)), diff --git a/packages/core/test/effect/observability.test.ts b/packages/core/test/effect/observability.test.ts index 416d9da4b6f1..0c190fd0af62 100644 --- a/packages/core/test/effect/observability.test.ts +++ b/packages/core/test/effect/observability.test.ts @@ -12,7 +12,6 @@ const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES afterEach(() => { if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes - }) describe("resource", () => { diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts index 441ee4fdb6e6..155b585c1f2d 100644 --- a/packages/core/test/formatter.test.ts +++ b/packages/core/test/formatter.test.ts @@ -39,10 +39,7 @@ function formatterLayer(directory: string, configured?: ConfigInput["formatter"] ], [ Location.node, - Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(directory) })), - ), + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), ], [Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })], ]) diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts index 5a56609ee629..36f9c61ef180 100644 --- a/packages/core/test/instruction-state.test.ts +++ b/packages/core/test/instruction-state.test.ts @@ -14,7 +14,12 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { InstructionState } from "@opencode-ai/core/session/instruction-state" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionSchema } from "@opencode-ai/core/session/schema" -import { InstructionBlobTable, InstructionStateTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { + InstructionBlobTable, + InstructionStateTable, + SessionMessageTable, + SessionTable, +} from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" const it = testEffect( diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index c99d2001330c..2862253efdbe 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -16,11 +16,7 @@ export const toolIdentity = { export const toolDefinitions = (registry: Tool.Interface, permissions?: Permission.Ruleset) => registry.snapshot(permissions).pipe(Effect.map((toolSet) => toolSet.definitions)) -export function waitForTool( - registry: Tool.Interface, - name: string, - remaining = 1000, -): Effect.Effect { +export function waitForTool(registry: Tool.Interface, name: string, remaining = 1000): Effect.Effect { return Effect.gen(function* () { if ((yield* toolDefinitions(registry)).some((tool) => tool.name === name)) return if (remaining === 0) { diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 846b8835e7a8..7989a53734a3 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -909,16 +909,18 @@ it.effect("waits for permission before calling an MCP tool", () => const registry = yield* Tool.Service const toolSet = yield* waitForCodeModeTool(registry, "demo.search") - const fiber = yield* toolSet.execute({ - sessionID: Session.ID.make("ses_mcp_permission"), - ...toolIdentity, - call: { - type: "tool-call", - id: "call_mcp_permission", - name: "execute", - input: { code: "return await tools.demo.search({})" }, - }, - }).pipe(Effect.forkScoped) + const fiber = yield* toolSet + .execute({ + sessionID: Session.ID.make("ses_mcp_permission"), + ...toolIdentity, + call: { + type: "tool-call", + id: "call_mcp_permission", + name: "execute", + input: { code: "return await tools.demo.search({})" }, + }, + }) + .pipe(Effect.forkScoped) expect(yield* Deferred.await(assertion)).toEqual({ action: "demo_search", resources: ["*"], diff --git a/packages/core/test/patch.test.ts b/packages/core/test/patch.test.ts index bd75b2664790..24d3077700cc 100644 --- a/packages/core/test/patch.test.ts +++ b/packages/core/test/patch.test.ts @@ -177,11 +177,7 @@ describe("Patch", () => { }) test("preserves the end-of-file marker", () => { - expect( - parse( - "*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch", - ), - ).toEqual([ + expect(parse("*** Begin Patch\n*** Update File: file.txt\n@@\n+quux\n*** End of File\n\n*** End Patch")).toEqual([ { type: "update", path: "file.txt", @@ -192,16 +188,16 @@ describe("Patch", () => { }) test("allows an end-of-file marker before an explicit chunk", () => { - expect( - parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n@@\n-old\n+new\n*** End Patch"), - ).toEqual([ - { - type: "update", - path: "file.txt", - movePath: undefined, - chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }], - }, - ]) + expect(parse("*** Begin Patch\n*** Update File: file.txt\n*** End of File\n@@\n-old\n+new\n*** End Patch")).toEqual( + [ + { + type: "update", + path: "file.txt", + movePath: undefined, + chunks: [{ oldLines: ["old"], newLines: ["new"], changeContext: undefined }], + }, + ], + ) }) test("allows an end-of-file marker before an implicit chunk and move", () => { @@ -247,9 +243,9 @@ describe("Patch", () => { }) test("appends a pure-addition chunk to a nonempty file", () => { - expect(Patch.derive("update.txt", [{ oldLines: [], newLines: ["added 1", "added 2"] }], "line 1\nline 2\n").content).toBe( - "line 1\nline 2\nadded 1\nadded 2\n", - ) + expect( + Patch.derive("update.txt", [{ oldLines: [], newLines: ["added 1", "added 2"] }], "line 1\nline 2\n").content, + ).toBe("line 1\nline 2\nadded 1\nadded 2\n") }) test("applies a pure-addition chunk after an earlier replacement", () => { @@ -303,27 +299,35 @@ describe("Patch", () => { test("matches Unicode minus signs and spaces", () => { expect( - Patch.derive("minus.txt", [{ oldLines: ["value - 1"], newLines: ["value - 2"] }], "value − 1\n") - .content, + Patch.derive("minus.txt", [{ oldLines: ["value - 1"], newLines: ["value - 2"] }], "value − 1\n").content, ).toBe("value - 2\n") - const spaces = ["\u00A0", "\u2002", "\u2003", "\u2004", "\u2005", "\u2006", "\u2007", "\u2008", "\u2009", "\u200A", "\u202F", "\u205F", "\u3000"] - spaces.forEach( - (space) => { - expect( - Patch.derive( - "spaces.txt", - [{ oldLines: ["hello world"], newLines: ["hello there"] }], - `hello${space}world\n`, - ).content, - ).toBe("hello there\n") - }, - ) + const spaces = [ + "\u00A0", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\u200A", + "\u202F", + "\u205F", + "\u3000", + ] + spaces.forEach((space) => { + expect( + Patch.derive("spaces.txt", [{ oldLines: ["hello world"], newLines: ["hello there"] }], `hello${space}world\n`) + .content, + ).toBe("hello there\n") + }) }) test("does not normalize ellipses", () => { - expect(() => - Patch.derive("ellipsis.txt", [{ oldLines: ["wait..."], newLines: ["done"] }], "wait…\n"), - ).toThrow("Failed to find expected lines") + expect(() => Patch.derive("ellipsis.txt", [{ oldLines: ["wait..."], newLines: ["done"] }], "wait…\n")).toThrow( + "Failed to find expected lines", + ) }) test("prefers a later exact match over an earlier normalized match", () => { @@ -357,9 +361,9 @@ describe("Patch", () => { }) test("identifies a missing blank line", () => { - expect(() => - Patch.derive("update.txt", [{ oldLines: [""], newLines: ["added"] }], "content\n"), - ).toThrow("Failed to find an expected blank line in update.txt") + expect(() => Patch.derive("update.txt", [{ oldLines: [""], newLines: ["added"] }], "content\n")).toThrow( + "Failed to find an expected blank line in update.txt", + ) }) test("parses an update without an explicit first chunk header", () => { @@ -483,7 +487,9 @@ describe("Patch", () => { "Invalid hunk at line 4: Unexpected line found in update hunk: '@@'", ) expect(() => - parse("*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n@@\n-old\n+new\n*** End Patch"), + parse( + "*** Begin Patch\n*** Update File: file.txt\n@@\n*** Update File: other.txt\n@@\n-old\n+new\n*** End Patch", + ), ).toThrow("Invalid hunk at line 4: Unexpected line found in update hunk: '*** Update File: other.txt'") expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\nbad\n*** End Patch")).toThrow( "Invalid hunk at line 4: Unexpected line found in update hunk: 'bad'", @@ -497,7 +503,9 @@ describe("Patch", () => { expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@foo\n*** End Patch")).toThrow( "Invalid hunk at line 3: Unexpected line found in update hunk: '@@foo'", ) - expect(() => parse("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n*** Frobnicate File: foo\n*** End Patch")).toThrow( + expect(() => + parse("*** Begin Patch\n*** Update File: file.txt\n@@\n-old\n*** Frobnicate File: foo\n*** End Patch"), + ).toThrow( "Invalid hunk at line 5: Expected update hunk to start with a @@ context marker, got: '*** Frobnicate File: foo'", ) }) diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 7e7ac0392c6d..e8565abe238c 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -26,14 +26,7 @@ const current = Layer.succeed( ) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([ - Database.node, - Bus.node, - SessionStore.node, - PermissionSaved.node, - Agent.node, - Permission.node, - ]), + LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]), [[Location.node, current]], ), ) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index c2032348bb86..e91908c541dc 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -250,16 +250,14 @@ describe("Plugin", () => { effect: (ctx) => ctx.tool .transform((draft) => - draft.add( - ({ - name: "plugin_tool", - options: { codemode: false }, - description: "Plugin tool", - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ output: { ok: true } }), - }), - ), + draft.add({ + name: "plugin_tool", + options: { codemode: false }, + description: "Plugin tool", + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ output: { ok: true } }), + }), ) .pipe(Effect.orDie), }) @@ -276,15 +274,14 @@ describe("Plugin", () => { Effect.gen(function* () { const plugins = yield* Plugin.Service const registry = yield* Tool.Service - const tool = (name: string, description: string, options?: Tool.Options) => - ({ - name, - options, - description, - input: Schema.Struct({}), - output: Schema.Struct({ ok: Schema.Boolean }), - execute: () => Effect.succeed({ output: { ok: true } }), - }) + const tool = (name: string, description: string, options?: Tool.Options) => ({ + name, + options, + description, + input: Schema.Struct({}), + output: Schema.Struct({ ok: Schema.Boolean }), + execute: () => Effect.succeed({ output: { ok: true } }), + }) const plugin = EffectPlugin.define({ id: "grouped-tools", effect: (ctx) => @@ -323,17 +320,15 @@ describe("Plugin", () => { Effect.gen(function* () { yield* ctx.tool .transform((draft) => - draft.add( - ({ - name: "echo", - options: { codemode: false }, - description: "Echo", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => - Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })), - }), - ), + draft.add({ + name: "echo", + options: { codemode: false }, + description: "Echo", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => + Effect.sync(() => executed.push({ text })).pipe(Effect.as({ output: { text } })), + }), ) .pipe(Effect.orDie) @@ -368,7 +363,8 @@ describe("Plugin", () => { yield* ctx.tool .hook("execute.after", (event) => Effect.sync(() => { - if (event.status === "completed" && Array.isArray(event.result.content)) event.result.content.splice(0) + if (event.status === "completed" && Array.isArray(event.result.content)) + event.result.content.splice(0) }), ) .pipe(Effect.asVoid) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index f45364ebdfe3..f35beaa1ec44 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -226,8 +226,7 @@ export function catalogHost(catalog: Catalog.Interface): Plugin.Context["catalog })), }) }), - remove: (providerID, modelID) => - draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)), + remove: (providerID, modelID) => draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)), default: { get: () => { const value = draft.model.default.get() @@ -417,9 +416,7 @@ function method(value: Integration.Method) { } } -function internalMethod( - value: IntegrationMethodRegistration["method"], -): Integration.Method { +function internalMethod(value: IntegrationMethodRegistration["method"]): Integration.Method { if (value.type === "env") return value if (value.type === "key") return value if (value.type === "command") { diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index bf13f452d4dd..1a479b7d8fa5 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -65,10 +65,7 @@ describe("CerebrasPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: Model.Info.make({ - ...Model.Info.default( - Provider.ID.make("custom-cerebras"), - Model.ID.make("llama-4-scout-17b-16e-instruct"), - ), + ...Model.Info.default(Provider.ID.make("custom-cerebras"), Model.ID.make("llama-4-scout-17b-16e-instruct")), modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"), package: "aisdk:test-provider", }), @@ -88,10 +85,7 @@ describe("CerebrasPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: Model.Info.make({ - ...Model.Info.default( - Provider.ID.make("custom-cerebras"), - Model.ID.make("llama-4-scout-17b-16e-instruct"), - ), + ...Model.Info.default(Provider.ID.make("custom-cerebras"), Model.ID.make("llama-4-scout-17b-16e-instruct")), modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"), package: "aisdk:test-provider", }), @@ -110,10 +104,7 @@ describe("CerebrasPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: Model.Info.make({ - ...Model.Info.default( - Provider.ID.make("custom-cerebras"), - Model.ID.make("llama-4-scout-17b-16e-instruct"), - ), + ...Model.Info.default(Provider.ID.make("custom-cerebras"), Model.ID.make("llama-4-scout-17b-16e-instruct")), modelID: Model.ID.make("llama-4-scout-17b-16e-instruct"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 88551c90ebf6..5f0ff9fc58a3 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -128,12 +128,8 @@ describe("OpenAIPlugin", () => { expect(eligible.cost).toEqual([]) expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) expect(eligible.enabled).toBe(true) - expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe( - false, - ) - expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe( - false, - ) + expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(false) + expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(false) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({ context: 400_000, input: 272_000, @@ -180,5 +176,4 @@ describe("OpenAIPlugin", () => { expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) }), ) - }) diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 7d9cae924ecc..ce97f1b6a3ce 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -75,8 +75,7 @@ describe("OpenRouterPlugin", () => { }) yield* addPlugin() expect( - (yield* catalog.model.get(Provider.ID.make("custom-openrouter"), Model.ID.make("gpt-5-chat-latest"))) - ?.enabled, + (yield* catalog.model.get(Provider.ID.make("custom-openrouter"), Model.ID.make("gpt-5-chat-latest")))?.enabled, ).toBe(true) }), ) diff --git a/packages/core/test/plugin/system-prompt.test.ts b/packages/core/test/plugin/system-prompt.test.ts index 31ff5b99a66a..78e98b575f1b 100644 --- a/packages/core/test/plugin/system-prompt.test.ts +++ b/packages/core/test/plugin/system-prompt.test.ts @@ -43,9 +43,7 @@ describe("SystemPromptPlugin", () => { expect(PROMPT_META).toContain("`edit` for editing") expect(PROMPT_META).toContain("`write` for creating files") expect(PROMPT_META).toContain("https://opencode.ai/v2/docs/") - expect(PROMPT_META).not.toMatch( - /TodoWrite|Task tool|WebFetch|\bBash\b|https:\/\/opencode\.ai\/docs/, - ) + expect(PROMPT_META).not.toMatch(/TodoWrite|Task tool|WebFetch|\bBash\b|https:\/\/opencode\.ai\/docs/) }) test("uses granular IDs with a common prefix", () => { diff --git a/packages/core/test/plugin/websearch-fixture.ts b/packages/core/test/plugin/websearch-fixture.ts index c6734e57a5b6..090db2b293f1 100644 --- a/packages/core/test/plugin/websearch-fixture.ts +++ b/packages/core/test/plugin/websearch-fixture.ts @@ -41,15 +41,9 @@ const http = Layer.succeed( export const webSearchIntegrationTest = testEffect( Layer.merge( - AppNodeBuilder.build( - LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]), - [ - [ - Config.node, - Config.testLayer(), - ], - ], - ), + AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]), [ + [Config.node, Config.testLayer()], + ]), http, ), ) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 8cb44e0f77f3..da4ddf4fb735 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -366,10 +366,7 @@ describe("Project.resolve", () => { }) expect( (yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)), - ).toEqual([ - { directory: yield* real(tmp.path) }, - { directory: yield* real(worktree), strategy: "git_worktree" }, - ]) + ).toEqual([{ directory: yield* real(tmp.path) }, { directory: yield* real(worktree), strategy: "git_worktree" }]) }), ) }) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index e4f0bdf6e784..c2b4e2fc4935 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -213,9 +213,7 @@ const configuredIt = testEffect( Layer.mock(Config.Service)({ entries: () => Effect.succeed( - configuredShell - ? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })] - : [], + configuredShell ? [new Document({ type: "document", info: new Info({ shell: configuredShell }) })] : [], ), }), ], diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index f885c09dde34..95f6ddf854af 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -129,7 +129,9 @@ describe("Ripgrep", () => { Effect.promise(() => tmpdir()), (tmp) => Effect.gen(function* () { - yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "generated.ts"), `Cloudflare${"x".repeat(70 * 1024)}\n`)) + yield* Effect.promise(() => + fs.writeFile(path.join(tmp.path, "generated.ts"), `Cloudflare${"x".repeat(70 * 1024)}\n`), + ) const matches = yield* (yield* Ripgrep.Service).grep({ cwd: tmp.path, diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 40c50c80f37d..7598d487e44f 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -563,11 +563,7 @@ describe("Session.prompt", () => { const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false } const first = yield* session.prompt(input) yield* SessionPending.promote(db, bus, sessionID, "steer") - yield* db - .delete(EventTable) - .where(eq(EventTable.aggregate_id, sessionID)) - .run() - .pipe(Effect.orDie) + yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, sessionID)).run().pipe(Effect.orDie) const retried = yield* session.prompt(input) @@ -982,13 +978,9 @@ describe("Session.prompt", () => { expect(input.delivery).toBe("queue") expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true) - expect( - yield* SessionPending.promote(db, bus, sessionID, "steer"), - ).toBe(0) + expect(yield* SessionPending.promote(db, bus, sessionID, "steer")).toBe(0) expect(yield* session.messages({ sessionID })).toEqual([]) - expect( - yield* SessionPending.promote(db, bus, sessionID, "input"), - ).toBe(1) + expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1) expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: input.id, type: "synthetic", text: "Queued completion" }, @@ -1058,14 +1050,10 @@ describe("Session.pending", () => { { id: second.id, type: "user", delivery: "steer" }, ]) - expect( - yield* SessionPending.promote(db, bus, sessionID, "input"), - ).toBe(2) + expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(2) expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }]) - expect( - yield* SessionPending.promote(db, bus, sessionID, "input"), - ).toBe(1) + expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1) expect(yield* session.pending(sessionID)).toEqual([]) }), ) @@ -1104,9 +1092,11 @@ describe("Session.pending", () => { expect(yield* session.pending(sessionID)).toEqual([]) expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1) - expect( - yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip), - ).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID }) + expect(yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip)).toMatchObject({ + _tag: "Session.PendingInputConflictError", + sessionID, + inputID, + }) expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1) const retried = yield* session.prompt({ @@ -1151,9 +1141,11 @@ describe("Session.pending", () => { expect(wakeCalls).toEqual([]) expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1) - expect( - yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip), - ).toMatchObject({ _tag: "Session.PendingInputConflictError", sessionID, inputID: alreadySteered.id }) + expect(yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({ + _tag: "Session.PendingInputConflictError", + sessionID, + inputID: alreadySteered.id, + }) yield* session.cancelPending({ sessionID, inputID: alreadySteered.id }) expect(wakeCalls).toEqual([]) expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index b33aa91d5493..69e9d2dfab38 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -24,9 +24,7 @@ const capture = (providerMetadataKey = "anthropic", options?: { readonly interru const publish = Effect.sync(() => { const event = { id: Event.ID.create(), type: definition.type, data } as Event.Payload published.push({ - type: definition.durable - ? Bus.versionedType(definition.type, definition.durable.version) - : definition.type, + type: definition.durable ? Bus.versionedType(definition.type, definition.durable.version) : definition.type, data, }) return event @@ -226,9 +224,7 @@ test("provider-executed tool metadata is flattened using the route key", async ( test("binary failure emits no success event", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(call)) - await Effect.runPromise( - publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" }), - ) + await Effect.runPromise(publisher.failTool(call.id, { type: "tool.execution", message: "Cannot read binary file" })) expect(published.some((event) => event.type === "session.tool.success.2")).toBe(false) expect(published.some((event) => event.type === "session.tool.failed.2")).toBe(true) }) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 7db79669b6dc..b3fb1029a0b3 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -48,33 +48,25 @@ const call = (name: string, id = `call-${name}`): Parameters - ({ - name: "echo", - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: ({ text }) => Effect.succeed({ output: { text }, content: text }), - }) - -const constant = (text: string): Info => - ({ - name: "constant", - description: "Return text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: () => Effect.succeed({ output: { text }, content: text }), - }) - -const transform = ( - service: Tool.Interface, - tools: Readonly>, - options?: Tool.Options, -) => +const make = (): Info => ({ + name: "echo", + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }) => Effect.succeed({ output: { text }, content: text }), +}) + +const constant = (text: string): Info => ({ + name: "constant", + description: "Return text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: () => Effect.succeed({ output: { text }, content: text }), +}) + +const transform = (service: Tool.Interface, tools: Readonly>, options?: Tool.Options) => service.transform((draft) => - Object.entries(tools).forEach(([name, tool]) => - draft.add({ ...tool, name, options: options ?? tool.options }), - ), + Object.entries(tools).forEach(([name, tool]) => draft.add({ ...tool, name, options: options ?? tool.options })), ) describe("Tool", () => { @@ -95,8 +87,9 @@ describe("Tool", () => { const invalid = yield* transform(service, { "123": make() }, { codemode: false }).pipe(Effect.flip) expect(invalid.message).toBe("Invalid tool name: 123") - const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }) - .pipe(Effect.flip) + const collision = yield* transform(service, { "echo.tool": make(), echo_tool: make() }, { codemode: false }).pipe( + Effect.flip, + ) expect(collision.message).toBe("Duplicate normalized tool name: echo_tool") expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"]) }), @@ -202,11 +195,7 @@ describe("Tool", () => { { action: "*", resource: "*", effect: "deny" }, ]), ).toEqual([]) - expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual([ - "bash", - "question", - "execute", - ]) + expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["bash", "question", "execute"]) }), ) @@ -239,13 +228,12 @@ describe("Tool", () => { const service = yield* Tool.Service const scope = yield* Scope.make() const registered = yield* Deferred.make() - const fiber = yield* transform(service, { echo: make() }, { codemode: false }) - .pipe( - Effect.andThen(Deferred.succeed(registered, undefined)), - Effect.andThen(Effect.never), - Scope.provide(scope), - Effect.forkChild, - ) + const fiber = yield* transform(service, { echo: make() }, { codemode: false }).pipe( + Effect.andThen(Deferred.succeed(registered, undefined)), + Effect.andThen(Effect.never), + Scope.provide(scope), + Effect.forkChild, + ) yield* Deferred.await(registered) yield* Fiber.interrupt(fiber) @@ -258,15 +246,16 @@ describe("Tool", () => { it.effect("returns model errors without swallowing interruption or defects", () => Effect.gen(function* () { const service = yield* Tool.Service - yield* transform(service, + yield* transform( + service, { - failed: ({ + failed: { name: "failed", description: "Failed", input: Schema.Struct({}), output: Schema.Struct({ ok: Schema.Boolean }), execute: () => Effect.fail(new Tool.Error({ message: "Denied" })), - }), + }, }, { codemode: false }, ) @@ -285,15 +274,16 @@ describe("Tool", () => { }), ).toEqual({ status: "error", error: { type: "tool.execution", message: "Unknown tool: missing" } }) - yield* transform(service, + yield* transform( + service, { - defect: ({ + defect: { name: "defect", description: "Defect", input: Schema.Struct({}), output: Schema.Struct({}), execute: () => Effect.die("unexpected executor defect"), - }), + }, }, { codemode: false }, ) @@ -326,16 +316,17 @@ describe("Tool", () => { Effect.gen(function* () { const service = yield* Tool.Service const contexts: Tool.Context[] = [] - yield* transform(service, + yield* transform( + service, { - context: ({ + context: { name: "context", description: "Context", input: Schema.Struct({}), output: Schema.Struct({ ok: Schema.Boolean }), execute: (_, context) => Effect.sync(() => contexts.push(context)).pipe(Effect.as({ output: { ok: true } })), - }), + }, }, { codemode: false }, ) @@ -353,9 +344,10 @@ describe("Tool", () => { it.effect("normalizes image tool output once and drops unresizable images", () => Effect.gen(function* () { const service = yield* Tool.Service - yield* transform(service, + yield* transform( + service, { - snapshot: ({ + snapshot: { name: "snapshot", description: "Return images", input: Schema.Struct({ text: Schema.String }), @@ -375,7 +367,7 @@ describe("Tool", () => { { type: "text", text }, ], }), - }), + }, }, { codemode: false }, ) @@ -405,9 +397,7 @@ describe("Tool", () => { if (event.status !== "completed") return event.result = { ...event.result, - content: [ - { type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "hook.png" }, - ], + content: [{ type: "file", uri: "data:image/png;base64,aW1hZ2U=", mime: "image/png", name: "hook.png" }], } }), ) @@ -426,16 +416,17 @@ describe("Tool", () => { it.effect("publishes progress metadata unchanged", () => Effect.gen(function* () { const service = yield* Tool.Service - yield* transform(service, + yield* transform( + service, { - progressive: ({ + progressive: { name: "progressive", description: "Emit image progress", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), execute: ({ text }, context) => context.progress({ stage: "capture" }).pipe(Effect.as({ output: { text } })), - }), + }, }, { codemode: false }, ) @@ -462,16 +453,17 @@ describe("Tool", () => { encode: SchemaGetter.transform((value) => value === "yes"), }), ) - yield* transform(service, + yield* transform( + service, { - transformed: ({ + transformed: { name: "transformed", description: "Transform values", input: Schema.Struct({ value: Transformed }), output: Schema.Struct({ value: Transformed }), execute: ({ value }) => Effect.sync(() => executed.push(value)).pipe(Effect.as({ output: { value }, content: String(value) })), - }), + }, }, { codemode: false }, ) @@ -501,9 +493,10 @@ describe("Tool", () => { }) expect(executed).toEqual(["yes"]) - yield* transform(service, + yield* transform( + service, { - invalid_output: ({ + invalid_output: { name: "invalid_output", description: "Return invalid output", input: Schema.Struct({}), @@ -520,7 +513,7 @@ describe("Tool", () => { ), }), execute: () => Effect.succeed({ output: { value: "invalid" } }), - }), + }, }, { codemode: false }, ) @@ -570,19 +563,18 @@ describe("Tool", () => { const executed: string[] = [] const scope = yield* Scope.make() yield* transform(service, { - echo: ({ - name: "echo", - description: "Echo text", - input: Schema.Struct({ text: Schema.String }), - output: Schema.Struct({ text: Schema.String }), - execute: ({ text }, context) => - Effect.sync(() => executed.push(`old:${text}`)).pipe( - Effect.andThen(context.progress({ stage: "old" })), - Effect.as({ output: { text } }), - ), - }), - }) - .pipe(Scope.provide(scope)) + echo: { + name: "echo", + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: ({ text }, context) => + Effect.sync(() => executed.push(`old:${text}`)).pipe( + Effect.andThen(context.progress({ stage: "old" })), + Effect.as({ output: { text } }), + ), + }, + }).pipe(Scope.provide(scope)) const toolSet = yield* service.snapshot() const execute = toolSet.definitions.find((tool) => tool.name === "execute") expect(toolSet.codeModeCatalog?.[0]?.signature).toContain("tools.echo") @@ -590,13 +582,13 @@ describe("Tool", () => { expect(execute?.description).not.toContain("Echo text") yield* Scope.close(scope, Exit.void) yield* transform(service, { - echo: ({ + echo: { name: "echo", description: "Echo text", input: Schema.Struct({ text: Schema.String }), output: Schema.Struct({ text: Schema.String }), execute: ({ text }) => Effect.sync(() => executed.push(`new:${text}`)).pipe(Effect.as({ output: { text } })), - }), + }, }) const progress: Tool.Metadata[] = [] diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 547b930d7e2f..e44b3d2c9c88 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -3254,10 +3254,7 @@ describe("SessionRunnerLLM", () => { yield* stream.started expect(requests).toHaveLength(2) - expect(requests.map((request) => request.promptCacheKey)).toEqual([ - sessionID, - otherSessionID, - ]) + expect(requests.map((request) => request.promptCacheKey)).toEqual([sessionID, otherSessionID]) yield* stream.release yield* Fiber.join(first) yield* Fiber.join(second) diff --git a/packages/core/test/shell-parse.test.ts b/packages/core/test/shell-parse.test.ts index 8b7525a8540c..d18fe710978c 100644 --- a/packages/core/test/shell-parse.test.ts +++ b/packages/core/test/shell-parse.test.ts @@ -20,7 +20,11 @@ describe("ShellParse", () => { test("splits PowerShell commands case-insensitively", async () => { const result = await Effect.runPromise( - ShellParse.scan("Get-ChildItem; Write-Output 'done'", "C:\\Program Files\\PowerShell\\7\\pwsh.exe", "C:\\workspace"), + ShellParse.scan( + "Get-ChildItem; Write-Output 'done'", + "C:\\Program Files\\PowerShell\\7\\pwsh.exe", + "C:\\workspace", + ), ) expect(result.commands).toEqual([ { resource: "Get-ChildItem", save: "Get-ChildItem *" }, diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index c36d2d447a67..678f68313e47 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -199,7 +199,5 @@ async function initGit(directory: string, commit = false) { await $`git init`.cwd(directory).quiet() await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet() if (!commit) return - await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial` - .cwd(directory) - .quiet() + await $`git -c user.email=test@opencode.test -c user.name=Test commit --no-gpg-sign -m initial`.cwd(directory).quiet() } diff --git a/packages/core/test/tool-execute.test.ts b/packages/core/test/tool-execute.test.ts index adcc49250219..f5e07ff7044d 100644 --- a/packages/core/test/tool-execute.test.ts +++ b/packages/core/test/tool-execute.test.ts @@ -33,27 +33,27 @@ test("execute describes invariant Code Mode behavior", () => { }) test("canonical execution distinguishes declared, model-only, and raw schema outputs", async () => { - const declared: Info = ({ + const declared: Info = { name: "declared", description: "Declared", input: Schema.Struct({ value: Schema.String }), output: Schema.Struct({ value: Schema.String }), execute: ({ value }) => Effect.succeed({ output: { value } }), - }) + } const modelOnlyInput = Schema.Struct({}) - const modelOnly = ({ + const modelOnly = { name: "model_only", description: "Model only", input: modelOnlyInput, execute: () => Effect.succeed({ content: "visible only", metadata: { kind: "model" } }), - }) satisfies Info - const raw: Info = ({ + } satisfies Info + const raw: Info = { name: "raw", description: "Raw", input: {}, output: {}, execute: (input) => Effect.succeed({ output: input, content: "raw" }), - }) + } expect(await Effect.runPromise(execute(declared, { value: "encoded" }, context))).toEqual({ output: { value: "encoded" }, @@ -95,22 +95,22 @@ test("declared outputs cannot bypass validation and raw outputs stay JSON-compat }) test("execute supports callable namespace tools", async () => { - const callable: Info = ({ + const callable: Info = { name: "admin", description: "Administer Slack", input: Schema.Struct({}), output: Schema.String, options: { namespace: "slack" }, execute: () => Effect.succeed({ output: "admin" }), - }) - const child: Info = ({ + } + const child: Info = { name: "create", description: "Create a Slack resource", input: Schema.Struct({}), output: Schema.String, options: { namespace: "slack.admin" }, execute: () => Effect.succeed({ output: "created" }), - }) + } const codeMode = createCodeMode( new Map([ ["slack_admin", callable], @@ -118,10 +118,7 @@ test("execute supports callable namespace tools", async () => { ]), ) const result = await Effect.runPromise( - codeMode.execute( - { code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" }, - context, - ), + codeMode.execute({ code: "return [await tools.slack.admin({}), await tools.slack.admin.create({})]" }, context), ) expect(result.metadata).toEqual({ diff --git a/packages/core/test/tool-output.test.ts b/packages/core/test/tool-output.test.ts index 1f2c5af2f2cc..962187af64fe 100644 --- a/packages/core/test/tool-output.test.ts +++ b/packages/core/test/tool-output.test.ts @@ -147,10 +147,7 @@ describe("ToolOutput", () => { withStore((output, fs, root) => Effect.gen(function* () { const directory = path.join(root, ToolOutput.DIRECTORY) - const old = path.join( - directory, - Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000), - ) + const old = path.join(directory, Identifier.create("tool", "ascending", Date.now() - 8 * 24 * 60 * 60 * 1_000)) const recent = path.join(directory, Identifier.ascending("tool")) yield* fs.ensureDir(directory) yield* fs.writeFileString(old, "old") diff --git a/packages/core/test/tool-schema.test.ts b/packages/core/test/tool-schema.test.ts index 7f3e13e685ae..a24e03b46135 100644 --- a/packages/core/test/tool-schema.test.ts +++ b/packages/core/test/tool-schema.test.ts @@ -121,13 +121,13 @@ test("portable schemas validate and describe typed tools", async () => { }, }, } - const tool: Info = ({ + const tool: Info = { name: "portable", description: "Portable tool", input, output, execute: ({ count }) => Effect.succeed({ output: count + 1 }), - }) + } expect(definition(tool)).toEqual({ name: "portable", @@ -170,13 +170,13 @@ test("portable schema failures become tool failures", async () => { test("canonical results carry metadata with typed output", async () => { const input = Schema.Struct({ value: Schema.String }) const output = Schema.Struct({ value: Schema.String, internal: Schema.Boolean }) - const tool: Info = ({ + const tool: Info = { name: "annotated", description: "Annotated tool", input, output, execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }), - }) + } expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({ output: { value: "out", internal: true }, @@ -187,12 +187,12 @@ test("canonical results carry metadata with typed output", async () => { test("raw JSON schemas are render-only and omitted output means model-only", async () => { const input = { type: "object", properties: { value: { type: "string" } } } - const tool: Info = ({ + const tool: Info = { name: "raw", description: "Raw tool", input, execute: (input) => Effect.succeed({ content: JSON.stringify(input) }), - }) + } expect(definition(tool)).toEqual({ name: "raw", diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 9d2f834de847..578009ea167c 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -309,27 +309,29 @@ describe("ShellTool", () => { ), ) - it.live("captures stderr-only and mixed stdout/stderr output", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - return withSession(tmp.path, (registry) => - Effect.gen(function* () { - const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr")) - expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false }) - expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" }) - - const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed")) - expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false }) - const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : "" - expect(output).toContain("stdout") - expect(output).toContain("stderr") - }), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), - ), + it.live( + "captures stderr-only and mixed stdout/stderr output", + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + return withSession(tmp.path, (registry) => + Effect.gen(function* () { + const stderr = yield* executeTool(registry, call({ command: stderrCommand }, "call-stderr")) + expect(stderr.metadata).toMatchObject({ exit: 0, truncated: false }) + expect(stderr.content?.[0]).toEqual({ type: "text", text: "stderr only" }) + + const mixed = yield* executeTool(registry, call({ command: mixedOutputCommand }, "call-mixed")) + expect(mixed.metadata).toMatchObject({ exit: 0, truncated: false }) + const output = mixed.content?.[0]?.type === "text" ? mixed.content[0].text : "" + expect(output).toContain("stdout") + expect(output).toContain("stderr") + }), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), { timeout: 15_000 }, ) @@ -390,7 +392,9 @@ describe("ShellTool", () => { const command = isWindows ? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path` : `cd '${outside.path}' && pwd` - return withSession(active.path, (registry) => executeTool(registry, call({ command }, "call-external-cd"))).pipe( + return withSession(active.path, (registry) => + executeTool(registry, call({ command }, "call-external-cd")), + ).pipe( Effect.andThen( Effect.sync(() => { expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"]) @@ -476,32 +480,34 @@ describe("ShellTool", () => { ), ) - it.live("truncates the model view and points at the saved output file when output overflows", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { - reset() - const bytes = ToolOutput.MAX_BYTES + 1024 - return withSession(tmp.path, (registry) => - executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")), - ).pipe( - Effect.andThen((settled) => - Effect.sync(() => { - expect(settled.metadata).toMatchObject({ exit: 0, truncated: true }) - const content = settled.content?.[0] - if (!content || content.type !== "text") throw new Error("Expected text content") - expect(content.text.includes("output-start")).toBe(false) - expect(content.text.includes("output-end")).toBe(true) - expect(content).toMatchObject({ - type: "text", - text: expect.stringContaining("output truncated; full output saved to:"), - }) - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), - ), + it.live( + "truncates the model view and points at the saved output file when output overflows", + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const bytes = ToolOutput.MAX_BYTES + 1024 + return withSession(tmp.path, (registry) => + executeTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")), + ).pipe( + Effect.andThen((settled) => + Effect.sync(() => { + expect(settled.metadata).toMatchObject({ exit: 0, truncated: true }) + const content = settled.content?.[0] + if (!content || content.type !== "text") throw new Error("Expected text content") + expect(content.text.includes("output-start")).toBe(false) + expect(content.text.includes("output-end")).toBe(true) + expect(content).toMatchObject({ + type: "text", + text: expect.stringContaining("output truncated; full output saved to:"), + }) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), { timeout: 15_000 }, ) @@ -546,10 +552,7 @@ describe("ShellTool", () => { Effect.gen(function* () { const observed = yield* Deferred.make() yield* executeTool(registry, { - ...call( - { command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) }, - "call-progress", - ), + ...call({ command: progressOverflowCommand(ToolOutput.MAX_BYTES + 1024, release) }, "call-progress"), progress: (update) => Effect.gen(function* () { if (typeof update.shellID !== "string") return @@ -591,10 +594,12 @@ describe("ShellTool", () => { { timeout: 10_000 }, ) - it.live("returns a useful timeout outcome", () => - Effect.acquireUseRelease( - Effect.promise(() => tmpdir()), - (tmp) => { + it.live( + "returns a useful timeout outcome", + () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { reset() return withSession(tmp.path, (registry) => executeTool(registry, call({ command: timeoutOutputCommand, timeout: isWindows ? 3_000 : 50 })), @@ -607,15 +612,15 @@ describe("ShellTool", () => { text: expect.stringContaining("before timeout"), }) expect(settled.content?.[1]).toMatchObject({ - type: "text", - text: expect.stringContaining("Command timed out"), - }) - }), - ), - ) - }, - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), - ), + type: "text", + text: expect.stringContaining("Command timed out"), + }) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), + ), { timeout: 15_000 }, ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 842b5ea6aae9..dedc4d56e969 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -85,14 +85,11 @@ describe("SkillTool", () => { list: () => Effect.succeed(current), }), ) - const skillToolLayer = AppNodeBuilder.build( - LayerNode.group([Tool.node, skillToolNode]), - [ - [Permission.node, permission], - [Skill.node, skills], - [Image.node, imagePassthrough], - ], - ) + const skillToolLayer = AppNodeBuilder.build(LayerNode.group([Tool.node, skillToolNode]), [ + [Permission.node, permission], + [Skill.node, skills], + [Image.node, imagePassthrough], + ]) return yield* Effect.gen(function* () { const registry = yield* Tool.Service diff --git a/packages/core/test/util/effect-flock.test.ts b/packages/core/test/util/effect-flock.test.ts index 436074b503b1..04a223b66e6b 100644 --- a/packages/core/test/util/effect-flock.test.ts +++ b/packages/core/test/util/effect-flock.test.ts @@ -146,9 +146,9 @@ describe("util.effect-flock", () => { Effect.gen(function* () { yield* flock.acquire(key, dir) const started = performance.now() - const error = yield* Effect.scoped( - flock.acquire(key, dir, { staleMs: 10_000, timeoutMs: 300 }), - ).pipe(Effect.flip) + const error = yield* Effect.scoped(flock.acquire(key, dir, { staleMs: 10_000, timeoutMs: 300 })).pipe( + Effect.flip, + ) expect(error._tag).toBe("LockTimeoutError") expect(performance.now() - started).toBeLessThan(1_000) }), diff --git a/packages/core/test/vcs-hg.test.ts b/packages/core/test/vcs-hg.test.ts index 33a6db3e817f..38b43f885bfc 100644 --- a/packages/core/test/vcs-hg.test.ts +++ b/packages/core/test/vcs-hg.test.ts @@ -46,7 +46,10 @@ const withHg = (f: (directory: string) => Effect.Effect) => ) async function hg(directory: string, ...args: string[]) { - await $`hg ${args}`.cwd(directory).env({ ...process.env, HGPLAIN: "1" }).quiet() + await $`hg ${args}` + .cwd(directory) + .env({ ...process.env, HGPLAIN: "1" }) + .quiet() } async function commitAll(directory: string, message: string) { @@ -117,11 +120,9 @@ describeHg("Vcs mercurial", () => { const bus = yield* Bus.Service expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } }) - const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe( - Stream.take(1), - Stream.runHead, - Effect.forkScoped({ startImmediately: true }), - ) + const updated = yield* bus + .subscribe(VcsEvent.BranchUpdated) + .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true })) yield* Effect.promise(() => hg(directory, "branch", "-q", "feature")) expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } }) diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts index 20e72807d0fc..677fb4f156ad 100644 --- a/packages/core/test/vcs.test.ts +++ b/packages/core/test/vcs.test.ts @@ -104,11 +104,9 @@ describe("Vcs", () => { const bus = yield* Bus.Service expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } }) - const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe( - Stream.take(1), - Stream.runHead, - Effect.forkScoped({ startImmediately: true }), - ) + const updated = yield* bus + .subscribe(VcsEvent.BranchUpdated) + .pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true })) yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet()) yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })