From 39d7394ede7cb5a729f2296480f58d9f57038c9f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:39:52 -0500 Subject: [PATCH 001/554] fix(stats): hide unique users tooltip total --- packages/stats/app/src/routes/index.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 8e4b86a00af7..51ca059f8e8d 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -744,10 +744,14 @@ function TopModelsChart(props: { data-placement={dayIndex() > props.data.length * 0.62 ? "left" : "right"} > {point().date} - - {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())} - -
+ + + {formatUsageChartValue(usageTotal(point()), metric())} {usageChartTotalLabel(metric())} + + + +
+ {(item) => (

Date: Mon, 22 Jun 2026 11:47:02 -0500 Subject: [PATCH 002/554] fix: dont show gpt-5.5-pro when using codex oauth (#33400) Co-authored-by: Devin Oldenburg --- packages/opencode/src/plugin/openai/codex.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index 93c22ea6af3a..c13a9c439d4b 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -13,6 +13,7 @@ const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) +const DISALLOWED_MODELS = new Set(["gpt-5.5-pro"]) interface PkceCodes { verifier: string @@ -370,6 +371,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug Object.entries(provider.models) .filter(([, model]) => { if (ALLOWED_MODELS.has(model.api.id)) return true + if (DISALLOWED_MODELS.has(model.api.id)) return false const match = model.api.id.match(/^gpt-(\d+\.\d+)/) return match ? parseFloat(match[1]) > 5.4 : false }) From 130957288e13b5f9ef26b9d7dec131aa2113fa74 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 19:43:07 +0200 Subject: [PATCH 003/554] fix(llm): preserve structured tool errors (#33405) --- packages/llm/src/protocols/shared.ts | 11 +++- .../llm/test/provider/openai-chat.test.ts | 21 +++++++ .../test/provider/openai-responses.test.ts | 58 +++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index 66b353c82854..c5b6003fd280 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -19,6 +19,7 @@ export { isRecord } export const Json = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownSync(Json) export const encodeJson = Schema.encodeSync(Json) +const isJson = Schema.is(Schema.Json) export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) @@ -243,8 +244,14 @@ export const validateToolFile = (route: string, part: ToolFileContent, supported export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") export const toolResultText = (part: ToolResultPart) => { - if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) - if (part.result.type === "content") return encodeJson(part.result.value) + if (part.result.type === "text") return String(part.result.value) + if (part.result.type === "error") { + const value = part.result.value + const prototype = + typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) + const structured = Array.isArray(value) || prototype === Object.prototype || prototype === null + return structured && isJson(value) ? encodeJson(value) : String(value) + } return encodeJson(part.result.value) } diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 9966b92e3dea..5dbc89f1aee7 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -224,6 +224,27 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { error: { type: "unknown", message: "Tool execution interrupted" } } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }), + ], + }), + ) + + expect(prepared.body.messages.at(-1)).toEqual({ + role: "tool", + tool_call_id: "call_1", + content: ProviderShared.encodeJson(error), + }) + }), + ) + it.effect("continues image tool results as vision input without base64 text", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 717a7e8024f3..b854537fe20f 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -360,6 +360,64 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("preserves structured tool errors for the model", () => + Effect.gen(function* () { + const error = { + error: { type: "unknown", message: "Tool execution interrupted" }, + content: [], + structured: {}, + } + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]), + Message.tool({ + id: "call_1", + name: "bash", + resultType: "error", + result: error, + }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error)) + }), + ) + + it.effect("keeps primitive tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("503") + }), + ) + + it.effect("keeps non-JSON tool errors as plain text", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]), + Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }), + ], + }), + ) + + expect(expectToolOutput(prepared.body).output).toBe("Error: boom") + }), + ) + // Regression: screenshot/read tool results must stay structured so base64 // image data is not JSON-stringified into `function_call_output.output`. it.effect("lowers image tool-result content as structured input_image items", () => From 1787fa4261960947a04bfe9367b61dc7f5b2b7ce Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 21:39:41 +0200 Subject: [PATCH 004/554] refactor(core): drop legacy compaction event (#33404) --- packages/core/src/database/migration.gen.ts | 1 + .../20260622170816_reset_v2_session_state.ts | 17 ++++ packages/core/src/session/event.ts | 13 +-- packages/core/src/session/projector.ts | 4 +- packages/core/test/database-migration.test.ts | 92 +++++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 2 +- packages/sdk/openapi.json | 2 +- specs/v2/schema-changelog.md | 8 +- specs/v2/session.md | 2 +- 9 files changed, 122 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index fd778414aa9d..19b1b5684325 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -38,5 +38,6 @@ export const migrations = ( import("./migration/20260611192811_lush_chimera"), import("./migration/20260612174303_project_dir_strategy"), import("./migration/20260622142730_simplify_session_context_epoch"), + import("./migration/20260622170816_reset_v2_session_state"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts new file mode 100644 index 000000000000..b771a64bb745 --- /dev/null +++ b/packages/core/src/database/migration/20260622170816_reset_v2_session_state.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260622170816_reset_v2_session_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_context_epoch\`;`) + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`) + yield* tx.run(`DELETE FROM \`workspace\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index 97e334617620..d4b773d18bf7 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -436,20 +436,9 @@ export namespace Compaction { }) export type Delta = typeof Delta.Type - // Retain the unpublished v1 decoder so stored beta events remain replayable. - export const EndedV1 = EventV2.define({ - type: "session.next.compaction.ended", - ...options, - schema: { - ...Base, - text: Schema.String, - include: Schema.String.pipe(Schema.optional), - }, - }) - export const Ended = EventV2.define({ type: "session.next.compaction.ended", - durable: { aggregate: "sessionID", version: 2 }, + ...options, schema: { ...Base, messageID: SessionMessageID.ID, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 50bfad465a50..2c87dfb8dffb 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -417,9 +417,7 @@ export const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event)) // yield* events.project(SessionEvent.Retried, (event) => run(db, event)) - yield* events.project(SessionEvent.Compaction.Ended, (event) => - event.durable?.version === 1 ? Effect.void : run(db, event), - ) + yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) }), ) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 914243a5899c..835f41931d60 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -14,6 +14,8 @@ import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/m import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" +import resetV2SessionStateMigration from "@opencode-ai/core/database/migration/20260622170816_reset_v2_session_state" +import { EventV2 } from "@opencode-ai/core/event" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -22,6 +24,8 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionV1 } from "@opencode-ai/core/v1/session" import { tmpdir } from "./fixture/tmpdir" const run = (effect: Effect.Effect) => @@ -226,6 +230,94 @@ describe("DatabaseMigration", () => { ) }) + test("preserves canonical V1 state and restarts its event stream", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`PRAGMA foreign_keys = ON`) + yield* DatabaseMigration.apply(db) + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/project', 1, 1, '[]')`, + ) + yield* db.run( + sql`INSERT INTO workspace (id, type, project_id, time_used) VALUES ('workspace', 'local', 'global', 1)`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, workspace_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'global', 'workspace', 'session', '/project', 'Before', 'test', 1, 1)`, + ) + yield* db.run( + sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('message', 'session', 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('part', 'message', 'session', 1, 1, '{}')`, + ) + yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`) + yield* db.run( + sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('event', 'session', 9, 'session.updated.1', '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, + ) + yield* db.run( + sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, + ) + yield* db.run( + sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`, + ) + yield* db.run(sql`DELETE FROM migration WHERE id = ${resetV2SessionStateMigration.id}`) + yield* DatabaseMigration.applyOnly(db, [resetV2SessionStateMigration]) + + const database = Layer.succeed(Database.Service, { db }) + const events = EventV2.layer.pipe(Layer.provide(database)) + yield* EventV2.Service.use((service) => + service.publish(SessionV1.Event.Updated, { + sessionID: SessionSchema.ID.make("session"), + info: { + id: SessionSchema.ID.make("session"), + slug: "session", + projectID: ProjectV2.ID.global, + directory: "/project", + title: "After", + version: "test", + time: { created: 1, updated: 2 }, + }, + }), + ).pipe( + Effect.provide( + Layer.merge(events, SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))), + ), + ) + + expect( + yield* db.get(sql` + SELECT + (SELECT title FROM session WHERE id = 'session') AS title, + (SELECT workspace_id FROM session WHERE id = 'session') AS workspaceID, + (SELECT COUNT(*) FROM message WHERE id = 'message') AS messages, + (SELECT COUNT(*) FROM part WHERE id = 'part') AS parts, + (SELECT COUNT(*) FROM workspace) AS workspaces, + (SELECT COUNT(*) FROM session_input) AS sessionInputs, + (SELECT COUNT(*) FROM session_message) AS sessionMessages, + (SELECT COUNT(*) FROM session_context_epoch) AS contextEpochs, + (SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq, + (SELECT type FROM event WHERE aggregate_id = 'session') AS eventType + `), + ).toEqual({ + title: "After", + workspaceID: null, + messages: 1, + parts: 1, + workspaces: 0, + sessionInputs: 0, + sessionMessages: 0, + contextEpochs: 0, + seq: 0, + eventType: "session.updated.1", + }) + }), + ) + }) + test("resets incompatible projected Session messages before adding sequence order", async () => { await run( Effect.gen(function* () { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e27957f88fe1..b15ff9347095 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3637,7 +3637,7 @@ export type SyncEventSessionNextCompactionEnded = { type: "sync" id: string syncEvent: { - type: "session.next.compaction.ended.2" + type: "session.next.compaction.ended.1" id: string seq: number aggregateID: string diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7501fc4b907a..0d8dc1aecfe2 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -25744,7 +25744,7 @@ "properties": { "type": { "type": "string", - "enum": ["session.next.compaction.ended.2"] + "enum": ["session.next.compaction.ended.1"] }, "id": { "type": "string", diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index f32948c82b80..6d9c0efddbc8 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,11 @@ # V2 Schema Changelog +## 2026-06-22: Reset Unpublished Compaction Event + +- Replace the unpublished `session.next.compaction.ended.1` payload with the current checkpoint payload and remove its legacy decoder. +- Reset experimental events, sequences, Session inputs, projected Session messages, Context Epochs, synchronized workspace rows, and Session workspace links. +- Preserve canonical V1 `session`, `message`, and `part` rows. + ## 2026-06-22: Make Session Interruption Process-Local - Remove the unprojected `session.next.interrupt.requested.1` event from the experimental durable Session event union and generated SDK. @@ -11,7 +17,7 @@ - Preserve the existing structured summary contract and update prior summaries with newly compacted history. - Store token-bounded recent history as plain serialized text inside the checkpoint instead of replaying provider-native messages. - Keep compaction starts durable and progress deltas live-only; activate history cutover only from a durable completed summary. -- Version the completed event as `session.next.compaction.ended.2` rather than changing the existing synchronized v1 payload in place. +- Store the completed event with the current checkpoint payload containing stable message identity, reason, summary, and recent context. - Reload the replacement Context Epoch and continue the original pending turn after compaction. - Preserve full durable history; compaction changes only the active model representation. - Defer provider-overflow recovery, explicit manual compaction, and deterministic old tool-result pruning. diff --git a/specs/v2/session.md b/specs/v2/session.md index ea22e6008e91..9946758322ea 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -107,7 +107,7 @@ Before each provider turn, the runner estimates the complete model-visible reque Compaction keeps the full transcript durable while replacing its active model representation with one hidden checkpoint containing a structured rolling summary and token-bounded serialized recent context. Provider-native assistant, reasoning, and tool messages never survive across the boundary, avoiding signature and encrypted-reasoning failures when the earlier prefix changes. -`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.2` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next provider attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active. +`session.next.compaction.started.1` durably identifies the attempt. Compaction deltas are live-only progress. `session.next.compaction.ended.1` durably stores the final summary and serialized recent context; only this completed event projects a model-visible compaction message. On the next provider attempt, the runner observes that completed compaction and directly renders a fresh Context Epoch baseline. A failed or interrupted attempt therefore leaves the previous history boundary active. Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn. From a0a500316ee009b5f84f593ec6b304e737224ad7 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 22 Jun 2026 15:33:56 -0400 Subject: [PATCH 005/554] zen: new inference --- packages/console/app/src/routes/zen/util/handler.ts | 7 +++++++ packages/console/core/src/model.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index e34c3e750b8d..46a0f17fcd78 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -196,6 +196,13 @@ export async function handler( Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => { headers.set(k, headers.get(v)!) }) + Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => { + if (v === "$ip") return headers.set(k, ip) + if (v === "$session") return headers.set(k, sessionId) + if (v === "$model") return headers.set(k, model) + if (v === "$request") return headers.set(k, requestId) + headers.set(k, v) + }) headers.delete("host") headers.delete("content-length") headers.delete("x-opencode-request") diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 4355c18818a2..bd18d44503ad 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -53,6 +53,7 @@ export namespace ZenData { apiKey: z.union([z.string(), z.record(z.string(), z.string())]), format: FormatSchema.optional(), headerMappings: z.record(z.string(), z.string()).optional(), + headerModifier: z.record(z.string(), z.any()).optional(), payloadModifier: z.record(z.string(), z.any()).optional(), payloadMappings: z.record(z.string(), z.string()).optional(), adjustCacheUsage: z.boolean().optional(), From 34b3d59a23eb979d1d66a8f2ede1dae6a33c277a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:52:43 -0500 Subject: [PATCH 006/554] ignore: update agents.md (#33446) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 02b1c4cb7725..1557a7f5601f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,7 +137,7 @@ const table = sqliteTable("session", { ## Testing -- Avoid mocks as much as possible +- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option. - Test actual implementation, do not duplicate logic into tests - Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`. From f48f24ec4e1e26cc32c4d4953497fe2734c61ee1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 22 Jun 2026 23:51:49 +0200 Subject: [PATCH 007/554] refactor(core): simplify session input promotion (#33443) --- packages/core/src/database/migration.gen.ts | 1 + .../20260622202450_simplify_session_input.ts | 17 ++ packages/core/src/event.ts | 13 ++ packages/core/src/session/context-epoch.ts | 4 +- packages/core/src/session/event.ts | 47 ++-- packages/core/src/session/input.ts | 111 ++++------ packages/core/src/session/message-updater.ts | 1 - packages/core/src/session/projector.ts | 33 +-- packages/core/src/session/runner/llm.ts | 2 +- packages/core/test/database-migration.test.ts | 6 +- packages/core/test/session-create.test.ts | 6 +- packages/core/test/session-projector.test.ts | 11 +- packages/core/test/session-prompt.test.ts | 35 ++- .../core/test/session-runner-recorded.test.ts | 2 +- packages/core/test/session-runner.test.ts | 6 +- packages/sdk/js/src/v2/gen/types.gen.ts | 65 ------ packages/sdk/openapi.json | 208 ------------------ packages/tui/src/context/data.tsx | 12 - packages/tui/test/cli/tui/data.test.tsx | 58 +---- specs/v2/schema-changelog.md | 7 + specs/v2/session.md | 8 +- 21 files changed, 160 insertions(+), 493 deletions(-) create mode 100644 packages/core/src/database/migration/20260622202450_simplify_session_input.ts diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 19b1b5684325..e6ea4eaa1477 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -39,5 +39,6 @@ export const migrations = ( import("./migration/20260612174303_project_dir_strategy"), import("./migration/20260622142730_simplify_session_context_epoch"), import("./migration/20260622170816_reset_v2_session_state"), + import("./migration/20260622202450_simplify_session_input"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260622202450_simplify_session_input.ts b/packages/core/src/database/migration/20260622202450_simplify_session_input.ts new file mode 100644 index 000000000000..0b5ddd1bfde3 --- /dev/null +++ b/packages/core/src/database/migration/20260622202450_simplify_session_input.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260622202450_simplify_session_input", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(`DELETE FROM \`session_context_epoch\`;`) + yield* tx.run(`DELETE FROM \`session_input\`;`) + yield* tx.run(`DELETE FROM \`session_message\`;`) + yield* tx.run(`DELETE FROM \`event\`;`) + yield* tx.run(`DELETE FROM \`event_sequence\`;`) + yield* tx.run(`UPDATE \`session\` SET \`workspace_id\` = NULL WHERE \`workspace_id\` IS NOT NULL;`) + yield* tx.run(`DELETE FROM \`workspace\`;`) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 32aaeae6995c..3439a0aefc85 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -46,6 +46,19 @@ export type Payload = { export type Subscriber = (event: Payload) => Effect.Effect export type Unsubscribe = Effect.Effect +export const latestSequence = Effect.fn("EventV2.latestSequence")(function* ( + db: Database.Interface["db"], + aggregateID: string, +) { + const row = yield* db + .select({ seq: EventSequenceTable.seq }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .get() + .pipe(Effect.orDie) + return row?.seq ?? -1 +}) + export type SerializedEvent = { readonly id: ID readonly type: string diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts index 18624706a973..f06b69cd5151 100644 --- a/packages/core/src/session/context-epoch.ts +++ b/packages/core/src/session/context-epoch.ts @@ -64,7 +64,7 @@ const prepareOnce = Effect.fnUntraced(function* ( return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } } if (result._tag === "ReplacementReady") { - const baselineSeq = replacementSeq ?? (yield* SessionInput.latestSeq(db, sessionID)) + const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID)) yield* replace(db, sessionID, baselineSeq, result.generation) return { baseline: result.generation.baseline, baselineSeq } } @@ -124,7 +124,7 @@ const insert = Effect.fnUntraced(function* ( sessionID: SessionSchema.ID, generation: SystemContext.Generation, ) { - const baselineSeq = yield* SessionInput.latestSeq(db, sessionID) + const baselineSeq = yield* EventV2.latestSequence(db, sessionID) yield* db .insert(SessionContextEpochTable) .values({ diff --git a/packages/core/src/session/event.ts b/packages/core/src/session/event.ts index d4b773d18bf7..88ac4aa2679b 100644 --- a/packages/core/src/session/event.ts +++ b/packages/core/src/session/event.ts @@ -25,6 +25,12 @@ const Base = { timestamp: V2Schema.DateTimeUtcFromMillis, sessionID: SessionSchema.ID, } +const PromptFields = { + ...Base, + messageID: SessionMessageID.ID, + prompt: Prompt, + delivery: Schema.Literals(["steer", "queue"]), +} const options = { durable: { @@ -83,40 +89,16 @@ export type Moved = typeof Moved.Type export const Prompted = EventV2.define({ type: "session.next.prompted", ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - delivery: Schema.Literals(["steer", "queue"]), - }, + schema: PromptFields, }) export type Prompted = typeof Prompted.Type -export namespace PromptLifecycle { - export const Admitted = EventV2.define({ - type: "session.next.prompt.admitted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - delivery: Schema.Literals(["steer", "queue"]), - }, - }) - export type Admitted = typeof Admitted.Type - - export const Promoted = EventV2.define({ - type: "session.next.prompt.promoted", - ...options, - schema: { - ...Base, - messageID: SessionMessageID.ID, - prompt: Prompt, - timeCreated: V2Schema.DateTimeUtcFromMillis, - }, - }) - export type Promoted = typeof Promoted.Type -} +export const PromptAdmitted = EventV2.define({ + type: "session.next.prompt.admitted", + ...options, + schema: PromptFields, +}) +export type PromptAdmitted = typeof PromptAdmitted.Type export const ContextUpdated = EventV2.define({ type: "session.next.context.updated", @@ -455,8 +437,7 @@ const DurableDefinitions = [ ModelSwitched, Moved, Prompted, - PromptLifecycle.Admitted, - PromptLifecycle.Promoted, + PromptAdmitted, ContextUpdated, Synthetic, Shell.Started, diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index f8bc2b0e6bb0..a45a37100376 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -4,7 +4,6 @@ import { and, asc, eq, isNull, lte } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" import type { Database } from "../database/database" import type { EventV2 } from "../event" -import { EventSequenceTable } from "../event/sql" import { NonNegativeInt } from "../schema" import { V2Schema } from "../v2-schema" import { SessionEvent } from "./event" @@ -65,7 +64,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( if (existing !== undefined) return existing const timestamp = yield* DateTime.now return yield* events - .publish(SessionEvent.PromptLifecycle.Admitted, { + .publish(SessionEvent.PromptAdmitted, { messageID: input.id, sessionID: input.sessionID, timestamp, @@ -93,19 +92,6 @@ export const admit = Effect.fn("SessionInput.admit")(function* ( ) }) -export const latestSeq = Effect.fn("SessionInput.latestSeq")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, -) { - const row = yield* db - .select({ seq: EventSequenceTable.seq }) - .from(EventSequenceTable) - .where(eq(EventSequenceTable.aggregate_id, sessionID)) - .get() - .pipe(Effect.orDie) - return row?.seq ?? -1 -}) - export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( db: DatabaseService, input: { @@ -117,6 +103,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio readonly timeCreated: DateTime.Utc }, ) { + const message = yield* db + .select({ id: SessionMessageTable.id }) + .from(SessionMessageTable) + .where(eq(SessionMessageTable.id, input.id)) + .get() + .pipe(Effect.orDie) + if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id })) const stored = yield* db .insert(SessionInputTable) .values({ @@ -134,12 +127,13 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) }) -export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(function* ( +export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(function* ( db: DatabaseService, input: { readonly id: SessionMessage.ID readonly sessionID: SessionSchema.ID readonly prompt: Prompt + readonly delivery: Delivery readonly timeCreated: DateTime.Utc readonly promotedSeq: number }, @@ -157,14 +151,32 @@ export const projectPromoted = Effect.fn("SessionInput.projectPromoted")(functio .returning() .get() .pipe(Effect.orDie) - if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id })) - const stored = fromRow(updated) - if ( - !matchesPrompt(stored, input) || - DateTime.toEpochMillis(stored.timeCreated) !== DateTime.toEpochMillis(input.timeCreated) - ) - return yield* Effect.die(new LifecycleConflict({ id: input.id })) - return toMessage(stored) + if (updated) { + const stored = fromRow(updated) + if (!matchesProjection(stored, input)) return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return + } + + const stored = yield* find(db, input.id) + if (stored) { + if (!matchesProjection(stored, input) || stored.promotedSeq !== input.promotedSeq) + return yield* Effect.die(new LifecycleConflict({ id: input.id })) + return + } + + yield* db + .insert(SessionInputTable) + .values({ + id: input.id, + session_id: input.sessionID, + prompt: encodePrompt(input.prompt), + delivery: input.delivery, + admitted_seq: input.promotedSeq, + promoted_seq: input.promotedSeq, + time_created: DateTime.toEpochMillis(input.timeCreated), + }) + .run() + .pipe(Effect.orDie) }) export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( @@ -201,35 +213,17 @@ const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionS input.sessionID === expected.sessionID && JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) -export const projectLegacyPrompted = Effect.fn("SessionInput.projectLegacyPrompted")(function* ( - db: DatabaseService, - input: { - readonly id: SessionMessage.ID +const matchesProjection = ( + input: Admitted, + expected: { readonly sessionID: SessionSchema.ID readonly prompt: Prompt readonly delivery: Delivery readonly timeCreated: DateTime.Utc - readonly promotedSeq: number }, -) { - const inserted = yield* db - .insert(SessionInputTable) - .values({ - id: input.id, - session_id: input.sessionID, - admitted_seq: input.promotedSeq, - prompt: encodePrompt(input.prompt), - delivery: input.delivery, - promoted_seq: input.promotedSeq, - time_created: DateTime.toEpochMillis(input.timeCreated), - }) - .onConflictDoNothing() - .returning() - .get() - .pipe(Effect.orDie) - if (!inserted) return yield* Effect.die("Prompt projection conflicts with admitted input") - return fromRow(inserted) -}) +) => + equivalent(input, expected) && + DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated) const publish = Effect.fn("SessionInput.publish")(function* ( db: DatabaseService, @@ -238,18 +232,19 @@ const publish = Effect.fn("SessionInput.publish")(function* ( rows: ReadonlyArray, ) { for (const row of rows) { + const id = SessionMessage.ID.make(row.id) yield* events - .publish(SessionEvent.PromptLifecycle.Promoted, { + .publish(SessionEvent.Prompted, { sessionID, - timestamp: yield* DateTime.now, - messageID: SessionMessage.ID.make(row.id), + timestamp: DateTime.makeUnsafe(row.time_created), + messageID: id, prompt: decodePrompt(row.prompt), - timeCreated: DateTime.makeUnsafe(row.time_created), + delivery: row.delivery, }) .pipe( Effect.catchDefect((defect) => defect instanceof LifecycleConflict - ? find(db, SessionMessage.ID.make(row.id)).pipe( + ? find(db, id).pipe( Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)), ) : Effect.die(defect), @@ -303,13 +298,3 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun .pipe(Effect.orDie) return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true)) }) - -const toMessage = (input: Admitted) => - new SessionMessage.User({ - id: input.id, - type: "user", - text: input.prompt.text, - files: input.prompt.files, - agents: input.prompt.agents, - time: { created: input.timeCreated }, - }) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 2c836dcd0173..4ece3c2d1954 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -137,7 +137,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }, "session.next.prompt.admitted": () => Effect.void, - "session.next.prompt.promoted": () => Effect.void, "session.next.context.updated": (event) => adapter.appendMessage( new SessionMessage.System({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 2c87dfb8dffb..4a0512d4759d 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -21,7 +21,6 @@ type DatabaseService = Database.Interface["db"] const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) -class PromptAlreadyProjected extends Error {} export class SessionAlreadyProjected extends Error {} type Usage = { @@ -350,27 +349,19 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.Prompted, (event) => Effect.gen(function* () { - const messageID = event.data.messageID - const existing = yield* db - .select({ id: SessionMessageTable.id }) - .from(SessionMessageTable) - .where(eq(SessionMessageTable.id, messageID)) - .get() - .pipe(Effect.orDie) - if (existing) return yield* Effect.die(new PromptAlreadyProjected()) - yield* run(db, event) if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") - yield* SessionInput.projectLegacyPrompted(db, { - id: messageID, + yield* SessionInput.projectPrompted(db, { + id: event.data.messageID, sessionID: event.data.sessionID, prompt: event.data.prompt, delivery: event.data.delivery, timeCreated: event.data.timestamp, promotedSeq: event.durable.seq, }) + yield* run(db, event) }), ) - yield* events.project(SessionEvent.PromptLifecycle.Admitted, (event) => + yield* events.project(SessionEvent.PromptAdmitted, (event) => Effect.gen(function* () { if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") yield* SessionInput.projectAdmitted(db, { @@ -383,22 +374,6 @@ export const layer = Layer.effectDiscard( }) }), ) - yield* events.project(SessionEvent.PromptLifecycle.Promoted, (event) => - Effect.gen(function* () { - if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") - yield* insertMessage( - db, - event, - yield* SessionInput.projectPromoted(db, { - id: event.data.messageID, - sessionID: event.data.sessionID, - prompt: event.data.prompt, - timeCreated: event.data.timeCreated, - promotedSeq: event.durable.seq, - }), - ) - }), - ) yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index ddd2bf4e152a..9caae3849472 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -176,7 +176,7 @@ export const layer = Layer.effect( const toolFibers = yield* FiberSet.make() let needsContinuation = false if (promotion) { - const cutoff = yield* SessionInput.latestSeq(db, session.id) + const cutoff = yield* EventV2.latestSequence(db, session.id) if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff) if (promotion === "queue") { yield* SessionInput.promoteNextQueued(db, events, session.id) diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 835f41931d60..63768719355c 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -14,7 +14,7 @@ import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/m import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input" import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" -import resetV2SessionStateMigration from "@opencode-ai/core/database/migration/20260622170816_reset_v2_session_state" +import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" import { EventV2 } from "@opencode-ai/core/event" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -264,8 +264,8 @@ describe("DatabaseMigration", () => { yield* db.run( sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('session', 'baseline', '{}', 9)`, ) - yield* db.run(sql`DELETE FROM migration WHERE id = ${resetV2SessionStateMigration.id}`) - yield* DatabaseMigration.applyOnly(db, [resetV2SessionStateMigration]) + yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionInputMigration.id}`) + yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration]) const database = Layer.succeed(Database.Service, { db }) const events = EventV2.layer.pipe(Layer.provide(database)) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 6fd80c60da1d..1ae6c011fc4a 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -225,7 +225,7 @@ describe("SessionV2.create", () => { Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(2), Stream.runCollect)), ).toMatchObject([ { durable: { seq: 1 }, type: "session.next.prompt.admitted", data: { prompt: { text: "Hello" } } }, - { durable: { seq: 2 }, type: "session.next.prompt.promoted" }, + { durable: { seq: 2 }, type: "session.next.prompted" }, ]) }), ) @@ -308,8 +308,8 @@ describe("SessionV2.create", () => { .pipe(Effect.orDie)).map((event) => [event.seq, event.type]), ).toEqual([ [0, EventV2.versionedType(SessionV1.Event.Created.type, 1)], - [1, EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1)], - [2, EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1)], + [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], + [2, EventV2.versionedType(SessionEvent.Prompted.type, 1)], ]) }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore)))) }), diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index a0894d07eb0f..76c3290a7078 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -120,7 +120,7 @@ describe("SessionProjector", () => { ), ) - it.effect("marks an admitted lifecycle row promoted with the PromptPromoted event sequence", () => + it.effect("marks an inbox row promoted with the Prompted event sequence", () => Effect.gen(function* () { const { db } = yield* Database.Service yield* db @@ -142,19 +142,20 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_admitted") - yield* SessionInput.admit(db, events, { + const admitted = yield* SessionInput.admit(db, events, { id, sessionID, prompt: new Prompt({ text: "promote me" }), delivery: "steer", }) + if (!admitted) return yield* Effect.die("Prompt admission failed") - const event = yield* events.publish(SessionEvent.PromptLifecycle.Promoted, { + const event = yield* events.publish(SessionEvent.Prompted, { sessionID, - timestamp: created, + timestamp: admitted.timeCreated, messageID: id, prompt: new Prompt({ text: "promote me" }), - timeCreated: created, + delivery: "steer", }) expect( diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 166b5deed12c..842474396e27 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -179,8 +179,8 @@ describe("SessionV2.prompt", () => { expect(streamed.map((event) => [event.durable?.seq, event.type])).toEqual([ [0, "session.next.prompt.admitted"], [1, "session.next.prompt.admitted"], - [2, "session.next.prompt.promoted"], - [3, "session.next.prompt.promoted"], + [2, "session.next.prompted"], + [3, "session.next.prompted"], ]) expect( Array.from( @@ -334,7 +334,7 @@ describe("SessionV2.prompt", () => { expect(messages[1]).toEqual(messages[0]) expect(yield* session.messages({ sessionID })).toEqual([]) expect(yield* admittedCount).toBe(1) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Admitted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1))).toBe(1) }), ) @@ -354,7 +354,7 @@ describe("SessionV2.prompt", () => { { concurrency: "unbounded" }, ) - expect(yield* eventCount(EventV2.versionedType(SessionEvent.PromptLifecycle.Promoted.type, 1))).toBe(1) + expect(yield* eventCount(EventV2.versionedType(SessionEvent.Prompted.type, 1))).toBe(1) expect(yield* admitted(messageID)).toMatchObject({ promotedSeq: 1 }) expect(yield* session.messages({ sessionID })).toMatchObject([ { id: messageID, type: "user", text: "Promote once" }, @@ -362,14 +362,14 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("promotes steers only through the captured aggregate cutoff", () => + it.effect("promotes steers only through the captured inbox cutoff", () => Effect.gen(function* () { yield* setup const { db } = yield* Database.Service const session = yield* SessionV2.Service const events = yield* EventV2.Service const first = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Before cutoff" }), resume: false }) - const cutoff = yield* SessionInput.latestSeq(db, sessionID) + const cutoff = first.admittedSeq const second = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "After cutoff" }), resume: false }) yield* SessionInput.promoteSteers(db, events, sessionID, cutoff) @@ -379,7 +379,7 @@ describe("SessionV2.prompt", () => { }), ) - it.effect("reprojects one pending lifecycle without scheduling execution", () => + it.effect("reprojects pending inbox input without scheduling execution", () => Effect.gen(function* () { yield* setup const { db } = yield* Database.Service @@ -489,6 +489,27 @@ describe("SessionV2.prompt", () => { }), ) + it.effect("rejects a prompt ID already used by visible Session history", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + yield* events.publish(SessionEvent.Synthetic, { + sessionID, + messageID, + timestamp: yield* DateTime.now, + text: "Existing history", + }) + + const failure = yield* session + .prompt({ id: messageID, sessionID, prompt: new Prompt({ text: "Conflicting prompt" }), resume: false }) + .pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID }) + expect(yield* admitted(messageID)).toBeUndefined() + }), + ) + it.effect("starts execution by default after recording the prompt", () => Effect.gen(function* () { yield* setup diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 65e90cb6d093..331c5bb48c17 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -176,7 +176,7 @@ describe("SessionRunnerLLM recorded", () => { .all()).map((event) => event.type), ).toEqual([ "session.next.prompt.admitted.1", - "session.next.prompt.promoted.1", + "session.next.prompted.1", "session.next.step.started.1", "session.next.text.started.1", "session.next.text.ended.1", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6e97ab7939fd..ec932c904bc1 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2404,7 +2404,7 @@ describe("SessionRunnerLLM", () => { const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true - yield* events.project(SessionEvent.PromptLifecycle.Promoted, () => (fail ? Effect.die(defect) : Effect.void)) + yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void)) yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Recover promoted input" }), resume: false }) expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) @@ -2429,9 +2429,7 @@ describe("SessionRunnerLLM", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service yield* events.listen((event) => - event.type === SessionEvent.PromptLifecycle.Promoted.type - ? Effect.die("fail after prompt promotion commits") - : Effect.void, + event.type === SessionEvent.Prompted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, ) yield* session.prompt({ sessionID, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b15ff9347095..f71b010420d8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -20,7 +20,6 @@ export type Event = | EventSessionNextMoved | EventSessionNextPrompted | EventSessionNextPromptAdmitted - | EventSessionNextPromptPromoted | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextShellStarted @@ -864,17 +863,6 @@ export type GlobalEvent = { delivery: "steer" | "queue" } } - | { - id: string - type: "session.next.prompt.promoted" - properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } - } | { id: string type: "session.next.context.updated" @@ -1628,7 +1616,6 @@ export type GlobalEvent = { | SyncEventSessionNextMoved | SyncEventSessionNextPrompted | SyncEventSessionNextPromptAdmitted - | SyncEventSessionNextPromptPromoted | SyncEventSessionNextContextUpdated | SyncEventSessionNextSynthetic | SyncEventSessionNextShellStarted @@ -2770,7 +2757,6 @@ export type V2Event = | V2EventSessionNextMoved | V2EventSessionNextPrompted | V2EventSessionNextPromptAdmitted - | V2EventSessionNextPromptPromoted | V2EventSessionNextContextUpdated | V2EventSessionNextSynthetic | V2EventSessionNextShellStarted @@ -3220,24 +3206,6 @@ export type SyncEventSessionNextPromptAdmitted = { } } -export type SyncEventSessionNextPromptPromoted = { - type: "sync" - id: string - syncEvent: { - type: "session.next.prompt.promoted.1" - id: string - seq: number - aggregateID: string - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } - } -} - export type SyncEventSessionNextContextUpdated = { type: "sync" id: string @@ -4523,27 +4491,6 @@ export type V2EventSessionNextPromptAdmitted = { } } -export type V2EventSessionNextPromptPromoted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.prompt.promoted" - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } -} - export type V2EventSessionNextContextUpdated = { id: string metadata?: { @@ -6168,18 +6115,6 @@ export type EventSessionNextPromptAdmitted = { } } -export type EventSessionNextPromptPromoted = { - id: string - type: "session.next.prompt.promoted" - properties: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - timeCreated: number - } -} - export type EventSessionNextContextUpdated = { id: string type: "session.next.context.updated" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0d8dc1aecfe2..0f8f5ca1980d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -14686,9 +14686,6 @@ { "$ref": "#/components/schemas/EventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/EventSessionNextPromptPromoted" - }, { "$ref": "#/components/schemas/EventSessionNextContextUpdated" }, @@ -17255,45 +17252,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, { "type": "object", "properties": { @@ -19832,9 +19790,6 @@ { "$ref": "#/components/schemas/SyncEventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/SyncEventSessionNextPromptPromoted" - }, { "$ref": "#/components/schemas/SyncEventSessionNextContextUpdated" }, @@ -23073,9 +23028,6 @@ { "$ref": "#/components/schemas/V2EventSessionNextPromptAdmitted" }, - { - "$ref": "#/components/schemas/V2EventSessionNextPromptPromoted" - }, { "$ref": "#/components/schemas/V2EventSessionNextContextUpdated" }, @@ -24400,66 +24352,6 @@ "required": ["type", "id", "syncEvent"], "additionalProperties": false }, - "SyncEventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["sync"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "syncEvent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted.1"] - }, - "id": { - "type": "string", - "pattern": "^evt_" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["type", "id", "seq", "aggregateID", "data"], - "additionalProperties": false - } - }, - "required": ["type", "id", "syncEvent"], - "additionalProperties": false - }, "SyncEventSessionNextContextUpdated": { "type": "object", "properties": { @@ -28622,67 +28514,6 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, "V2EventSessionNextContextUpdated": { "type": "object", "properties": { @@ -33282,45 +33113,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextPromptPromoted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.promoted"] - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "timeCreated": { - "type": "number" - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "timeCreated"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventSessionNextContextUpdated": { "type": "object", "properties": { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 05cf4afbebc6..9b2e58907ad9 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -164,18 +164,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } case "session.next.prompt.admitted": break - case "session.next.prompt.promoted": - message.update(event.data.sessionID, (draft) => { - message.prepend(draft, { - id: event.data.messageID, - type: "user", - text: event.data.prompt.text, - files: event.data.prompt.files, - agents: event.data.prompt.agents, - time: { created: event.data.timeCreated }, - }) - }) - break case "session.next.context.updated": message.update(event.data.sessionID, (draft) => { message.prepend(draft, { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 0d6ada4d161c..279ba4d065c4 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -370,7 +370,7 @@ test("settles pending tools when a live failure arrives", async () => { } }) -test("renders admitted prompts only after promotion", async () => { +test("renders admitted prompts only after they become model-visible", async () => { const events = createEventSource() const calls = createFetch(undefined, events) let sync!: ReturnType @@ -413,14 +413,14 @@ test("renders admitted prompts only after promotion", async () => { expect(sync.session.message.list("session-1") ?? []).toEqual([]) emitEvent(events, { - id: "evt_promoted_1", - type: "session.next.prompt.promoted", + id: "evt_prompted_1", + type: "session.next.prompted", properties: { sessionID: "session-1", messageID: "msg_user_1", - timestamp: 1, + timestamp: 0, prompt: { text: "hello" }, - timeCreated: 0, + delivery: "steer", }, }) @@ -434,54 +434,6 @@ test("renders admitted prompts only after promotion", async () => { } }) -test("renders a promoted prompt when admission was missed", async () => { - const events = createEventSource() - const calls = createFetch(undefined, events) - let sync!: ReturnType - let ready!: () => void - const mounted = new Promise((resolve) => { - ready = resolve - }) - - function Probe() { - sync = useData() - onMount(ready) - return - } - - const app = await testRender(() => ( - - - - - - - - - - )) - - try { - await mounted - emitEvent(events, { - id: "evt_promoted_1", - type: "session.next.prompt.promoted", - properties: { - sessionID: "session-1", - messageID: "msg_user_1", - timestamp: 1, - prompt: { text: "hello" }, - timeCreated: 0, - }, - }) - - await wait(() => sync.session.message.list("session-1")?.length === 1) - expect(sync.session.message.list("session-1")?.[0]?.id).toBe("msg_user_1") - } finally { - app.renderer.destroy() - } -}) - test("projects live context updates with their message ID", async () => { const events = createEventSource() const calls = createFetch(undefined, events) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 6d9c0efddbc8..bdd483715627 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,12 @@ # V2 Schema Changelog +## 2026-06-22: Simplify Session Input Promotion + +- Keep `session.next.prompt.admitted.1` as the durable, client-visible record of pending Session input. +- Replace `session.next.prompt.promoted.1` with the existing `session.next.prompted.1` event when input becomes model-visible. +- Preserve the prompt endpoint, admission receipt, idempotency, steer/queue ordering, and atomic user-message projection. +- Reset experimental V2 events, projections, inputs, Context Epochs, and synchronized workspace state while preserving canonical V1 `session`, `message`, and `part` rows. + ## 2026-06-22: Reset Unpublished Compaction Event - Replace the unpublished `session.next.compaction.ended.1` payload with the current checkpoint payload and remove its legacy decoder. diff --git a/specs/v2/session.md b/specs/v2/session.md index 9946758322ea..6788a893eafa 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -12,8 +12,8 @@ sessions.create({ id?, location, ... }) sessions.prompt({ id?, sessionID, prompt, delivery?, resume? }) -> omitted ID generates one internal message ID - -> supplied ID admits one durable Session input when absent - -> exact reuse returns the same admitted lifecycle receipt + -> supplied ID inserts one durable Session inbox row when absent + -> exact reuse returns the same admission receipt -> reusing one message ID for another Session, prompt, or delivery mode fails -> exact retry schedules another wake unless resume is false -> resume omitted or true schedules execution after admission @@ -27,7 +27,9 @@ sessions.interrupt(sessionID) -> idle or missing Session is a no-op ``` -`session_input` is the durable admission inbox. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `PromptLifecycle.Promoted`. The projector atomically writes the visible user message and marks its inbox row promoted in the same event transaction. The legacy V1-to-V2 shadow bridge continues publishing ordinary `Prompted` events for already-visible V1 prompts. +`session_input` is the durable admission inbox. `PromptAdmitted` records and projects accepted input so pending queue state can be replayed, replicated, and observed by clients. Admitted inputs remain outside model-visible Session history until the serialized runner publishes `Prompted`. Its projector atomically writes the visible user message and marks the inbox row promoted in the same event transaction. The V1-to-V2 shadow bridge publishes the same `Prompted` event for already-visible V1 prompts. + +`admittedSeq` is the durable Session event sequence of `PromptAdmitted`. Clients may use the admission event to represent queued input before `Prompted` makes it part of visible conversation history. Execution routing starts from only the Session ID: From dc468bdcfd92b120a2e54493c63994549fa8f11a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 23 Jun 2026 01:01:14 +0200 Subject: [PATCH 008/554] fix(core): reset steps for promoted prompts (#33452) --- AGENTS.md | 4 +- CONTEXT.md | 17 +++ packages/core/src/session/runner/llm.ts | 53 ++++---- packages/core/test/session-runner.test.ts | 145 +++++++++++----------- packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 +- packages/sdk/openapi.json | 4 +- packages/server/src/groups/session.ts | 4 +- specs/v2/schema-changelog.md | 2 +- specs/v2/session.md | 12 +- specs/v2/todo.md | 19 +-- 10 files changed, 143 insertions(+), 121 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1557a7f5601f..4c6be738db56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,7 +152,7 @@ const table = sqliteTable("session", { - Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op. - Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. - Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop. -- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work. -- Keep delivery vocabulary explicit. Prompts steer by default and coalesce into the active activity at the next safe provider-turn boundary. Explicit `queue` inputs open FIFO future activities one at a time after the active activity settles. +- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. +- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. - Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. diff --git a/CONTEXT.md b/CONTEXT.md index faf8ce9d125f..7fe7f3fc8dac 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,6 +39,18 @@ An expected temporary inability to observe a **Context Source** value; the runti **Safe Provider-Turn Boundary**: The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. +**Admitted Prompt**: +A durable user input accepted into the Session inbox but not yet included in **Session History**. + +**Prompt Promotion**: +The durable transition that removes an **Admitted Prompt** from pending input and appends its user message to **Session History**. + +**Provider Turn**: +One request to a model provider and the response projected from that request. + +**Session Drain**: +One process-local execution span that promotes eligible input and runs required **Provider Turns** until no immediate continuation remains. A Session Drain has no durable identity or transcript boundary. + **Model Tool Output**: The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit. @@ -67,6 +79,11 @@ The host-supplied environment overlay applied by the server when creating a PTY, - Changes from multiple **Context Sources** admitted at one safe boundary combine into one **Mid-Conversation System Message**. - Context changes are sampled and admitted lazily at a **Safe Provider-Turn Boundary**, never pushed asynchronously when their source changes. - At a **Safe Provider-Turn Boundary**, newly promoted user input or settled tool results precede any combined **Mid-Conversation System Message**. +- An **Admitted Prompt** is replayable pending input, not yet model-visible **Session History**. +- **Prompt Promotion** atomically consumes the pending inbox entry and appends its model-visible user message. +- Steering prompts promote at the next **Safe Provider-Turn Boundary** while the current **Session Drain** still requires continuation. Promoting any newly admitted user input resets the selected agent's provider-turn allowance; multiple prompts promoted at one boundary reset it once. +- A queued prompt does not promote while the current **Session Drain** requires continuation. The runner promotes one queued prompt when the Session would otherwise become idle, then reevaluates continuation before promoting another. +- A **Session Drain** is process-local coordination rather than a durable domain entity. Durable recovery must reason from prompts, projected history, provider attempts, and tool state rather than inventing an enclosing execution identity. - The first provider turn renders the latest complete **Baseline System Context** and initializes its **Context Snapshot** without emitting a redundant **Mid-Conversation System Message**; unavailable initial context blocks the turn instead of persisting an incomplete baseline. - Initial **System Context** preparation precedes the first durable input promotion so an unavailable baseline leaves that input pending and retryable; ordinary reconciliation remains after promotion. - Compaction starts a new **Context Epoch** with a freshly rendered **Baseline System Context** and **Context Snapshot**; prior **Mid-Conversation System Messages** remain durable audit history but leave projected model history. diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 9caae3849472..2cd77beb0716 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -79,7 +79,7 @@ import { MAX_STEPS_PROMPT } from "./max-steps" * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * * Use `llm.stream(request)` for each provider turn. Keep tool execution and continuation here. - * Durable activity recovery remains a separate future slice with an explicit retry policy. + * Durable continuation recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an @@ -142,9 +142,9 @@ export const layer = Layer.effect( type TurnTransition = // Automatic compaction completed; rebuild the request from compacted history. - | { readonly _tag: "ContinueAfterCompaction" } + | { readonly _tag: "ContinueAfterCompaction"; readonly step: number } // Overflow compaction completed; rebuild once through the path without overflow recovery. - | { readonly _tag: "ContinueAfterOverflowCompaction" } + | { readonly _tag: "ContinueAfterOverflowCompaction"; readonly step: number } class TurnTransitionError extends Error { constructor(readonly transition: TurnTransition) { @@ -152,10 +152,9 @@ export const layer = Layer.effect( } } - const continueAfterCompaction = new TurnTransitionError({ _tag: "ContinueAfterCompaction" }) - const continueAfterOverflowCompaction = new TurnTransitionError({ - _tag: "ContinueAfterOverflowCompaction", - }) + const continueAfterCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterCompaction", step }) + const continueAfterOverflowCompaction = (step: number) => + new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) const loadSystemContext = (agent: AgentV2.Selection) => Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { @@ -175,20 +174,23 @@ export const layer = Layer.effect( const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) const toolFibers = yield* FiberSet.make() let needsContinuation = false + let currentStep = step if (promotion) { const cutoff = yield* EventV2.latestSequence(db, session.id) - if (promotion === "steer") yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + let promoted = 0 + if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff) if (promotion === "queue") { - yield* SessionInput.promoteNextQueued(db, events, session.id) - yield* SessionInput.promoteSteers(db, events, session.id, cutoff) + promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id)) + promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff) } + if (promoted > 0) currentStep = 1 } const system = initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) - const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps + const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ @@ -202,7 +204,7 @@ export const layer = Layer.effect( toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) - return yield* Effect.die(continueAfterCompaction) + return yield* Effect.die(continueAfterCompaction(currentStep)) const publisher = createLLMEventPublisher(events, { sessionID: session.id, agent: agent.id, @@ -272,7 +274,7 @@ export const layer = Layer.effect( isContextOverflowFailure(overflowFailure ?? failure) && (yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request }))) ) - return yield* Effect.die(continueAfterOverflowCompaction) + return yield* Effect.die(continueAfterOverflowCompaction(currentStep)) if (overflowFailure) yield* publish(overflowFailure) const llmFailure = failure instanceof LLMError ? failure : undefined if (llmFailure && !publisher.hasProviderError()) { @@ -306,7 +308,7 @@ export const layer = Layer.effect( yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause) - return !publisher.hasProviderError() && needsContinuation + return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep } }), ) }, Effect.scoped) @@ -314,7 +316,7 @@ export const layer = Layer.effect( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, step: number, - ) => Effect.Effect + ) => Effect.Effect<{ readonly needsContinuation: boolean; readonly step: number }, RunError> const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { return yield* runTurnAttempt(sessionID, promotion, step).pipe( @@ -324,7 +326,7 @@ export const layer = Layer.effect( if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") yield* Effect.yieldNow - return yield* runAfterOverflowCompaction(sessionID, undefined, step) + return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) }), ), ) @@ -337,8 +339,8 @@ export const layer = Layer.effect( if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") - return yield* runAfterOverflowCompaction(sessionID, undefined, step) - return yield* runTurn(sessionID, undefined, step) + return yield* runAfterOverflowCompaction(sessionID, undefined, defect.transition.step) + return yield* runTurn(sessionID, undefined, defect.transition.step) }), ), ) @@ -353,16 +355,19 @@ export const layer = Layer.effect( if (!input.force && !hasSteer && !hasQueue) return yield* failInterruptedTools(input.sessionID) let promotion: SessionInput.Delivery | undefined = hasSteer ? "steer" : hasQueue ? "queue" : undefined - let openActivity = input.force || hasSteer || hasQueue - while (openActivity) { + let shouldRun = input.force || hasSteer || hasQueue + while (shouldRun) { let needsContinuation = true - for (let step = 1; needsContinuation; step++) { - needsContinuation = yield* runTurn(input.sessionID, promotion, step) + let step = 1 + while (needsContinuation) { + const result = yield* runTurn(input.sessionID, promotion, step) + needsContinuation = result.needsContinuation + step = result.step + 1 promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") } - openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") - promotion = openActivity ? "queue" : undefined + shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue") + promotion = shouldRun ? "queue" : undefined } }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ec932c904bc1..572c599be774 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1851,7 +1851,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("starts queued input after the active activity settles", () => + it.effect("promotes queued input after continuation ends", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1883,7 +1883,7 @@ describe("SessionRunnerLLM", () => { yield* Deferred.await(streamStarted) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait until the next activity" }), + prompt: new Prompt({ text: "Wait until continuation ends" }), delivery: "queue", }) yield* Deferred.succeed(streamGate, undefined) @@ -1894,7 +1894,7 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(3) expect(userTexts(requests[0]!)).toEqual(["Start working"]) expect(userTexts(requests[1]!)).toEqual(["Start working"]) - expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until the next activity"]) + expect(userTexts(requests[2]!)).toEqual(["Start working", "Wait until continuation ends"]) }), ) @@ -1984,7 +1984,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("runs queued active inputs as separate FIFO activities", () => + it.effect("promotes queued inputs one at a time in FIFO order", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -2027,14 +2027,14 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("opens queued input after idle steering activity settles", () => + it.effect("promotes queued input after steering continuation ends", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering activity" }), resume: false }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start steering" }), resume: false }) yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Queue later activity" }), + prompt: new Prompt({ text: "Queue for later" }), delivery: "queue", resume: false, }) @@ -2056,12 +2056,12 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests).toHaveLength(2) - expect(userTexts(requests[0]!)).toEqual(["Start steering activity"]) - expect(userTexts(requests[1]!)).toEqual(["Start steering activity", "Queue later activity"]) + expect(userTexts(requests[0]!)).toEqual(["Start steering"]) + expect(userTexts(requests[1]!)).toEqual(["Start steering", "Queue for later"]) }), ) - it.effect("coalesces steers into the active queued activity before starting the next queued activity", () => + it.effect("promotes steers before the next queued input", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -2101,8 +2101,8 @@ describe("SessionRunnerLLM", () => { streamGate = secondGate yield* Deferred.succeed(firstGate, undefined) while (requests.length < 2) yield* Effect.yieldNow - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Steer first queued activity" }) }) - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Also steer first queued activity" }) }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Steer before next queued input" }) }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Also steer before next queued input" }) }) yield* Deferred.succeed(secondGate, undefined) yield* Fiber.join(first) streamGate = undefined @@ -2113,14 +2113,14 @@ describe("SessionRunnerLLM", () => { expect(userTexts(requests[2]!)).toEqual([ "Start working", "Queue first", - "Steer first queued activity", - "Also steer first queued activity", + "Steer before next queued input", + "Also steer before next queued input", ]) expect(userTexts(requests[3]!)).toEqual([ "Start working", "Queue first", - "Steer first queued activity", - "Also steer first queued activity", + "Steer before next queued input", + "Also steer before next queued input", "Queue second", ]) }), @@ -2354,13 +2354,13 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("starts the first queued activity when woken while idle", () => + it.effect("promotes the first queued input when woken while idle", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service yield* session.prompt({ sessionID, - prompt: new Prompt({ text: "Wait for fresh activity" }), + prompt: new Prompt({ text: "Wait in queue" }), delivery: "queue", resume: false, }) @@ -2370,30 +2370,7 @@ describe("SessionRunnerLLM", () => { yield* Effect.yieldNow expect(requests).toHaveLength(1) - expect(userTexts(requests[0]!)).toEqual(["Wait for fresh activity"]) - }), - ) - - it.effect("does not spend one activity step budget across queued activities", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const queued = Array.from({ length: 26 }, (_, index) => `Queued activity ${index + 1}`) - for (const text of queued) { - yield* session.prompt({ sessionID, prompt: new Prompt({ text }), delivery: "queue", resume: false }) - } - - requests.length = 0 - responses = queued.map(() => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ]) - - yield* session.resume(sessionID) - - expect(requests).toHaveLength(queued.length) - expect(userTexts(requests.at(-1)!)).toEqual(queued) + expect(userTexts(requests[0]!)).toEqual(["Wait in queue"]) }), ) @@ -2768,7 +2745,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("interrupts a blocked provider turn without local tool activity", () => + it.effect("interrupts a blocked provider turn without local tool execution", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -2828,39 +2805,55 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("continues past 25 local tool steps when the agent has no step limit", () => + it.effect("forces a text response on an agent's configured final step", () => Effect.gen(function* () { yield* setup + const agents = yield* AgentV2.Service + yield* agents.transform((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) requests.length = 0 - authorizations.length = 0 executions.length = 0 - streamGate = undefined - streamStarted = undefined responses = [ - ...Array.from({ length: 25 }, (_, index) => [ + [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), + LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), - ]), + ], [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), + LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), ], ] yield* session.resume(sessionID) - expect(requests).toHaveLength(26) - expect(executions).toHaveLength(25) + expect(requests).toHaveLength(2) + expect(requests[0]?.toolChoice).toBeUndefined() + expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) + expect(requests[1]?.tools).toEqual([]) + expect(requests[1]?.messages.at(-1)).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], + }) + expect(executions).toEqual(["done"]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Finish at the limit" }, + { type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] }, + { type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] }, + ]) }), ) - it.effect("forces a text response on an agent's configured final step", () => + it.effect("resets the configured step allowance when steering input promotes", () => Effect.gen(function* () { yield* setup const agents = yield* AgentV2.Service @@ -2870,41 +2863,45 @@ describe("SessionRunnerLLM", () => { }), ) const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Start work" }), resume: false }) requests.length = 0 executions.length = 0 responses = [ [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }), + LLMEvent.toolCall({ id: "call-before-steer", name: "echo", input: { text: "before" } }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ], [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }), + LLMEvent.toolCall({ id: "call-after-steer", name: "echo", input: { text: "after" } }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], ] + streamGate = yield* Deferred.make() + streamStarted = yield* Deferred.make() - yield* session.resume(sessionID) + const run = yield* session.resume(sessionID).pipe(Effect.forkChild) + yield* Deferred.await(streamStarted) + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Change direction" }) }) + yield* Deferred.succeed(streamGate, undefined) + yield* Fiber.join(run) + streamGate = undefined + streamStarted = undefined - expect(requests).toHaveLength(2) - expect(requests[0]?.toolChoice).toBeUndefined() - expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) - expect(requests[1]?.tools).toEqual([]) - expect(requests[1]?.messages.at(-1)).toMatchObject({ - role: "assistant", - content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], - }) - expect(executions).toEqual(["done"]) - expect(yield* session.context(sessionID)).toMatchObject([ - { type: "user", text: "Finish at the limit" }, - { type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] }, - { type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] }, - ]) + expect(requests).toHaveLength(3) + expect(requests[1]?.toolChoice).toBeUndefined() + expect(requests[1]?.tools).not.toEqual([]) + expect(requests[2]?.toolChoice).toMatchObject({ type: "none" }) + expect(executions).toEqual(["before", "after"]) }), ) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e6bec85f99de..d501e28132cd 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5344,7 +5344,7 @@ export class Session3 extends HeyApiClient { /** * Switch session agent * - * Switch the agent used by subsequent session activity. + * Switch the agent used by subsequent provider turns. */ public switchAgent( parameters: { @@ -5383,7 +5383,7 @@ export class Session3 extends HeyApiClient { /** * Switch session model * - * Switch the model used by subsequent session activity. + * Switch the model used by subsequent provider turns. */ public switchModel( parameters: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0f8f5ca1980d..14c8b94dd0ef 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -10383,7 +10383,7 @@ } } }, - "description": "Switch the agent used by subsequent session activity.", + "description": "Switch the agent used by subsequent provider turns.", "summary": "Switch session agent", "requestBody": { "content": { @@ -10468,7 +10468,7 @@ } } }, - "description": "Switch the model used by subsequent session activity.", + "description": "Switch the model used by subsequent provider turns.", "summary": "Switch session model", "requestBody": { "content": { diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index ac4418d39f7a..6a4f4aff609c 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -152,7 +152,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") OpenApi.annotations({ identifier: "v2.session.switchAgent", summary: "Switch session agent", - description: "Switch the agent used by subsequent session activity.", + description: "Switch the agent used by subsequent provider turns.", }), ), ) @@ -168,7 +168,7 @@ export const SessionGroup = HttpApiGroup.make("server.session") OpenApi.annotations({ identifier: "v2.session.switchModel", summary: "Switch session model", - description: "Switch the model used by subsequent session activity.", + description: "Switch the model used by subsequent provider turns.", }), ), ) diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index bdd483715627..cb90e305e8b7 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -138,7 +138,7 @@ Change: Reason: - Prompt admission and model-visible promotion must be separate durable operations. -- Steering must promote at safe provider-turn boundaries while queued prompts remain separate FIFO activities. +- Steering must promote at safe provider-turn boundaries while queued prompts remain pending in FIFO order until continuation would otherwise end. Compatibility: diff --git a/specs/v2/session.md b/specs/v2/session.md index 6788a893eafa..6529ff223262 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -42,7 +42,7 @@ SessionExecution.resume(sessionID) `SessionExecution` and the read-side `SessionStore` are process-global. `SessionRunner`, catalog, model resolver, tool registry, permission state, and filesystem are cached per Location. No layer takes a Session ID. An omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics. -The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, reloads projected history once before continuation, and fails after 25 provider turns within one local drain activity only when work remains. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. +The local runner issues one explicit `llm.stream(request)` per provider turn, projects each complete local tool call durably before eagerly starting its structured child execution, awaits every started tool fiber after provider-stream closure, and reloads projected history once before continuation. Promoting any new user input resets the selected agent's configured provider-turn allowance; multiple steers promoted at one boundary reset it once. Tool settlement events carry the owning assistant message ID because provider-local call IDs may repeat across turns. Before assembling a provider request, the runner durably fails any local tool still projected as `running` from a previous process with `Tool execution interrupted`; abandoned side effects are never silently replayed. Projected hosted tools preserve call-side and settlement-side provider metadata separately so settlement and interruption recovery cannot erase continuation identifiers. Provider-native reasoning and provider metadata replay only while the historical assistant model matches the selected continuation model; after a model switch, visible reasoning text remains ordinary assistant text and provider-native metadata is omitted. @@ -96,7 +96,7 @@ Ambient project discovery canonicalizes and contains traversal within the projec Current Context Epoch follow-ups: - Add configured, remote, and nested instruction sources with explicit precedence and removal semantics. -- Add durable post-crash activity recovery for promoted or provider-dispatched work. +- Add durable post-crash continuation recovery for promoted or provider-dispatched work. - Add explicit manual compaction on top of automatic request-budget compaction. - Add operational metrics for observation latency, unavailable sources, contention, baseline size, and chronological-update growth. - Consider watcher-backed per-file caching only if measurements show direct safe-boundary observation is too expensive. @@ -113,7 +113,7 @@ Compaction keeps the full transcript durable while replacing its active model re Repeated compactions update the previous structured summary with newly compacted messages. The runner then reloads projected history and executes the original pending turn. -When a provider rejects a request as context overflow before durable assistant output or tool activity, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical provider turn with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up. +When a provider rejects a request as context overflow before durable assistant output or tool execution, the runner attempts one overflow-triggered compaction even when the local estimate did not predict pressure. A completed checkpoint rebuilds the same logical provider turn with one remaining physical attempt. A second overflow, unavailable compaction, or overflow after durable output becomes the ordinary terminal failure; recovery never loops or replays partial side effects. Deterministic old tool-result pruning remains a separate follow-up. ## V1 Runtime Context Parity @@ -150,18 +150,18 @@ Provider timeout, retry, and watchdog policy is intentionally deferred. The runn Inbox delivery is explicit: - `steer` inputs promote at the next safe provider-turn boundary, including continuation inside the current drain. -- `queue` inputs form a FIFO of future activities. When the current activity settles, the runner promotes exactly one queued input to open the next activity. Multiple queued inputs remain separate activities. +- `queue` inputs remain in a FIFO while the current drain requires continuation. When the Session would otherwise become idle, the runner promotes exactly one queued input, then reevaluates continuation before promoting another. Execution has two entry points: - `run` is an explicit resume. It joins any active execution or starts a forced drain while idle. A forced drain bypasses the no-eligible-input guard, but preparation may still fail before a provider attempt. - `wake` reports newly recorded durable inbox work. Repeated wakes coalesce. A wake calls the provider only when it can promote eligible input. -Post-crash activity recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model durable activity identity, provider-dispatch ambiguity, required continuation, queue-opener reservation, retry policy, and visible recovery status together. +Post-crash continuation recovery is intentionally deferred. A wake does not infer that ambiguous provider work is safe to retry after an input has already been promoted. Explicit `run` may deliberately continue from durable projected history. A future recovery slice should model provider-dispatch ambiguity, required continuation, queued-input promotion, retry policy, and visible recovery status together. It must not assume an enclosing durable execution identity that the Session model does not otherwise need. A process-global `SessionRunCoordinator` serializes execution for each local Session while allowing different Sessions to run concurrently. Resumes join active execution, overlapping wakes coalesce into one follow-up, and interruption stops current process-local execution without deleting durable inbox work. The runner enters the Session's current Location when execution starts and fences each new provider turn against that Location. -Inbox promotion coalesces pending steers in durable admission order and opens one queued activity at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth. +Inbox promotion coalesces pending steers in durable admission order. Once continuation would otherwise end, it promotes one queued input at a time in FIFO order. Add explicit inbox backlog and steering-batch limits before exposing broad multi-caller admission or untrusted queue growth. Eager local-tool execution is intentionally unbounded in the current local slice. This minimizes tool latency but does not increase SQLite settlement throughput: Session-event publication remains serialized per provider turn. Before broadening exposure, revisit per-turn call limits, output truncation, and operational backpressure using observed workloads. The `session.next.*` event schemas remain experimental and unshipped; databases created by earlier experimental builds are disposable rather than compatibility targets. diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 893b1dc2dd32..b774c51c6bc9 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -26,14 +26,14 @@ through legacy `SessionPrompt.loop(...)`: tool results, and assistant output - a scoped `ToolRegistry` advertises definitions and the first permission-checked `read` built-in -- local continuation reloads projected history and stops after 25 provider turns within one local drain activity +- local continuation reloads projected history, and promoting new user input resets the selected agent's configured provider-turn allowance - concurrent resumes for one Session join one process-local run while different Sessions remain concurrent Prompt admission now uses a durable `session_input` inbox rather than immediate -transcript projection. `steer` inputs coalesce into the active activity at the -next safe provider-turn boundary. `queue` inputs form a FIFO of future activities -that open one at a time. +transcript projection. `steer` inputs promote at the next safe provider-turn +boundary while the current drain requires continuation. `queue` inputs remain in +a FIFO until the Session would otherwise become idle and then promote one at a time. Next reviewed slices: @@ -53,16 +53,16 @@ Next reviewed slices: - add durable/clustered interruption, retries, and stale-owner fencing only as their slices become concrete -### Deferred durable activity recovery +### Deferred durable continuation recovery Do not infer that ambiguous provider work is safe to retry from an advisory wake. The first inbox-driven runner intentionally omits outer provider-attempt markers until they have a concrete consumer and a complete recovery policy. -Design post-crash activity recovery as one explicit slice. It should model: +Design post-crash continuation recovery as one explicit slice. It should model: -- durable activity identity and settlement -- queue-opener reservation and steer assignment +- promoted input and projected-history state +- queued-input promotion and steering assignment - provider-attempt preparation versus provider-dispatch ambiguity - required post-tool continuation across process loss - explicit `retry` and `abandon` decisions for unknown outcomes @@ -70,6 +70,9 @@ Design post-crash activity recovery as one explicit slice. It should model: - retry budget, backoff, visible recovery status, startup discovery, and future clustered ownership fencing +Do not introduce an enclosing durable execution identity solely to group these +facts; a process-local Session drain has no durable transcript boundary. + ## Plugin API design - James? We need to figure out how we want server plugins to work and what hooks are useful. From 909a1a6d788bd516c22cfe8eb6c1a10e7dcc92a1 Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 22 Jun 2026 19:06:57 -0400 Subject: [PATCH 009/554] feat(plugin): add namespaced hook API (#33416) --- packages/core/src/agent.ts | 2 +- packages/core/src/aisdk.ts | 95 ++- packages/core/src/catalog.ts | 2 +- packages/core/src/command.ts | 2 +- packages/core/src/config/plugin/agent.ts | 2 +- packages/core/src/config/plugin/command.ts | 2 +- packages/core/src/config/plugin/external.ts | 99 +++ packages/core/src/config/plugin/provider.ts | 2 +- packages/core/src/config/plugin/reference.ts | 10 +- packages/core/src/config/plugin/skill.ts | 12 +- packages/core/src/integration.ts | 2 +- packages/core/src/plugin.ts | 255 +++----- packages/core/src/plugin/agent.ts | 6 +- packages/core/src/plugin/boot.ts | 120 ++-- packages/core/src/plugin/command.ts | 8 +- packages/core/src/plugin/host.ts | 148 +---- packages/core/src/plugin/internal.ts | 43 ++ packages/core/src/plugin/models-dev.ts | 8 +- packages/core/src/plugin/promise.ts | 86 +++ packages/core/src/plugin/provider.ts | 4 +- packages/core/src/plugin/provider/alibaba.ts | 5 +- .../src/plugin/provider/amazon-bedrock.ts | 8 +- .../core/src/plugin/provider/anthropic.ts | 5 +- packages/core/src/plugin/provider/azure.ts | 11 +- packages/core/src/plugin/provider/cerebras.ts | 5 +- .../plugin/provider/cloudflare-ai-gateway.ts | 5 +- .../plugin/provider/cloudflare-workers-ai.ts | 8 +- packages/core/src/plugin/provider/cohere.ts | 5 +- .../core/src/plugin/provider/deepinfra.ts | 5 +- packages/core/src/plugin/provider/dynamic.ts | 9 +- packages/core/src/plugin/provider/gateway.ts | 5 +- .../src/plugin/provider/github-copilot.ts | 8 +- packages/core/src/plugin/provider/gitlab.ts | 8 +- .../core/src/plugin/provider/google-vertex.ts | 14 +- packages/core/src/plugin/provider/google.ts | 5 +- packages/core/src/plugin/provider/groq.ts | 5 +- packages/core/src/plugin/provider/kilo.ts | 2 +- .../core/src/plugin/provider/llmgateway.ts | 6 +- packages/core/src/plugin/provider/mistral.ts | 5 +- packages/core/src/plugin/provider/nvidia.ts | 2 +- .../src/plugin/provider/openai-compatible.ts | 5 +- packages/core/src/plugin/provider/openai.ts | 8 +- packages/core/src/plugin/provider/opencode.ts | 6 +- .../core/src/plugin/provider/openrouter.ts | 5 +- .../core/src/plugin/provider/perplexity.ts | 5 +- .../core/src/plugin/provider/sap-ai-core.ts | 12 +- .../src/plugin/provider/snowflake-cortex.ts | 5 +- .../core/src/plugin/provider/togetherai.ts | 5 +- packages/core/src/plugin/provider/venice.ts | 5 +- packages/core/src/plugin/provider/vercel.ts | 5 +- packages/core/src/plugin/provider/xai.ts | 8 +- packages/core/src/plugin/provider/zenmux.ts | 2 +- packages/core/src/plugin/skill.ts | 2 +- packages/core/src/project/copy.ts | 3 - packages/core/src/reference.ts | 2 +- packages/core/src/reference/guidance.ts | 3 - packages/core/src/session/runner/model.ts | 3 - packages/core/src/skill.ts | 2 +- packages/core/src/skill/guidance.ts | 3 - packages/core/src/state.ts | 30 +- packages/core/src/tool/skill.ts | 3 - packages/core/test/agent.test.ts | 8 +- packages/core/test/catalog.test.ts | 2 +- packages/core/test/config/command.test.ts | 2 +- .../fixtures/plugin/directory-plugin.ts | 13 + packages/core/test/config/plugin.test.ts | 248 +++++++ packages/core/test/config/provider.test.ts | 7 +- packages/core/test/config/skill.test.ts | 11 +- packages/core/test/location-layer.test.ts | 47 +- packages/core/test/plugin.test.ts | 143 +---- packages/core/test/plugin/command.test.ts | 8 +- packages/core/test/plugin/fixture.ts | 15 +- .../plugin/fixtures/config-effect-plugin.ts | 15 + .../plugin/fixtures/config-promise-plugin.ts | 13 + .../test/plugin/fixtures/invalid-plugin.ts | 1 + packages/core/test/plugin/host.ts | 136 +--- packages/core/test/plugin/models-dev.test.ts | 17 +- packages/core/test/plugin/promise.test.ts | 67 ++ .../core/test/plugin/provider-alibaba.test.ts | 72 +-- .../plugin/provider-amazon-bedrock.test.ts | 550 +++++++--------- .../test/plugin/provider-anthropic.test.ts | 48 +- .../provider-azure-cognitive-services.test.ts | 135 ++-- .../core/test/plugin/provider-azure.test.ts | 198 +++--- .../test/plugin/provider-cerebras.test.ts | 111 ++-- .../provider-cloudflare-ai-gateway.test.ts | 285 ++++---- .../provider-cloudflare-workers-ai.test.ts | 180 +++--- .../core/test/plugin/provider-cohere.test.ts | 89 ++- .../test/plugin/provider-deepinfra.test.ts | 148 ++--- .../core/test/plugin/provider-dynamic.test.ts | 100 ++- .../core/test/plugin/provider-gateway.test.ts | 97 ++- .../plugin/provider-github-copilot.test.ts | 272 ++++---- .../core/test/plugin/provider-gitlab.test.ts | 256 ++++---- .../provider-google-vertex-anthropic.test.ts | 188 +++--- .../plugin/provider-google-vertex.test.ts | 116 ++-- .../core/test/plugin/provider-google.test.ts | 83 ++- .../core/test/plugin/provider-groq.test.ts | 119 ++-- .../core/test/plugin/provider-kilo.test.ts | 4 +- .../test/plugin/provider-llmgateway.test.ts | 5 +- .../core/test/plugin/provider-mistral.test.ts | 115 ++-- .../core/test/plugin/provider-nvidia.test.ts | 4 +- .../plugin/provider-openai-compatible.test.ts | 123 ++-- .../core/test/plugin/provider-openai.test.ts | 93 ++- .../test/plugin/provider-opencode.test.ts | 5 +- .../test/plugin/provider-openrouter.test.ts | 47 +- .../test/plugin/provider-perplexity.test.ts | 111 ++-- .../test/plugin/provider-sap-ai-core.test.ts | 81 ++- .../plugin/provider-snowflake-cortex.test.ts | 144 ++--- .../test/plugin/provider-togetherai.test.ts | 124 ++-- .../core/test/plugin/provider-venice.test.ts | 110 ++-- .../core/test/plugin/provider-vercel.test.ts | 27 +- .../core/test/plugin/provider-xai.test.ts | 112 ++-- .../core/test/plugin/provider-zenmux.test.ts | 4 +- packages/core/test/plugin/skill.test.ts | 2 +- packages/core/test/reference-guidance.test.ts | 4 - packages/core/test/skill/guidance.test.ts | 19 +- packages/core/test/state.test.ts | 4 +- packages/core/test/tool-skill.test.ts | 18 +- packages/opencode/src/agent/agent.ts | 2 - packages/opencode/src/cli/cmd/debug/v2.ts | 2 - packages/opencode/src/session/system.ts | 2 - packages/plugin/package.json | 3 +- packages/plugin/src/v2/effect/README.md | 606 ++---------------- packages/plugin/src/v2/effect/agent.ts | 11 +- packages/plugin/src/v2/effect/aisdk.ts | 17 +- packages/plugin/src/v2/effect/catalog.ts | 20 +- packages/plugin/src/v2/effect/command.ts | 10 +- packages/plugin/src/v2/effect/context.ts | 22 + packages/plugin/src/v2/effect/host.ts | 27 - packages/plugin/src/v2/effect/index.ts | 18 +- packages/plugin/src/v2/effect/integration.ts | 10 +- packages/plugin/src/v2/effect/plugin.ts | 25 +- packages/plugin/src/v2/effect/reference.ts | 11 +- packages/plugin/src/v2/effect/registration.ts | 13 +- packages/plugin/src/v2/effect/skill.ts | 10 +- packages/plugin/src/v2/options.ts | 1 + packages/plugin/src/v2/promise/README.md | 103 +++ packages/plugin/src/v2/promise/agent.ts | 8 + packages/plugin/src/v2/promise/aisdk.ts | 18 + packages/plugin/src/v2/promise/catalog.ts | 8 + packages/plugin/src/v2/promise/command.ts | 8 + packages/plugin/src/v2/promise/context.ts | 22 + packages/plugin/src/v2/promise/index.ts | 17 + packages/plugin/src/v2/promise/integration.ts | 8 + packages/plugin/src/v2/promise/plugin.ts | 18 + packages/plugin/src/v2/promise/reference.ts | 8 + .../plugin/src/v2/promise/registration.ts | 11 + packages/plugin/src/v2/promise/skill.ts | 8 + packages/server/src/handlers/agent.ts | 2 - packages/server/src/handlers/model.ts | 9 - packages/server/src/handlers/provider.ts | 12 +- 150 files changed, 3276 insertions(+), 3906 deletions(-) create mode 100644 packages/core/src/config/plugin/external.ts create mode 100644 packages/core/src/plugin/internal.ts create mode 100644 packages/core/src/plugin/promise.ts create mode 100644 packages/core/test/config/fixtures/plugin/directory-plugin.ts create mode 100644 packages/core/test/config/plugin.test.ts create mode 100644 packages/core/test/plugin/fixtures/config-effect-plugin.ts create mode 100644 packages/core/test/plugin/fixtures/config-promise-plugin.ts create mode 100644 packages/core/test/plugin/fixtures/invalid-plugin.ts create mode 100644 packages/core/test/plugin/promise.test.ts create mode 100644 packages/plugin/src/v2/effect/context.ts delete mode 100644 packages/plugin/src/v2/effect/host.ts create mode 100644 packages/plugin/src/v2/options.ts create mode 100644 packages/plugin/src/v2/promise/README.md create mode 100644 packages/plugin/src/v2/promise/agent.ts create mode 100644 packages/plugin/src/v2/promise/aisdk.ts create mode 100644 packages/plugin/src/v2/promise/catalog.ts create mode 100644 packages/plugin/src/v2/promise/command.ts create mode 100644 packages/plugin/src/v2/promise/context.ts create mode 100644 packages/plugin/src/v2/promise/index.ts create mode 100644 packages/plugin/src/v2/promise/integration.ts create mode 100644 packages/plugin/src/v2/promise/plugin.ts create mode 100644 packages/plugin/src/v2/promise/reference.ts create mode 100644 packages/plugin/src/v2/promise/registration.ts create mode 100644 packages/plugin/src/v2/promise/skill.ts diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 18e9e59c0ffe..f27b5c3ef8b0 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -108,7 +108,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, get: Effect.fn("AgentV2.get")(function* (id) { return state.get().agents.get(id) }), diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 769941fd276b..9ea79394f34a 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -1,14 +1,27 @@ export * as AISDK from "./aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Cause, Context, Effect, Layer, Schema } from "effect" +import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" import { ModelV2 } from "./model" -import { EventV2 } from "./event" -import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" +import { State } from "./state" type SDK = any +export interface SDKEvent { + readonly model: ModelV2.Info + readonly package: string + readonly options: Record + sdk?: SDK +} + +export interface LanguageEvent { + readonly model: ModelV2.Info + readonly sdk: SDK + readonly options: Record + language?: LanguageModelV3 +} + function wrapSSE(res: Response, ms: number, ctl: AbortController) { if (typeof ms !== "number" || ms <= 0) return res if (!res.body) return res @@ -117,19 +130,70 @@ function initError(providerID: ProviderV2.ID) { } export interface Interface { + readonly hook: { + readonly sdk: ( + callback: (event: SDKEvent) => Effect.Effect | void, + ) => Effect.Effect + readonly language: ( + callback: (event: LanguageEvent) => Effect.Effect | void, + ) => Effect.Effect + } + readonly runSDK: (event: SDKEvent) => Effect.Effect + readonly runLanguage: (event: LanguageEvent) => Effect.Effect readonly language: (model: ModelV2.Info) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/AISDK") {} -export const layer = Layer.effect( +export const locationLayer = Layer.effect( Service, Effect.gen(function* () { - const plugin = yield* PluginV2.Service + let sdkHooks: ((event: SDKEvent) => Effect.Effect | void)[] = [] + let languageHooks: ((event: LanguageEvent) => Effect.Effect | void)[] = [] const languages = new Map() const sdks = new Map() - return Service.of({ + const register = ( + hooks: () => ((event: Event) => Effect.Effect | void)[], + update: (hooks: ((event: Event) => Effect.Effect | void)[]) => void, + ) => + Effect.fn("AISDK.hook")(function* (callback: (event: Event) => Effect.Effect | void) { + const scope = yield* Scope.Scope + let active = true + update([...hooks(), callback]) + const dispose = Effect.sync(() => { + if (!active) return + active = false + update(hooks().filter((item) => item !== callback)) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }) + + const run = Effect.fnUntraced(function* ( + hooks: readonly ((event: Event) => Effect.Effect | void)[], + event: Event, + ) { + for (const hook of hooks) { + const result = hook(event) + if (Effect.isEffect(result)) yield* result + } + return event + }) + + const service = Service.of({ + hook: { + sdk: register( + () => sdkHooks, + (next) => (sdkHooks = next), + ), + language: register( + () => languageHooks, + (next) => (languageHooks = next), + ), + }, + runSDK: (event) => run(sdkHooks, event), + runLanguage: (event) => run(languageHooks, event), language: Effect.fn("AISDK.language")(function* (model) { const key = `${model.providerID}/${model.id}/${model.request.variant ?? "default"}` const existing = languages.get(key) @@ -148,26 +212,14 @@ export const layer = Layer.effect( }) const sdk = sdks.get(sdkKey) ?? - (yield* plugin - .trigger("aisdk.sdk", { model, package: model.api.package, options }, {}) - .pipe(initError(model.providerID))).sdk + (yield* service.runSDK({ model, package: model.api.package, options }).pipe(initError(model.providerID))).sdk if (!sdk) return yield* new InitError({ providerID: model.providerID, cause: new Error("No AISDK provider plugin returned an SDK"), }) sdks.set(sdkKey, sdk) - const result = yield* plugin - .trigger( - "aisdk.language", - { - model, - sdk, - options, - }, - {}, - ) - .pipe(initError(model.providerID)) + const result = yield* service.runLanguage({ model, sdk, options }).pipe(initError(model.providerID)) const language = yield* Effect.sync(() => result.language ?? sdk.languageModel(model.api.id)).pipe( initError(model.providerID), ) @@ -175,7 +227,8 @@ export const layer = Layer.effect( return language }), }) + return service }), ) -export const defaultLayer = layer.pipe(Layer.provide(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))) +export const defaultLayer = locationLayer diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index ed982cb6d7f6..ade2d6460758 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -170,7 +170,7 @@ export const layer = Layer.effect( }) const result: Interface = { transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 622702e9946f..947ad311e79f 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -52,7 +52,7 @@ export const layer = Layer.effect( }) return Service.of({ - rebuild: state.rebuild, + reload: state.reload, transform: state.transform, get: Effect.fn("CommandV2.get")(function* (name) { return state.get().commands.get(name) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index ffc268a0e24d..48efe758047a 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -1,6 +1,6 @@ export * as ConfigAgentPlugin from "./agent" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import path from "path" import { Effect, Option, Schema } from "effect" import { AgentV2 } from "../../agent" diff --git a/packages/core/src/config/plugin/command.ts b/packages/core/src/config/plugin/command.ts index a88c60559e94..f9b31f8e45a1 100644 --- a/packages/core/src/config/plugin/command.ts +++ b/packages/core/src/config/plugin/command.ts @@ -1,6 +1,6 @@ export * as ConfigCommandPlugin from "./command" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import path from "path" import { Effect, Option, Schema } from "effect" import { CommandV2 } from "../../command" diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts new file mode 100644 index 000000000000..d81d9f7c8713 --- /dev/null +++ b/packages/core/src/config/plugin/external.ts @@ -0,0 +1,99 @@ +export * as ConfigExternalPlugin from "./external" + +import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise" +import { Effect, Schema } from "effect" +import path from "path" +import { fileURLToPath, pathToFileURL } from "url" +import { Config } from "../../config" +import { FSUtil } from "../../fs-util" +import { Location } from "../../location" +import { Npm } from "../../npm" +import { define } from "../../plugin/internal" +import { PluginPromise } from "../../plugin/promise" + +const PluginModule = Schema.Struct({ + default: Schema.Union([ + Schema.Struct({ + id: Schema.String, + effect: Schema.declare( + (input): input is EffectPlugin["effect"] => typeof input === "function", + ), + }), + Schema.Struct({ + id: Schema.String, + setup: Schema.declare( + (input): input is PromisePlugin["setup"] => typeof input === "function", + ), + }), + ]), +}) + +export const Plugin = define({ + id: "config-plugin", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const loaded: EffectPlugin[] = [] + + yield* ctx.plugin.transform((plugins) => { + for (const plugin of loaded) plugins.add(plugin) + }) + + yield* Effect.gen(function* () { + const configured: { package: string; options?: Record }[] = [] + + for (const entry of yield* config.entries()) { + if (entry.type === "document") { + const directory = entry.path ? path.dirname(entry.path) : location.directory + for (const item of entry.info.plugins ?? []) { + const ref = typeof item === "string" ? { package: item } : item + const packageName = (() => { + if (ref.package.startsWith("file://")) return fileURLToPath(ref.package) + if (ref.package.startsWith("./") || ref.package.startsWith("../")) { + return path.resolve(directory, ref.package) + } + return ref.package + })() + configured.push({ package: packageName, options: ref.options }) + } + } + + if (entry.type === "directory") { + const files = yield* fs + .glob("{plugin,plugins}/*.{ts,js}", { + cwd: entry.path, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .pipe(Effect.orElseSucceed(() => [])) + files.sort() + for (const file of files) configured.push({ package: file }) + } + } + + for (const ref of configured) { + yield* Effect.gen(function* () { + const entrypoint = path.isAbsolute(ref.package) + ? pathToFileURL(ref.package).href + : (yield* npm.add(ref.package)).entrypoint + if (!entrypoint) return + + const mod = yield* Effect.promise(() => import(entrypoint)) + const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default + const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) + loaded.push({ + id: plugin.id, + effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), + }) + }).pipe(Effect.ignoreCause) + } + + yield* ctx.plugin.reload() + }).pipe(Effect.forkScoped({ startImmediately: true })) + }), +}) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 0171fee37bb2..6fb13f1c63c7 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,6 +1,6 @@ export * as ConfigProviderPlugin from "./provider" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import { Effect } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts index f511736e11f7..82487e4a898a 100644 --- a/packages/core/src/config/plugin/reference.ts +++ b/packages/core/src/config/plugin/reference.ts @@ -1,24 +1,28 @@ export * as ConfigReferencePlugin from "./reference" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import path from "path" import { Effect } from "effect" import { Config } from "../../config" import { ConfigReference } from "../reference" import { Reference } from "../../reference" import { AbsolutePath } from "../../schema" +import { Global } from "../../global" +import { Location } from "../../location" export const Plugin = define({ id: "core/config-reference", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service + const location = yield* Location.Service + const global = yield* Global.Service yield* ctx.reference.transform( Effect.fn(function* (draft) { const entries = new Map() for (const doc of (yield* config.entries()).filter( (entry): entry is Config.Document => entry.type === "document", )) { - const directory = doc.path ? path.dirname(doc.path) : ctx.location.directory + const directory = doc.path ? path.dirname(doc.path) : location.directory for (const [name, entry] of Object.entries(doc.info.references ?? {})) { if (!validAlias(name)) continue entries.set( @@ -27,7 +31,7 @@ export const Plugin = define({ ? new Reference.LocalSource({ type: "local", path: AbsolutePath.make( - localPath(directory, ctx.path.home, typeof entry === "string" ? entry : entry.path), + localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), ), description: typeof entry === "string" ? undefined : entry.description, hidden: typeof entry === "string" ? undefined : entry.hidden, diff --git a/packages/core/src/config/plugin/skill.ts b/packages/core/src/config/plugin/skill.ts index 9f6a99d8b1a4..eca8b5ccae5b 100644 --- a/packages/core/src/config/plugin/skill.ts +++ b/packages/core/src/config/plugin/skill.ts @@ -1,16 +1,20 @@ export * as ConfigSkillPlugin from "./skill" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../../plugin/internal" import path from "path" import { Effect } from "effect" import { Config } from "../../config" import { AbsolutePath } from "../../schema" import { SkillV2 } from "../../skill" +import { Global } from "../../global" +import { Location } from "../../location" export const Plugin = define({ id: "config-skill", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service + const global = yield* Global.Service + const location = yield* Location.Service yield* ctx.skill.transform( Effect.fn(function* (draft) { const entries = yield* config.entries() @@ -29,13 +33,11 @@ export const Plugin = define({ draft.source(new SkillV2.UrlSource({ type: "url", url: item })) continue } - const expanded = item.startsWith("~/") ? path.join(ctx.path.home, item.slice(2)) : item + const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item draft.source( new SkillV2.DirectorySource({ type: "directory", - path: AbsolutePath.make( - path.isAbsolute(expanded) ? expanded : path.join(ctx.location.directory, expanded), - ), + path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)), }), ) } diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 03192921b939..1e1e613e3fa4 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -432,7 +432,7 @@ export const locationLayer = Layer.effect( return Service.of({ transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, get: Effect.fn("Integration.get")(function* (id) { const entry = state.get().integrations.get(id) if (!entry) return undefined diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 0a02ddfa7f5f..85c51ea7dba2 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,12 +1,17 @@ export * as PluginV2 from "./plugin" -import { createDraft, finishDraft, type Draft } from "immer" -import type { LanguageModelV3 } from "@ai-sdk/provider" import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" -import type { ModelV2 } from "./model" -import type { Catalog } from "./catalog" +import type { Plugin, PluginDraft } from "@opencode-ai/plugin/v2/effect" +import { AgentV2 } from "./agent" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { CommandV2 } from "./command" import { EventV2 } from "./event" +import { Integration } from "./integration" import { KeyedMutex } from "./effect/keyed-mutex" +import { PluginHost } from "./plugin/host" +import { Reference } from "./reference" +import { SkillV2 } from "./skill" import { State } from "./state" export const ID = Schema.String.pipe(Schema.brand("Plugin.ID")) @@ -21,69 +26,9 @@ export const Event = { }), } -type HookSpec = { - "catalog.transform": { - input: Catalog.Draft - output: {} - } - "aisdk.language": { - input: { - model: ModelV2.Info - sdk: any - options: Record - } - output: { - language?: LanguageModelV3 - } - } - "aisdk.sdk": { - input: { - model: ModelV2.Info - package: string - options: Record - } - output: { - sdk?: any - } - } -} - -export type Hooks = { - [Name in keyof HookSpec]: Readonly & { - -readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object - ? Draft - : HookSpec[Name]["output"][Field] - } -} - -export type HookFunctions = { - [key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect -} - -export type HookInput = HookSpec[Name]["input"] -export type HookOutput = HookSpec[Name]["output"] - export interface Interface { - readonly add: (input: { - id: string - effect: Effect.Effect - }) => Effect.Effect - readonly remove: (id: ID) => Effect.Effect - readonly hook: ( - name: Name, - callback: (input: Hooks[Name]) => Effect.Effect | void, - ) => Effect.Effect - readonly triggerFor: ( - id: ID, - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> - readonly trigger: ( - name: Name, - input: HookInput, - output: HookOutput, - ) => Effect.Effect & HookOutput> + readonly transform: State.Transform + readonly reload: State.Reload } export class Service extends Context.Service()("@opencode/v2/Plugin") {} @@ -91,127 +36,85 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - let hooks: { - id: ID - hooks: HookFunctions - scope: Scope.Closeable - }[] = [] - let registrations: { - [Name in keyof Hooks]: { - name: Name - callback: (input: Hooks[Name]) => Effect.Effect | void - } - }[keyof Hooks][] = [] const events = yield* EventV2.Service const locks = KeyedMutex.makeUnsafe() const scope = yield* Scope.make() + const active = new Map() + let host: Parameters[0] + + const attach = Effect.fn("Plugin.attach")(function* (plugin: Plugin, host: Parameters[0]) { + const id = ID.make(plugin.id) + yield* locks.withLock(id)( + Effect.gen(function* () { + const existing = active.get(id) + if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + + const child = yield* Scope.fork(scope) + yield* plugin.effect(host).pipe( + Scope.provide(child), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + ) + active.set(id, child) + yield* events.publish(Event.Added, { id }) + }), + ) + }) - // One registry-owned scope lets shutdown remove every plugin transform in one batch. - yield* Effect.addFinalizer((exit) => - Effect.gen(function* () { - hooks = [] - yield* State.batch(Scope.close(scope, exit)) - }), - ) + const detach = Effect.fn("Plugin.detach")(function* (id: ID) { + yield* locks.withLock(id)( + Effect.gen(function* () { + const current = active.get(id) + active.delete(id) + if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) + }), + ) + }) - const svc = Service.of({ - add: Effect.fn("Plugin.add")(function* (input) { - const id = ID.make(input.id) - yield* locks.withLock(id)( - Effect.gen(function* () { - const existing = hooks.find((item) => item.id === id) - if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) - const childScope = yield* Scope.fork(scope) - const result = yield* input.effect.pipe( - Scope.provide(childScope), - Effect.withSpan("Plugin.load", { - attributes: { - "plugin.id": id, - }, - }), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)), - ) - const next = { - id, - hooks: result ?? {}, - scope: childScope, - } - hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next] - yield* events.publish(Event.Added, { id }) - }), - ) + const state = State.create, PluginDraft>({ + initial: () => new Map(), + draft: (draft) => ({ + list: () => Array.from(draft.values()), + add: (plugin) => draft.set(ID.make(plugin.id), plugin), + remove: (id) => draft.delete(ID.make(id)), }), - trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) { - return yield* svc.triggerFor(ID.make("*"), name, input, output) - }), - triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) { - const draftEntries = new Map>() - const event = { - ...input, - ...output, - } as Record - - for (const [field, value] of Object.entries(output)) { - if (value && typeof value === "object") { - draftEntries.set(field, createDraft(value)) - event[field] = draftEntries.get(field) - } - } - - for (const item of hooks) { - if (id !== ID.make("*") && item.id !== id) continue - const match = item.hooks[name] - if (!match) continue - yield* match(event as any).pipe( - Effect.withSpan(`Plugin.hook.${name}`, { - attributes: { - plugin: item.id, - hook: name, - }, - }), - ) - } + finalize: (draft) => + State.batch( + Effect.gen(function* () { + const desired = new Set() + for (const plugin of draft.list()) desired.add(ID.make(plugin.id)) - for (const item of registrations) { - if (item.name !== name) continue - const result = item.callback(event as never) - if (Effect.isEffect(result)) yield* result - } + for (const id of active.keys()) { + if (!desired.has(id)) yield* detach(id) + } - for (const [field, draft] of draftEntries) { - event[field] = finishDraft(draft) - } + for (const plugin of draft.list()) yield* attach(plugin, host) + }).pipe(Effect.withSpan("Plugin.reconcile")), + ), + }) - return event as any - }), - remove: Effect.fn("Plugin.remove")(function* (id) { - yield* locks.withLock(id)( - Effect.gen(function* () { - const existing = hooks.find((item) => item.id === id) - hooks = hooks.filter((item) => item.id !== id) - if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore) - }), - ) - }), - hook: Effect.fn("Plugin.hook")(function* (name, callback) { - const scope = yield* Scope.Scope - const registration = { name, callback } as (typeof registrations)[number] - let active = true - registrations = [...registrations, registration] - const dispose = Effect.sync(() => { - if (!active) return - active = false - registrations = registrations.filter((item) => item !== registration) - }) - yield* Scope.addFinalizer(scope, dispose) - return { dispose } + yield* Effect.addFinalizer((exit) => + Effect.gen(function* () { + active.clear() + yield* State.batch(Scope.close(scope, exit)) }), + ) + + const service = Service.of({ + transform: state.transform, + reload: state.reload, }) - return svc + host = yield* PluginHost.make(service) + return service }), ) -export const locationLayer = layer - -// opencode -// sdcok +export const locationLayer = layer.pipe( + Layer.provideMerge(AgentV2.locationLayer), + Layer.provideMerge(AISDK.locationLayer), + Layer.provideMerge(Catalog.locationLayer), + Layer.provideMerge(CommandV2.locationLayer), + Layer.provideMerge(Integration.locationLayer), + Layer.provideMerge(Reference.locationLayer), + Layer.provideMerge(SkillV2.locationLayer), +) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 735ddd310727..9a763c7ea9b8 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -1,10 +1,11 @@ export * as AgentPlugin from "./agent" import path from "path" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "./internal" import { Effect } from "effect" import { AgentV2 } from "../agent" import { Global } from "../global" +import { Location } from "../location" import { PermissionV2 } from "../permission" const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*") @@ -99,7 +100,8 @@ Rules: export const Plugin = define({ id: "agent", effect: Effect.fn(function* (ctx) { - const worktree = ctx.location.directory + const location = yield* Location.Service + const worktree = location.directory const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] const readonlyExternalDirectory: PermissionV2.Ruleset = [ { action: "external_directory", resource: "*", effect: "ask" }, diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 3acd94a218f7..34b52417d8c5 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -1,9 +1,9 @@ export * as PluginBoot from "./boot" -import type { Plugin as PublicPlugin } from "@opencode-ai/plugin/v2/effect" -import { Context, Deferred, Effect, Layer } from "effect" +import { Effect, Layer } from "effect" import { Integration } from "../integration" import { AgentV2 } from "../agent" +import { AISDK } from "../aisdk" import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Config } from "../config" @@ -11,6 +11,7 @@ import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" import { ConfigSkillPlugin } from "../config/plugin/skill" import { ConfigReferencePlugin } from "../config/plugin/reference" +import { ConfigExternalPlugin } from "../config/plugin/external" import { EventV2 } from "../event" import { FSUtil } from "../fs-util" import { FileSystem } from "../filesystem" @@ -29,18 +30,9 @@ import { SkillV2 } from "../skill" import { Reference } from "../reference" import { State } from "../state" import { PluginHost } from "./host" +import { PluginInternal } from "./internal" -type InternalPlugin = PublicPlugin - -export interface Interface { - readonly add: (plugin: PublicPlugin) => Effect.Effect - readonly wait: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/PluginBoot") {} - -export const layer = Layer.effect( - Service, +export const locationLayer = Layer.effectDiscard( Effect.gen(function* () { const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service @@ -57,75 +49,47 @@ export const layer = Layer.effect( const global = yield* Global.Service const skill = yield* SkillV2.Service const reference = yield* Reference.Service - const host = yield* PluginHost.make() - const done = yield* Deferred.make() - - const add = Effect.fn("PluginBoot.add")(function* (input: InternalPlugin) { - yield* plugin.add({ - id: input.id, - effect: input - .effect(host) - .pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Integration.Service, integration), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(FileSystem.Service, filesystem), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, reference), - ), - }) - }) + const host = yield* PluginHost.make(plugin) - const boot = Effect.gen(function* () { - yield* State.batch( - Effect.gen(function* () { - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - yield* add(ModelsDevPlugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) - for (const item of ProviderPlugins) { - yield* add(item) - } - }), - ) - }).pipe(Effect.withSpan("PluginBoot.boot")) + const add = (input: PluginInternal.Plugin) => + input + .effect({ ...host, options: {} }) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ) - yield* boot.pipe( - Effect.exit, - Effect.flatMap((exit) => Deferred.done(done, exit)), - Effect.forkScoped, - ) - - return Service.of({ - add: (input) => - Deferred.await(done).pipe( - Effect.andThen( - plugin.add({ - id: input.id, - effect: input.effect(host), - }), - ), - ), - wait: () => Deferred.await(done), - }) + yield* State.batch( + Effect.gen(function* () { + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) + yield* add(ModelsDevPlugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + yield* add(ConfigReferencePlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + }), + ).pipe(Effect.withSpan("PluginBoot.boot")) }), -) - -export const locationLayer = layer.pipe( +).pipe( Layer.provideMerge(PluginV2.locationLayer), + Layer.provideMerge(AISDK.locationLayer), Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Catalog.locationLayer), Layer.provideMerge(CommandV2.locationLayer), diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index 121bc0e6ccbb..cbafd68b5025 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -1,20 +1,22 @@ export * as CommandPlugin from "./command" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "./internal" import { Effect } from "effect" +import { Location } from "../location" import PROMPT_INITIALIZE from "./command/initialize.txt" import PROMPT_REVIEW from "./command/review.txt" export const Plugin = define({ id: "command", effect: Effect.fn(function* (ctx) { + const location = yield* Location.Service yield* ctx.command.transform((draft) => { draft.update("init", (command) => { - command.template = PROMPT_INITIALIZE.replace("${path}", ctx.location.project.directory) + command.template = PROMPT_INITIALIZE.replace("${path}", location.project.directory) command.description = "guided AGENTS.md setup" }) draft.update("review", (command) => { - command.template = PROMPT_REVIEW.replace("${path}", ctx.location.project.directory) + command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" command.subtask = true }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 833671c0a58e..27afc14d2af5 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,58 +1,31 @@ export * as PluginHost from "./host" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import type { PluginHost as Interface } from "@opencode-ai/plugin/v2/effect" -import type { Event as SDKEvent, ModelV2Info } from "@opencode-ai/sdk/v2/types" -import { Effect, Schema, Stream } from "effect" +import type { PluginContext as Interface } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema } from "effect" import { AgentV2 } from "../agent" +import { AISDK } from "../aisdk" import { Catalog } from "../catalog" import { CommandV2 } from "../command" -import { EventV2 } from "../event" -import { FileSystem } from "../filesystem" -import { Global } from "../global" import { Integration } from "../integration" -import { Location } from "../location" import { ModelV2 } from "../model" -import { Npm } from "../npm" -import { PluginV2 } from "../plugin" +import type { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" import { Reference } from "../reference" import { SkillV2 } from "../skill" -type EventMap = { [Item in SDKEvent as Item["type"]]: Item } -type SDKHook = (event: { - readonly model: ModelV2Info - readonly package: string - readonly options: Record - sdk?: any -}) => Effect.Effect | void -type LanguageHook = (event: { - readonly model: ModelV2Info - readonly sdk: any - readonly options: Record - language?: LanguageModelV3 -}) => Effect.Effect | void - -export const make = Effect.fn("PluginHost.make")(function* () { +export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { const agents = yield* AgentV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service - const events = yield* EventV2.Service - const filesystem = yield* FileSystem.Service - const global = yield* Global.Service const integration = yield* Integration.Service - const location = yield* Location.Service - const npm = yield* Npm.Service - const plugin = yield* PluginV2.Service const reference = yield* Reference.Service const skill = yield* SkillV2.Service return { + options: {}, agent: { - get: (id) => agents.get(AgentV2.ID.make(id)), - default: agents.default, - list: agents.all, - rebuild: agents.rebuild, + reload: agents.reload, transform: (callback) => agents.transform((draft) => callback({ @@ -65,51 +38,35 @@ export const make = Effect.fn("PluginHost.make")(function* () { ), }, aisdk: { - hook: (name, callback) => { - if (name === "sdk") { - const run = callback as SDKHook - return plugin.hook("aisdk.sdk", (event) => { - const output = { - model: event.model, - package: event.package, - options: event.options, - sdk: event.sdk, - } - const result = run(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), - ) - }) - } - const run = callback as LanguageHook - return plugin.hook("aisdk.language", (event) => { + sdk: (callback) => + aisdk.hook.sdk((event) => { + const output = { + model: event.model, + package: event.package, + options: event.options, + sdk: event.sdk, + } + const result = callback(output) + return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }), + language: (callback) => + aisdk.hook.language((event) => { const output = { model: event.model, sdk: event.sdk, options: event.options, language: event.language, } - const result = run(output) + const result = callback(output) return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( Effect.tap(() => Effect.sync(() => (event.language = output.language))), ) - }) - }, + }), }, catalog: { - provider: { - get: (id) => catalog.provider.get(ProviderV2.ID.make(id)), - list: catalog.provider.all, - available: catalog.provider.available, - }, - model: { - get: (providerID, modelID) => catalog.model.get(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)), - list: catalog.model.all, - available: catalog.model.available, - default: catalog.model.default, - small: (providerID) => catalog.model.small(ProviderV2.ID.make(providerID)), - }, - rebuild: catalog.rebuild, + reload: catalog.reload, transform: (callback) => catalog.transform((draft) => callback({ @@ -135,41 +92,11 @@ export const make = Effect.fn("PluginHost.make")(function* () { ), }, command: { - get: commands.get, - list: commands.list, - rebuild: commands.rebuild, + reload: commands.reload, transform: commands.transform, }, - event: { - subscribe: (type: Type): Stream.Stream => - Stream.unwrap( - Effect.sync(() => { - const definition = EventV2.registry.get(type) - if (!definition) throw new Error(`Unknown event type: ${type}`) - const encode = Schema.encodeUnknownSync(definition.data as Schema.Codec) - return events.subscribe(definition).pipe( - Stream.map( - (event) => - ({ - id: event.id, - type: event.type, - properties: encode(event.data), - }) as unknown as EventMap[Type], - ), - ) - }), - ), - }, - filesystem: { - read: (input) => filesystem.read(Schema.decodeUnknownSync(FileSystem.ReadInput)(input)), - list: (input) => filesystem.list(Schema.decodeUnknownSync(FileSystem.ListInput)(input ?? {})), - find: (input) => filesystem.find(Schema.decodeUnknownSync(FileSystem.FindInput)(input)), - glob: (input) => filesystem.glob(Schema.decodeUnknownSync(FileSystem.GlobInput)(input)), - }, integration: { - get: (id) => integration.get(Integration.ID.make(id)), - list: integration.list, - rebuild: integration.rebuild, + reload: integration.reload, transform: (callback) => integration.transform((draft) => callback({ @@ -198,19 +125,12 @@ export const make = Effect.fn("PluginHost.make")(function* () { }), ), }, - location, - npm, - path: { - home: global.home, - data: global.data, - cache: global.cache, - config: global.config, - state: global.state, - temp: global.tmp, + plugin: { + reload: plugin.reload, + transform: plugin.transform, }, reference: { - list: reference.list, - rebuild: reference.rebuild, + reload: reference.reload, transform: (callback) => reference.transform((draft) => callback({ @@ -221,9 +141,7 @@ export const make = Effect.fn("PluginHost.make")(function* () { ), }, skill: { - sources: skill.sources, - list: skill.list, - rebuild: skill.rebuild, + reload: skill.reload, transform: (callback) => skill.transform((draft) => callback({ diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts new file mode 100644 index 000000000000..ba7e248f684a --- /dev/null +++ b/packages/core/src/plugin/internal.ts @@ -0,0 +1,43 @@ +export * as PluginInternal from "./internal" + +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Effect, Scope } from "effect" +import type { AgentV2 } from "../agent" +import type { Catalog } from "../catalog" +import type { CommandV2 } from "../command" +import type { Config } from "../config" +import type { EventV2 } from "../event" +import type { FileSystem } from "../filesystem" +import type { FSUtil } from "../fs-util" +import type { Global } from "../global" +import type { Integration } from "../integration" +import type { Location } from "../location" +import type { ModelsDev } from "../models-dev" +import type { Npm } from "../npm" +import type { Reference } from "../reference" +import type { SkillV2 } from "../skill" + +export type Requirements = + | AgentV2.Service + | Catalog.Service + | CommandV2.Service + | Config.Service + | EventV2.Service + | FileSystem.Service + | FSUtil.Service + | Global.Service + | Integration.Service + | Location.Service + | ModelsDev.Service + | Npm.Service + | Reference.Service + | SkillV2.Service + +export interface Plugin { + readonly id: string + readonly effect: (context: PluginContext) => Effect.Effect +} + +export function define(plugin: Plugin) { + return plugin +} diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 34f46685007c..04f1f092a09b 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,5 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "./internal" import { Effect, Stream } from "effect" +import { EventV2 } from "../event" import { ModelV2 } from "../model" import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" @@ -52,6 +53,7 @@ export const ModelsDevPlugin = define({ id: "models-dev", effect: Effect.fn(function* (ctx) { const modelsDev = yield* ModelsDev.Service + const events = yield* EventV2.Service yield* ctx.integration.transform( Effect.fn(function* (integrations) { const data = yield* modelsDev.get() @@ -128,8 +130,8 @@ export const ModelsDevPlugin = define({ } }), ) - yield* ctx.event.subscribe("models-dev.refreshed").pipe( - Stream.runForEach(() => ctx.integration.rebuild().pipe(Effect.andThen(ctx.catalog.rebuild()))), + yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( + Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))), Effect.forkScoped({ startImmediately: true }), ) }), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts new file mode 100644 index 000000000000..58fb4ba0dcb8 --- /dev/null +++ b/packages/core/src/plugin/promise.ts @@ -0,0 +1,86 @@ +export * as PluginPromise from "./promise" + +import { define } from "@opencode-ai/plugin/v2/effect" +import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise" +import { Effect, Scope } from "effect" + +// The Effect host hands back this registration shape; mirror it structurally so +// we do not have to alias the Effect package's `Registration` against the Promise one. +type HostRegistration = { readonly dispose: Effect.Effect } + +/** + * Adapts a Promise plugin into an Effect plugin so the existing Effect-only + * loader (`PluginV2` / `PluginBoot`) can run it unchanged. + * + * Hook registrations created during the async `setup` attach to the plugin's + * scope, so unloading the plugin disposes them. The captured fiber context + * preserves boot-time batching, so Promise-plugin transforms still coalesce + * into one reload per domain. + */ +export function fromPromise(plugin: Plugin) { + return define({ + id: plugin.id, + effect: (host) => + Effect.gen(function* () { + const scope = yield* Scope.Scope + const context = yield* Effect.context() + + // Run a hook registration on the plugin scope and resolve once it is registered. + const register = (effect: Effect.Effect): Promise => + Effect.runPromiseWith(context)(Scope.provide(scope)(effect)).then((registration) => ({ + dispose: () => Effect.runPromiseWith(context)(registration.dispose), + })) + + const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) + + const transform = + (domain: { + transform: ( + callback: (draft: Draft) => Effect.Effect | void, + ) => Effect.Effect + }) => + (callback: (draft: Draft) => Promise | void) => + register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft))))) + + const context2: PluginContext = { + options: host.options, + agent: { + transform: transform(host.agent), + reload: () => run(host.agent.reload()), + }, + aisdk: { + sdk: (callback) => + register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), + language: (callback) => + register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), + }, + catalog: { + transform: transform(host.catalog), + reload: () => run(host.catalog.reload()), + }, + command: { + transform: transform(host.command), + reload: () => run(host.command.reload()), + }, + integration: { + transform: transform(host.integration), + reload: () => run(host.integration.reload()), + }, + plugin: { + transform: transform(host.plugin), + reload: () => run(host.plugin.reload()), + }, + reference: { + transform: transform(host.reference), + reload: () => run(host.reference.reload()), + }, + skill: { + transform: transform(host.skill), + reload: () => run(host.skill.reload()), + }, + } + + yield* Effect.promise(() => Promise.resolve(plugin.setup(context2))) + }), + }) +} diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index ea3939b750de..1749b474ed33 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -30,8 +30,10 @@ import { VercelPlugin } from "./provider/vercel" import { VenicePlugin } from "./provider/venice" import { XAIPlugin } from "./provider/xai" import { ZenmuxPlugin } from "./provider/zenmux" +import type { PluginInternal } from "./internal" +import type { Scope } from "effect" -export const ProviderPlugins = [ +export const ProviderPlugins: PluginInternal.Plugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, AnthropicPlugin, diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index a75d0c0d08bf..c5c4be0d0bec 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const AlibabaPlugin = define({ id: "alibaba", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index fe7bc10365b7..0995cf1c1724 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type MantleSDK = { @@ -78,8 +78,7 @@ export const AmazonBedrockPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } @@ -112,8 +111,7 @@ export const AmazonBedrockPlugin = define({ evt.sdk = mod.createAmazonBedrock(options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if (evt.model.api.type === "aisdk" && evt.model.api.package === "@ai-sdk/amazon-bedrock/mantle") { diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 7c36d6dd9bee..cf883a0687f3 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const AnthropicPlugin = define({ id: "anthropic", @@ -16,8 +16,7 @@ export const AnthropicPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 9115dcefe3c9..2e1f9d9b48f4 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function selectLanguage(sdk: any, modelID: string, useChat: boolean) { @@ -28,8 +28,7 @@ export const AzurePlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { @@ -47,8 +46,7 @@ export const AzurePlugin = define({ evt.sdk = mod.createAzure(evt.options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) @@ -74,8 +72,7 @@ export const AzureCognitiveServicesPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage(evt.sdk, evt.model.api.id, Boolean(evt.options.useCompletionUrls)) diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index f82f3eacc657..0fd651160fe2 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const CerebrasPlugin = define({ id: "cerebras", @@ -15,8 +15,7 @@ export const CerebrasPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index d6ba76db60c0..d416f6f19d3a 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -1,13 +1,12 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect, Option, Schema } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const CloudflareAIGatewayPlugin = define({ id: "cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 3904ee5b833d..1a1c533eb552 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -1,7 +1,7 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" const providerID = ProviderV2.ID.make("cloudflare-workers-ai") @@ -21,8 +21,7 @@ export const CloudflareWorkersAIPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -38,8 +37,7 @@ export const CloudflareWorkersAIPlugin = define({ ) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.api.id) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index df9f64685d00..0ca0708577aa 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const CoherePlugin = define({ id: "cohere", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index 2f62029a57bc..1b23e08ba4a3 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const DeepInfraPlugin = define({ id: "deepinfra", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index 4ab7c738da12..c84a6ed51f89 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -1,18 +1,19 @@ import { Effect } from "effect" import { pathToFileURL } from "url" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" +import { Npm } from "../../npm" export const DynamicProviderPlugin = define({ id: "dynamic-provider", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + const npm = yield* Npm.Service + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.sdk) return const installedPath = evt.package.startsWith("file://") ? evt.package - : (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 6e8f91861083..f097dcaca3f6 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const GatewayPlugin = define({ id: "gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 6adc366c04f1..682579d7a99b 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function shouldUseResponses(modelID: string) { @@ -25,16 +25,14 @@ export const GithubCopilotPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 70af07164631..8723cdaac2e3 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -1,14 +1,13 @@ import os from "os" import { InstallationVersion } from "../../installation/version" import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" export const GitLabPlugin = define({ id: "gitlab", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) @@ -32,8 +31,7 @@ export const GitLabPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index e3d429504735..4e643c9f519e 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" function resolveProject(options: Record) { @@ -84,8 +84,7 @@ export const GoogleVertexPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) @@ -104,8 +103,7 @@ export const GoogleVertexPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) @@ -139,8 +137,7 @@ export const GoogleVertexAnthropicPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) @@ -166,8 +163,7 @@ export const GoogleVertexAnthropicPlugin = define({ }) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.api.id).trim()) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 19b240b70167..476af5b91213 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const GooglePlugin = define({ id: "google", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index 6a6e14ae6d6c..0bddb4430950 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const GroqPlugin = define({ id: "groq", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index f57322a90300..6ee6670ee5dd 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const KiloPlugin = define({ id: "kilo", diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index 5c9802065bfc..eafc5edd6c7a 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,9 +1,11 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" +import { Integration } from "../../integration" export const LLMGatewayPlugin = define({ id: "llmgateway", effect: Effect.fn(function* (ctx) { + const integrations = yield* Integration.Service yield* ctx.catalog.transform( Effect.fn(function* (evt) { for (const item of evt.provider.list()) { @@ -11,7 +13,7 @@ export const LLMGatewayPlugin = define({ if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue - if (!(yield* ctx.integration.get(item.provider.id))) continue + if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue evt.provider.update(item.provider.id, (provider) => { provider.request.headers["HTTP-Referer"] = "https://opencode.ai/" provider.request.headers["X-Title"] = "opencode" diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index a799c2b451a2..a73197565989 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const MistralPlugin = define({ id: "mistral", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) diff --git a/packages/core/src/plugin/provider/nvidia.ts b/packages/core/src/plugin/provider/nvidia.ts index 25f695d95260..449599727c22 100644 --- a/packages/core/src/plugin/provider/nvidia.ts +++ b/packages/core/src/plugin/provider/nvidia.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const NvidiaPlugin = define({ id: "nvidia", diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index de2da085fe05..d602ed0ff957 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const OpenAICompatiblePlugin = define({ id: "openai-compatible", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 07fb2fec9752..c1734d62a190 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" import { Integration } from "../../integration" import { browser, headless } from "./openai-auth" @@ -27,16 +27,14 @@ export const OpenAIPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) evt.sdk = mod.createOpenAI(evt.options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.openai) return evt.language = evt.sdk.responses(evt.model.api.id) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 1414d5a1ecee..d50992b51dfe 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,16 +1,18 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" +import { Integration } from "../../integration" export const OpencodePlugin = define({ id: "opencode", effect: Effect.fn(function* (ctx) { + const integrations = yield* Integration.Service let hasKey = false yield* ctx.catalog.transform( Effect.fn(function* (evt) { const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return - const integration = yield* ctx.integration.get(item.provider.id) + const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) hasKey = Boolean( process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, ) diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index 81c4911d969e..0f295fb0950f 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -1,6 +1,6 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const OpenRouterPlugin = define({ id: "openrouter", @@ -25,8 +25,7 @@ export const OpenRouterPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index c9e1873deebf..44c1ef2fc0a1 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const PerplexityPlugin = define({ id: "perplexity", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index b3675961bf2e..8c668d8b4147 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -1,13 +1,14 @@ import { Effect } from "effect" import { pathToFileURL } from "url" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" +import { Npm } from "../../npm" import { ProviderV2 } from "../../provider" export const SapAICorePlugin = define({ id: "sap-ai-core", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + const npm = yield* Npm.Service + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = @@ -17,7 +18,7 @@ export const SapAICorePlugin = define({ const installedPath = evt.package.startsWith("file://") ? evt.package - : (yield* ctx.npm.add(evt.package).pipe(Effect.orDie)).entrypoint + : (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`) const mod = yield* Effect.promise(async () => { @@ -35,8 +36,7 @@ export const SapAICorePlugin = define({ ) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.api.id) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 48e5e73aad95..788ac63eb037 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise @@ -67,8 +67,7 @@ export function cortexFetch(upstream: FetchLike = fetch) { export const SnowflakeCortexPlugin = define({ id: "snowflake-cortex", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index 10eb849bafca..8022e0de668c 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const TogetherAIPlugin = define({ id: "togetherai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index 2d2bc4dc91e0..1a602ffd50be 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -1,11 +1,10 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const VenicePlugin = define({ id: "venice", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 45f117158d25..00f5601430cd 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const VercelPlugin = define({ id: "vercel", @@ -16,8 +16,7 @@ export const VercelPlugin = define({ } }), ) - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index 5fc10e8675be..8145a3480a0a 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -1,20 +1,18 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" import { ProviderV2 } from "../../provider" export const XAIPlugin = define({ id: "xai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.hook( - "sdk", + yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), ) - yield* ctx.aisdk.hook( - "language", + yield* ctx.aisdk.language( Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.api.id) diff --git a/packages/core/src/plugin/provider/zenmux.ts b/packages/core/src/plugin/provider/zenmux.ts index 497561e00ed3..29adebc0ee5c 100644 --- a/packages/core/src/plugin/provider/zenmux.ts +++ b/packages/core/src/plugin/provider/zenmux.ts @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "../internal" export const ZenmuxPlugin = define({ id: "zenmux", diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 1dec8ba3570a..0e9c85abe9aa 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -2,7 +2,7 @@ export * as SkillPlugin from "./skill" -import { define } from "@opencode-ai/plugin/v2/effect" +import { define } from "./internal" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 0e3246b3b2aa..441c380d7c7a 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -13,7 +13,6 @@ import { Slug } from "../util/slug" import { EventV2 } from "../event" import { Database } from "../database/database" import { Location } from "../location" -import { PluginBoot } from "../plugin/boot" export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID")) export type StrategyID = typeof StrategyID.Type @@ -125,10 +124,8 @@ export class Service extends Context.Service()("@opencode/Pr export const refreshAfterBoot = Effect.gen(function* () { const location = yield* Location.Service - const boot = yield* PluginBoot.Service const copies = yield* Service yield* Effect.gen(function* () { - yield* boot.wait() yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id }) const result = yield* copies.refresh({ projectID: location.project.id }) yield* Effect.logInfo("project copy refresh done", { diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 5ed46d76e130..9572f55a22d2 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -126,7 +126,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, list: Effect.fn("Reference.list")(function* () { return Array.from(materialized.values()) }), diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index f567264768a1..fa3423c58ac9 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -1,7 +1,6 @@ export * as ReferenceGuidance from "./guidance" import { Context, Effect, Layer, Schema } from "effect" -import { PluginBoot } from "../plugin/boot" import { Reference } from "../reference" import { SystemContext } from "../system-context/index" @@ -34,12 +33,10 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const boot = yield* PluginBoot.Service const references = yield* Reference.Service return Service.of({ load: Effect.fn("ReferenceGuidance.load")(function* () { - yield* boot.wait() const available = (yield* references.list()) .filter((reference) => reference.description !== undefined) .map((reference) => ({ diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 968933a6b52a..68e4ba5e6ac2 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -13,7 +13,6 @@ import { Integration } from "../../integration" import { IntegrationConnection } from "../../integration/connection" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" -import { PluginBoot } from "../../plugin/boot" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" @@ -178,11 +177,9 @@ export const locationLayer = Layer.effect( const catalog = yield* Catalog.Service const credentials = yield* Credential.Service const integrations = yield* Integration.Service - const boot = yield* PluginBoot.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { // Location plugins populate and filter the catalog asynchronously during layer startup. - yield* boot.wait() const defaultModel = session.model ? undefined : yield* catalog.model.default() const selected = session.model ? (yield* catalog.model.available()).find( diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 158fb4fab5e5..31e3d60c2229 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -148,7 +148,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - rebuild: state.rebuild, + reload: state.reload, sources: Effect.fn("SkillV2.sources")(function* () { return state.get().sources }), diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 92fb4c0a6290..81a33f016455 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -3,7 +3,6 @@ export * as SkillGuidance from "./guidance" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" -import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" import { SystemContext } from "../system-context/index" @@ -40,12 +39,10 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service return Service.of({ load: Effect.fn("SkillGuidance.load")(function* (selection) { - yield* boot.wait() const agent = selection.info if (!agent) return SystemContext.empty const permitted = SkillV2.available(yield* skills.list(), agent) diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 1c540e0e97cc..ab3457fc1814 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -3,7 +3,7 @@ export * as State from "./state" import { Context, Effect, Scope, Semaphore } from "effect" /** - * A replayable transform applied to a draft during rebuild. + * A replayable transform applied to a draft during reload. * * Domain drafts expose readable and writable state while preserving concise * plugin/config code. Transforms may perform Effects before returning. @@ -19,14 +19,14 @@ export type Transform = ( transform: TransformCallback, ) => Effect.Effect -export type Rebuild = () => Effect.Effect +export type Reload = () => Effect.Effect export interface Transformable { readonly transform: Transform - readonly rebuild: Rebuild + readonly reload: Reload } -const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { +const CurrentBatch = Context.Reference | undefined>("@opencode/State/CurrentBatch", { defaultValue: () => undefined, }) @@ -34,15 +34,15 @@ export function batch(effect: Effect.Effect) { return Effect.gen(function* () { const current = yield* CurrentBatch if (current) return yield* effect - const rebuilds = new Set() - const result = yield* effect.pipe(Effect.provideService(CurrentBatch, rebuilds)) - yield* Effect.forEach(rebuilds, (rebuild) => rebuild(), { discard: true }) + const reloads = new Set() + const result = yield* effect.pipe(Effect.provideService(CurrentBatch, reloads)) + yield* Effect.forEach(reloads, (reload) => reload(), { discard: true }) return result }) } export interface Options { - /** Creates the base value for initial state and every scoped-transform rebuild. */ + /** Creates the base value for initial state and every scoped-transform reload. */ readonly initial: () => State /** Wraps mutable state in a domain-specific draft API. */ readonly draft: MakeDraft @@ -54,7 +54,7 @@ export interface Interface extends Transformable { readonly get: () => State /** * Registers and applies a scoped transform. Closing the owning Scope removes - * the transform and rebuilds the materialized state. + * the transform and reloads the materialized state. */ } @@ -78,11 +78,11 @@ export function create(options: Options): Inte const materialize = Effect.fnUntraced(function* () { const next = options.initial() const api = options.draft(next) - for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.rebuild.update")) + for (const transform of transforms) yield* apply(transform.run, api).pipe(Effect.withSpan("State.reload.update")) yield* commit(next) }) - const rebuild = () => semaphore.withPermit(materialize()) + const reload = () => semaphore.withPermit(materialize()) const result: Interface = { get: () => state, @@ -101,7 +101,7 @@ export function create(options: Options): Inte return Effect.gen(function* () { const batch = yield* CurrentBatch if (batch) { - batch.add(rebuild) + batch.add(reload) return } yield* materialize() @@ -116,13 +116,13 @@ export function create(options: Options): Inte ) yield* Scope.addFinalizer(scope, dispose) const batch = yield* CurrentBatch - if (batch) batch.add(rebuild) - else yield* rebuild() + if (batch) batch.add(reload) + else yield* reload() return { dispose } }), ) }), - rebuild, + reload, } return result } diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 589a99d46227..ce25e785273b 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -5,7 +5,6 @@ import { pathToFileURL } from "url" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import { FSUtil } from "../fs-util" -import { PluginBoot } from "../plugin/boot" import { SkillV2 } from "../skill" import { PermissionV2 } from "../permission" import { Tool } from "./tool" @@ -58,10 +57,8 @@ export const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* Tools.Service const fs = yield* FSUtil.Service - const boot = yield* PluginBoot.Service const skills = yield* SkillV2.Service const permission = yield* PermissionV2.Service - yield* boot.wait() yield* tools .register({ [name]: Tool.make({ diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index f8b8d4eb2edd..ef8d0747d8dc 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -50,7 +50,7 @@ describe("AgentV2", () => { ) description = "New description" hidden = false - yield* agent.rebuild() + yield* agent.reload() expect(yield* agent.get(id)).toMatchObject({ description: "New description", hidden: false }) }), @@ -104,8 +104,12 @@ describe("AgentV2", () => { yield* AgentPlugin.Plugin.effect( host({ agent: agentHost(agent), - location: location({ directory: AbsolutePath.make("/project") }), }), + ).pipe( + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/project") })), + ), ) const agents = yield* agent.all() diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index bb4b256f8937..9890f7e79444 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -259,7 +259,7 @@ describe("CatalogV2", () => { expect((yield* catalog.model.default())?.id).toBe(old) configured = false - yield* catalog.rebuild() + yield* catalog.reload() expect((yield* catalog.model.default())?.id).toBe(newest) }), ) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index bc84d9cdb58c..11707c202634 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -42,7 +42,7 @@ Review files`, }) const command = yield* CommandV2.Service - yield* ConfigCommandPlugin.Plugin.effect(host({ command })).pipe( + yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe( Effect.provideService( Config.Service, Config.Service.of({ diff --git a/packages/core/test/config/fixtures/plugin/directory-plugin.ts b/packages/core/test/config/fixtures/plugin/directory-plugin.ts new file mode 100644 index 000000000000..e26e12bdac7a --- /dev/null +++ b/packages/core/test/config/fixtures/plugin/directory-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@opencode-ai/plugin/v2/promise" + +export default define({ + id: "directory-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("directory", (agent) => { + agent.description = "Loaded from plugin directory" + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts new file mode 100644 index 000000000000..e6944368c471 --- /dev/null +++ b/packages/core/test/config/plugin.test.ts @@ -0,0 +1,248 @@ +import path from "path" +import { describe, expect } from "bun:test" +import { Effect, Schema } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { Config } from "@opencode-ai/core/config" +import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" +import { Npm } from "@opencode-ai/core/npm" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "../plugin/fixture" + +const it = testEffect(PluginTestLayer) +const decode = Schema.decodeUnknownSync(Config.Info) + +describe("ConfigExternalPlugin", () => { + it.live("resolves and loads a configured Promise plugin with options", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + const document = path.join(import.meta.dir, "config.json") + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: document, + info: decode({ + plugins: [ + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: "Loaded from config" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Loaded from config", + mode: "subagent", + }) + }), + ) + + it.live("loads a configured Effect plugin with options", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: path.join(import.meta.dir, "config.json"), + info: decode({ + plugins: [ + { + package: "../plugin/fixtures/config-effect-plugin.ts", + options: { description: "Effect plugin from config" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "effect-configured")).toMatchObject({ + description: "Effect plugin from config", + mode: "subagent", + }) + }), + ) + + it.live("ignores invalid plugins and continues loading", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + path: path.join(import.meta.dir, "config.json"), + info: decode({ + plugins: [ + "../plugin/fixtures/missing-plugin.ts", + "../plugin/fixtures/invalid-plugin.ts", + { + package: "../plugin/fixtures/config-promise-plugin.ts", + options: { description: "Loaded after invalid plugins" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Loaded after invalid plugins", + }) + }), + ) + + it.live("installs and resolves npm plugin packages", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const host = yield* PluginHost.make(plugins) + let installed: string | undefined + const npm = Npm.Service.of({ + add: (spec) => + Effect.sync(() => { + installed = spec + return { + directory: import.meta.dir, + entrypoint: path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts"), + } + }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + plugins: [ + { + package: "example-plugin@1.0.0", + options: { description: "Installed from npm" }, + }, + ], + }), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "configured")).toMatchObject({ + description: "Installed from npm", + }) + expect(installed).toBe("example-plugin@1.0.0") + }), + ) + + it.live("loads plugin files from config directories", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const npm = yield* Npm.Service + const host = yield* PluginHost.make(plugins) + + yield* ConfigExternalPlugin.Plugin.effect(host).pipe( + Effect.provideService(PluginV2.Service, plugins), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(Location.Service, location), + Effect.provideService(Npm.Service, npm), + Effect.provideService( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Directory({ + type: "directory", + path: AbsolutePath.make(path.join(import.meta.dir, "fixtures")), + }), + ]), + }), + ), + ) + + expect(yield* waitForAgent(agents, "directory")).toMatchObject({ + description: "Loaded from plugin directory", + mode: "subagent", + }) + }), + ) +}) + +const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) { + for (let attempt = 0; attempt < 100; attempt++) { + const agent = yield* agents.get(AgentV2.ID.make(id)) + if (agent) return agent + yield* Effect.sleep("10 millis") + } + return yield* Effect.die(`Timed out waiting for agent ${id}`) +}) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 19311363edc2..12f4a01c7888 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -15,11 +15,8 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* (config: Config.Interface) { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)), - }) + const host = yield* PluginHost.make(plugin) + yield* ConfigProviderPlugin.Plugin.effect(host).pipe(Effect.provideService(Config.Service, config)) }) function required(value: T | undefined): T { diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 2f86714bb2a2..e3cbfd557c9f 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -36,16 +36,11 @@ describe("ConfigSkillPlugin.Plugin", () => { yield* ConfigSkillPlugin.Plugin.effect( host({ - location: location({ directory }), - path: { ...host().path, home: "/home/test" }, - skill: SkillV2.Service.of({ - transform, - rebuild: () => Effect.void, - sources: () => Effect.succeed(sources), - list: () => Effect.succeed([]), - }), + skill: { transform, reload: () => Effect.void }, }), ).pipe( + Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })), + Effect.provideService(Location.Service, Location.Service.of(location({ directory }))), Effect.provideService( Config.Service, Config.Service.of({ diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 0b3e0c8e54f1..67e811558e5f 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,15 +1,15 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { DateTime, Deferred, Effect, Equal, Hash, Layer, Schema, Stream } from "effect" +import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect" import { Tool } from "@opencode-ai/core/public" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" +import { PluginV2 } from "@opencode-ai/core/plugin" import { ModelV2 } from "@opencode-ai/core/model" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { ProjectV2 } from "@opencode-ai/core/project" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -88,7 +88,6 @@ describe("LocationServiceMap", () => { const update = (directory: string) => Effect.gen(function* () { - yield* PluginBoot.Service.use((boot) => boot.wait()) yield* Reference.Service const catalog = yield* Catalog.Service yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) @@ -197,36 +196,24 @@ describe("LocationServiceMap", () => { ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { - const boot = yield* PluginBoot.Service - const catalogUpdated = yield* Deferred.make() - const seen: string[] = [] - yield* boot.add( - define({ - id: "reviewer", - effect: (ctx) => - Effect.gen(function* () { - yield* ctx.event.subscribe("catalog.updated").pipe( - Stream.runForEach(() => Deferred.succeed(catalogUpdated, undefined).pipe(Effect.asVoid)), - Effect.forkScoped({ startImmediately: true }), - ) - yield* ctx.agent.transform((agent) => { - agent.update("reviewer", (item) => { - item.description = "Reviews code" - item.mode = "subagent" + const plugins = yield* PluginV2.Service + yield* plugins.transform((draft) => + draft.add( + define({ + id: "reviewer", + effect: (ctx) => + ctx.agent + .transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) }) - }) - seen.push((yield* ctx.agent.get("reviewer"))?.description ?? "") - yield* ctx.catalog.transform((catalog) => { - catalog.provider.update("public", (provider) => { - provider.name = "Public provider" - }) - }) - }), - }), + .pipe(Effect.asVoid), + }), + ), ) - yield* Deferred.await(catalogUpdated) - expect(seen).toEqual(["Reviews code"]) expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ description: "Reviews code", mode: "subagent", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index d8fe74336bd4..a662ed7ca001 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,127 +1,44 @@ import { describe, expect } from "bun:test" -import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect" -import { EventV2 } from "@opencode-ai/core/event" +import { Effect } from "effect" +import { define } from "@opencode-ai/plugin/v2/effect" +import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" -import { State } from "@opencode-ai/core/state" -import { it } from "./lib/effect" +import { testEffect } from "./lib/effect" +import { PluginTestLayer } from "./plugin/fixture" -const events = Layer.mock(EventV2.Service)({ - publish: (definition, data) => - Effect.succeed({ - id: EventV2.ID.make("evt_plugin_test"), - type: definition.type, - data, - }), -}) -const plugins = PluginV2.layer.pipe(Layer.provide(events)) - -function state() { - return State.create({ - initial: () => ({ values: [] as string[] }), - draft: (draft) => ({ - add: (value: string) => draft.values.push(value), - }), - }) -} +const it = testEffect(PluginTestLayer) describe("PluginV2", () => { - it.effect("closes plugin-owned scopes when the registry layer finalizes", () => - Effect.gen(function* () { - const values = state() - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) - - yield* plugin.add({ - id: PluginV2.ID.make("scoped"), - effect: Effect.gen(function* () { - yield* values.transform((editor) => { - editor.add("scoped") - }) - }), - }) - expect(values.get().values).toEqual(["scoped"]) - - yield* Scope.close(layerScope, Exit.void) - expect(values.get().values).toEqual([]) - }), - ) - - it.effect("batches plugin state rebuilds when the registry layer finalizes", () => + it.effect("reconciles transformed plugins", () => Effect.gen(function* () { - let finalized = 0 - const values = State.create({ - initial: () => ({ values: [] as string[] }), - draft: (draft) => ({ add: (value: string) => draft.values.push(value) }), - finalize: () => Effect.sync(() => finalized++), - }) - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) - - yield* State.batch( - Effect.forEach( - ["first", "second"], - (id) => - plugin.add({ - id: PluginV2.ID.make(id), - effect: values - .transform((editor) => { - editor.add(id) - }) + const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service + let description = "first" + + const registration = yield* plugins.transform((draft) => { + draft.add( + define({ + id: "managed", + effect: (ctx) => + ctx.agent + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = description + }), + ) .pipe(Effect.asVoid), - }), - { discard: true }, - ), - ) - finalized = 0 - - yield* Scope.close(layerScope, Exit.void) - expect(values.get().values).toEqual([]) - expect(finalized).toBe(1) - }), - ) - - it.effect("serializes same-ID additions and leaves one removable attachment", () => - Effect.gen(function* () { - const values = state() - const layerScope = yield* Scope.fork(yield* Scope.Scope) - const plugin = Context.get(yield* Layer.buildWithScope(Layer.fresh(plugins), layerScope), PluginV2.Service) - const id = PluginV2.ID.make("shared") - const firstStarted = yield* Deferred.make() - const releaseFirst = yield* Deferred.make() - - const first = yield* plugin - .add({ - id, - effect: Effect.gen(function* () { - yield* values.transform((editor) => { - editor.add("first") - }) - yield* Deferred.succeed(firstStarted, undefined) - yield* Deferred.await(releaseFirst) }), - }) - .pipe(Effect.forkChild) - yield* Deferred.await(firstStarted) + ) + }) - const second = yield* plugin - .add({ - id, - effect: Effect.gen(function* () { - yield* values.transform((editor) => { - editor.add("second") - }) - }), - }) - .pipe(Effect.forkChild({ startImmediately: true })) - expect(values.get().values).toEqual(["first"]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") - yield* Deferred.succeed(releaseFirst, undefined) - yield* Fiber.join(first) - yield* Fiber.join(second) - expect(values.get().values).toEqual(["second"]) + description = "second" + yield* plugins.reload() + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") - yield* plugin.remove(id) - expect(values.get().values).toEqual([]) + yield* registration.dispose + expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) }) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index d9d68e98b187..d4e2500c2187 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -24,9 +24,13 @@ describe("CommandPlugin.Plugin", () => { const command = yield* CommandV2.Service yield* CommandPlugin.Plugin.effect( host({ - command, - location: location({ directory }, { projectDirectory: project }), + command: { transform: command.transform, reload: command.reload }, }), + ).pipe( + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory }, { projectDirectory: project })), + ), ) expect(yield* command.get("init")).toMatchObject({ diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 3faa65a6587c..4062d37f7a23 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -1,6 +1,3 @@ -import { AgentV2 } from "@opencode-ai/core/agent" -import { Catalog } from "@opencode-ai/core/catalog" -import { CommandV2 } from "@opencode-ai/core/command" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { FileSystem } from "@opencode-ai/core/filesystem" @@ -8,23 +5,13 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" import { PluginV2 } from "@opencode-ai/core/plugin" -import { Reference } from "@opencode-ai/core/reference" import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { SkillV2 } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" import { Effect, Layer } from "effect" import { tempLocationLayer } from "../fixture/location" -export const PluginTestLayer = Layer.mergeAll( - AgentV2.locationLayer, - CommandV2.locationLayer, - Catalog.locationLayer, - FileSystem.locationLayer, - PluginV2.locationLayer, - Reference.locationLayer, - SkillV2.locationLayer, -).pipe( +export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe( Layer.provideMerge( Layer.mergeAll( Credential.defaultLayer, diff --git a/packages/core/test/plugin/fixtures/config-effect-plugin.ts b/packages/core/test/plugin/fixtures/config-effect-plugin.ts new file mode 100644 index 000000000000..a5f12a113da0 --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-effect-plugin.ts @@ -0,0 +1,15 @@ +import { define } from "@opencode-ai/plugin/v2/effect" +import { Effect } from "effect" + +export default define({ + id: "config-effect-plugin", + effect: (ctx) => + ctx.agent + .transform((agents) => { + agents.update("effect-configured", (agent) => { + agent.description = ctx.options.description + agent.mode = "subagent" + }) + }) + .pipe(Effect.asVoid), +}) diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts new file mode 100644 index 000000000000..ed53e4b947b6 --- /dev/null +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -0,0 +1,13 @@ +import { define } from "@opencode-ai/plugin/v2/promise" + +export default define({ + id: "config-promise-plugin", + setup: async (ctx) => { + await ctx.agent.transform((agents) => { + agents.update("configured", (agent) => { + agent.description = ctx.options.description + agent.mode = "subagent" + }) + }) + }, +}) diff --git a/packages/core/test/plugin/fixtures/invalid-plugin.ts b/packages/core/test/plugin/fixtures/invalid-plugin.ts new file mode 100644 index 000000000000..b1c6ea436a54 --- /dev/null +++ b/packages/core/test/plugin/fixtures/invalid-plugin.ts @@ -0,0 +1 @@ +export default {} diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index eb11dc30dd13..02bce652c273 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,120 +1,55 @@ -import type { AISDKHooks, PluginHost } from "@opencode-ai/plugin/v2/effect" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types" -import { Effect, Stream } from "effect" +import { Effect } from "effect" -export function host(overrides: Partial = {}): PluginHost { +type Overrides = Partial> + +export function host(overrides: Overrides = {}): PluginContext { return { - aisdk: { - hook: () => Effect.die("unused aisdk.hook"), - }, - agent: { - get: () => Effect.die("unused agent.get"), - default: () => Effect.die("unused agent.default"), - list: () => Effect.die("unused agent.list"), - rebuild: () => Effect.die("unused agent.rebuild"), + options: {}, + agent: overrides.agent ?? { transform: () => Effect.die("unused agent.transform"), + reload: () => Effect.die("unused agent.reload"), }, - catalog: { - provider: { - get: () => Effect.die("unused catalog.provider.get"), - list: () => Effect.die("unused catalog.provider.list"), - available: () => Effect.die("unused catalog.provider.available"), - }, - model: { - get: () => Effect.die("unused catalog.model.get"), - list: () => Effect.die("unused catalog.model.list"), - available: () => Effect.die("unused catalog.model.available"), - default: () => Effect.die("unused catalog.model.default"), - small: () => Effect.die("unused catalog.model.small"), - }, - rebuild: () => Effect.die("unused catalog.rebuild"), + aisdk: overrides.aisdk ?? { + sdk: () => Effect.die("unused aisdk.sdk"), + language: () => Effect.die("unused aisdk.language"), + }, + catalog: overrides.catalog ?? { transform: () => Effect.die("unused catalog.transform"), + reload: () => Effect.die("unused catalog.reload"), }, - command: { - get: () => Effect.die("unused command.get"), - list: () => Effect.die("unused command.list"), - rebuild: () => Effect.die("unused command.rebuild"), + command: overrides.command ?? { transform: () => Effect.die("unused command.transform"), + reload: () => Effect.die("unused command.reload"), }, - event: { - subscribe: () => Stream.die("unused event.subscribe"), - }, - filesystem: { - read: () => Effect.die("unused filesystem.read"), - list: () => Effect.die("unused filesystem.list"), - find: () => Effect.die("unused filesystem.find"), - glob: () => Effect.die("unused filesystem.glob"), - }, - integration: { - get: () => Effect.die("unused integration.get"), - list: () => Effect.die("unused integration.list"), - rebuild: () => Effect.die("unused integration.rebuild"), + integration: overrides.integration ?? { transform: () => Effect.die("unused integration.transform"), + reload: () => Effect.die("unused integration.reload"), }, - location: { - directory: "/unused/location", - project: { directory: "/unused/project" }, - }, - npm: { - add: () => Effect.die("unused npm.add"), + plugin: overrides.plugin ?? { + transform: () => Effect.die("unused plugin.transform"), + reload: () => Effect.die("unused plugin.reload"), }, - path: { - home: "/unused/home", - data: "/unused/data", - cache: "/unused/cache", - config: "/unused/config", - state: "/unused/state", - temp: "/unused/temp", - }, - reference: { - list: () => Effect.die("unused reference.list"), - rebuild: () => Effect.die("unused reference.rebuild"), + reference: overrides.reference ?? { transform: () => Effect.die("unused reference.transform"), + reload: () => Effect.die("unused reference.reload"), }, - skill: { - sources: () => Effect.die("unused skill.sources"), - list: () => Effect.die("unused skill.list"), - rebuild: () => Effect.die("unused skill.rebuild"), + skill: overrides.skill ?? { transform: () => Effect.die("unused skill.transform"), + reload: () => Effect.die("unused skill.reload"), }, - ...overrides, } } -export function aisdkHost(plugin: PluginV2.Interface): PluginHost["aisdk"] { +export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] { return { - hook: (name, callback) => { - if (name === "sdk") { - const run = callback as AISDKHooks["sdk"] - return plugin.hook("aisdk.sdk", (event) => { - const output = { ...event } - const result = run(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), - ) - }) - } - const run = callback as AISDKHooks["language"] - return plugin.hook("aisdk.language", (event) => { - const output = { ...event } - const result = run(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => Effect.sync(() => (event.language = output.language))), - ) - }) - }, - } -} - -export function agentHost(agent: AgentV2.Interface): PluginHost["agent"] { - return { - ...host().agent, + reload: agent.reload, transform: (callback) => agent.transform((draft) => callback({ @@ -136,10 +71,9 @@ export function agentHost(agent: AgentV2.Interface): PluginHost["agent"] { } } -export function catalogHost(catalog: Catalog.Interface): PluginHost["catalog"] { +export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] { return { - ...host().catalog, - rebuild: catalog.rebuild, + reload: catalog.reload, transform: (callback) => catalog.transform((draft) => callback({ @@ -201,17 +135,9 @@ export function catalogHost(catalog: Catalog.Interface): PluginHost["catalog"] { } } -export function integrationHost(integration: Integration.Interface): PluginHost["integration"] { - const info = (value: Integration.Info) => ({ - id: value.id, - name: value.name, - methods: value.methods.map(method), - connections: value.connections.map((item) => ({ ...item })), - }) +export function integrationHost(integration: Integration.Interface): PluginContext["integration"] { return { - get: (id) => integration.get(Integration.ID.make(id)).pipe(Effect.map((value) => value && info(value))), - list: () => integration.list().pipe(Effect.map((items) => items.map(info))), - rebuild: integration.rebuild, + reload: integration.reload, transform: (callback) => integration.transform((draft) => callback({ diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index c872b6fe65eb..4c3071c77481 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1,15 +1,13 @@ import path from "path" import { describe, expect } from "bun:test" -import { Effect, Layer, Stream } from "effect" +import { Effect, Layer } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" -import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { Flag } from "@opencode-ai/core/flag/flag" import { Location } from "@opencode-ai/core/location" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { PluginV2 } from "@opencode-ai/core/plugin" import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" import { Policy } from "@opencode-ai/core/policy" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -22,21 +20,13 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), ) -const plugins = PluginV2.layer.pipe(Layer.provide(events)) const policy = Policy.layer.pipe(Layer.provide(locationLayer)) const connections = Credential.defaultLayer.pipe(Layer.fresh) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) const catalog = Catalog.layer.pipe( - Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), -) -const layer = Layer.mergeAll( - catalog.pipe(Layer.provide(connections)), - integrations, - connections, - events, - locationLayer, - plugins, + Layer.provide(Layer.mergeAll(events, locationLayer, policy, connections, integrations)), ) +const layer = Layer.mergeAll(catalog.pipe(Layer.provide(connections)), integrations, connections, events, locationLayer) const it = testEffect(layer) describe("ModelsDevPlugin", () => { @@ -58,7 +48,6 @@ describe("ModelsDevPlugin", () => { yield* ModelsDevPlugin.effect( host({ catalog: catalogHost(catalog), - event: { subscribe: () => Stream.never }, integration: integrationHost(integrations), }), ) diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts new file mode 100644 index 000000000000..41a664194642 --- /dev/null +++ b/packages/core/test/plugin/promise.test.ts @@ -0,0 +1,67 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { AgentV2 } from "@opencode-ai/core/agent" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { PluginPromise } from "@opencode-ai/core/plugin/promise" +import { define } from "@opencode-ai/plugin/v2/promise" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +describe("fromPromise", () => { + it.effect("loads a promise plugin and registers a transform hook", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-example", + setup: async (ctx) => { + expect(ctx.options.mode).toBe("strict") + await ctx.agent.transform((draft) => { + draft.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect({ ...host, options: { mode: "strict" } }) + + expect(yield* agents.get(AgentV2.ID.make("reviewer"))).toMatchObject({ + description: "Reviews code", + mode: "subagent", + }) + }), + ) + + it.effect("disposes a hook registration on request", () => + Effect.gen(function* () { + const agents = yield* AgentV2.Service + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + + const promisePlugin = define({ + id: "promise-dispose", + setup: async (ctx) => { + const registration = await ctx.agent.transform((draft) => { + draft.update("temp", (item) => { + item.description = "temporary" + }) + }) + await registration.dispose() + }, + }) + + const adapted = PluginPromise.fromPromise(promisePlugin) + yield* adapted.effect(host) + + expect(yield* agents.get(AgentV2.ID.make("temp"))).toBeUndefined() + }), + ) +}) diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts index 5fb8b16bf00c..cda7b23e2d9e 100644 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ b/packages/core/test/plugin/provider-alibaba.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { createAlibaba } from "@ai-sdk/alibaba" import { Effect } from "effect" @@ -13,27 +14,25 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AlibabaPlugin.id, effect: AlibabaPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AlibabaPlugin.effect(host) }) describe("AlibabaPlugin", () => { it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/alibaba", - options: { name: "alibaba" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "alibaba" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -41,19 +40,16 @@ describe("AlibabaPlugin", () => { it.effect("ignores non-Alibaba SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "alibaba" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "alibaba" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -61,19 +57,16 @@ describe("AlibabaPlugin", () => { it.effect("matches the old bundled Alibaba SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), - api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/alibaba", - options: { name: "custom-alibaba", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), + api: { id: ModelV2.ID.make("qwen"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/alibaba", + options: { name: "custom-alibaba", apiKey: "test" }, + }) const expected = createAlibaba({ apiKey: "test", ...{ name: "custom-alibaba" } }).languageModel("qwen") const actual = result.sdk?.languageModel("qwen") expect(actual?.provider).toBe(expected.provider) @@ -84,12 +77,13 @@ describe("AlibabaPlugin", () => { it.effect("uses the old default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() const item = new ModelV2.Info({ ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), api: { id: ModelV2.ID.make("qwen-plus"), type: "aisdk", package: "test-provider" }, }) - const result = yield* plugin.trigger("aisdk.sdk", { model: item, package: "@ai-sdk/alibaba", options: {} }, {}) + const result = yield* aisdk.runSDK({ model: item, package: "@ai-sdk/alibaba", options: {} }) const language = result.sdk?.languageModel(item.api.id) expect(language?.modelId).toBe("qwen-plus") expect(language?.provider).toBe("alibaba.chat") diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index 1a2512485b04..b6ef65f1aef6 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AmazonBedrockPlugin.id, effect: AmazonBedrockPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AmazonBedrockPlugin.effect(host) }) function required(value: T | undefined): T { @@ -109,25 +111,22 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://base.example", - endpoint: "https://endpoint.example", - region: "us-east-1", - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + endpoint: "https://endpoint.example", + region: "us-east-1", }, - {}, - ) + }) expect(bedrockBaseURL(result.sdk)).toBe("https://endpoint.example") }), ), @@ -137,24 +136,21 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://base.example", - region: "us-east-1", - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://base.example", + region: "us-east-1", }, - {}, - ) + }) expect(bedrockBaseURL(result.sdk)).toBe("https://base.example") }), ), @@ -174,23 +170,20 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(result.sdk).toBeDefined() expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") }), @@ -201,19 +194,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "us-east-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock", region: "eu-west-1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock", region: "eu-west-1" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") }), ), @@ -223,19 +213,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.eu-west-1.amazonaws.com") }), ), @@ -245,19 +232,16 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "token", AWS_REGION: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { name: "amazon-bedrock" }, + }) expect(bedrockBaseURL(result.sdk)).toBe("https://bedrock-runtime.us-east-1.amazonaws.com") }), ), @@ -267,27 +251,24 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_ACCESS_KEY_ID: undefined, AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "option-token", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("option-token") expect(headers).toEqual(["Bearer option-token"]) @@ -299,27 +280,24 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: "env-token" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - bearerToken: "option-token", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + bearerToken: "option-token", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock.example", { method: "POST" })) expect(process.env.AWS_BEARER_TOKEN_BEDROCK).toBe("env-token") expect(headers).toEqual(["Bearer env-token"]) @@ -331,28 +309,25 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_BEARER_TOKEN_BEDROCK: undefined, AWS_PROFILE: undefined, AWS_ACCESS_KEY_ID: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), - api: { - id: ModelV2.ID.make("openai.gpt-5.5"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/mantle", - }, - }), - package: "@ai-sdk/amazon-bedrock/mantle", - options: { - name: "amazon-bedrock", - bearerToken: "token", - baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", - region: "us-east-2", + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", }, + }), + package: "@ai-sdk/amazon-bedrock/mantle", + options: { + name: "amazon-bedrock", + bearerToken: "token", + baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", + region: "us-east-2", }, - {}, - ) + }) const language = result.sdk.responses("openai.gpt-5.5") expect(openAIUrl(language, "/responses", "openai.gpt-5.5")).toBe( "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses", @@ -364,40 +339,33 @@ describe("AmazonBedrockPlugin", () => { it.effect("selects Mantle APIs without Bedrock cross-region prefixes", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), - api: { - id: ModelV2.ID.make("openai.gpt-5.5"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/mantle", - }, - }), - sdk: fakeSelectorSdk(calls), - options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), - api: { - id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/mantle", - }, - }), - sdk: fakeSelectorSdk(calls), - options: { region: "us-east-1" }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + api: { + id: ModelV2.ID.make("openai.gpt-5.5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, + }), + sdk: fakeSelectorSdk(calls), + options: { baseURL: "https://bedrock-mantle.us-east-2.api.aws/openai/v1", region: "us-east-2" }, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), + api: { + id: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/mantle", + }, + }), + sdk: fakeSelectorSdk(calls), + options: { region: "us-east-1" }, + }) expect(calls).toEqual(["responses:openai.gpt-5.5", "chat:openai.gpt-oss-safeguard-120b"]) }), ) @@ -405,23 +373,20 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores other Bedrock provider subpaths", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "@ai-sdk/amazon-bedrock/anthropic", - }, - }), - package: "@ai-sdk/amazon-bedrock/anthropic", - options: { name: "amazon-bedrock" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "@ai-sdk/amazon-bedrock/anthropic", + }, + }), + package: "@ai-sdk/amazon-bedrock/anthropic", + options: { name: "amazon-bedrock" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -438,30 +403,27 @@ describe("AmazonBedrockPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const headers: Array = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/amazon-bedrock", - options: { - name: "amazon-bedrock", - fetch: async (_input: Parameters[0], init?: RequestInit) => { - headers.push(new Headers(init?.headers).get("Authorization")) - return new Response("{}") - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/amazon-bedrock", + options: { + name: "amazon-bedrock", + fetch: async (_input: Parameters[0], init?: RequestInit) => { + headers.push(new Headers(init?.headers).get("Authorization")) + return new Response("{}") }, }, - {}, - ) + }) yield* Effect.promise(() => bedrockFetch(result.sdk)("https://bedrock-runtime.us-east-1.amazonaws.com/model/test/invoke", { body: "{}", @@ -476,72 +438,53 @@ describe("AmazonBedrockPlugin", () => { it.effect("applies legacy cross-region inference prefixes", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), - api: { - id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "ap-northeast-1" }, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "ap-southeast-2" }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), + api: { + id: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-northeast-1" }, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "ap-southeast-2" }, + }) expect(calls).toEqual([ "languageModel:us.anthropic.claude-sonnet-4-5", "languageModel:eu.anthropic.claude-sonnet-4-5", @@ -556,20 +499,17 @@ describe("AmazonBedrockPlugin", () => { withEnv({ AWS_REGION: "eu-west-1" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:eu.anthropic.claude-sonnet-4-5"]) }), ), @@ -578,6 +518,7 @@ describe("AmazonBedrockPlugin", () => { it.effect("applies the full legacy cross-region prefix matrix", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const cases = [ { region: "us-east-1", modelID: "amazon.nova-micro-v1:0", expected: "us.amazon.nova-micro-v1:0" }, @@ -647,18 +588,14 @@ describe("AmazonBedrockPlugin", () => { ] yield* addPlugin() for (const item of cases) { - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), - api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: item.region }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), + api: { id: ModelV2.ID.make(item.modelID), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: item.region }, + }) } expect(calls).toEqual(cases.map((item) => `languageModel:${item.expected}`)) }), @@ -667,20 +604,17 @@ describe("AmazonBedrockPlugin", () => { it.effect("ignores non-Bedrock providers for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: { region: "eu-west-1" }, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: { region: "eu-west-1" }, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index ba3a33915b08..8cf8d1555579 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AnthropicPlugin.id, effect: AnthropicPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AnthropicPlugin.effect(host) }) function required(value: T | undefined): T { @@ -59,19 +61,16 @@ describe("AnthropicPlugin", () => { it.effect("creates Anthropic SDKs with the model provider ID as the SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, - }), - package: "@ai-sdk/anthropic", - options: { name: "custom-anthropic", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), + package: "@ai-sdk/anthropic", + options: { name: "custom-anthropic", apiKey: "test" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("custom-anthropic") }), ) @@ -79,19 +78,16 @@ describe("AnthropicPlugin", () => { it.effect("uses the Anthropic provider ID as the SDK name for the bundled Anthropic provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, - }), - package: "@ai-sdk/anthropic", - options: { name: "anthropic", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "@ai-sdk/anthropic" }, + }), + package: "@ai-sdk/anthropic", + options: { name: "anthropic", apiKey: "test" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").provider).toBe("anthropic") }), ) diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index 2c1c7ec87889..08e878801a77 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AzureCognitiveServicesPlugin.id, effect: AzureCognitiveServicesPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AzureCognitiveServicesPlugin.effect(host) }) function required(value: T | undefined): T { @@ -114,20 +116,17 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("selects chat only for completion URLs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: { useCompletionUrls: true }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -135,32 +134,25 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("uses the legacy Azure selector order and provider guard", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - const ignored = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + const ignored = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) expect(ignored.language).toBeUndefined() }), @@ -169,51 +161,34 @@ describe("AzureCognitiveServicesPlugin", () => { it.effect("falls back from responses to messages, chat, then languageModel", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("azure-cognitive-services"), - ModelV2.ID.make("messages-deployment"), - ), - api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), - api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("azure-cognitive-services"), - ModelV2.ID.make("language-deployment"), - ), - api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: sdk.languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { messages: sdk.messages, chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), + api: { id: ModelV2.ID.make("chat-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { chat: sdk.chat, languageModel: sdk.languageModel }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: sdk.languageModel }, + options: {}, + }) expect(calls).toEqual([ "messages:messages-deployment", "chat:chat-deployment", diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 10c2a005dcca..b479435e5e7b 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: AzurePlugin.id, effect: AzurePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* AzurePlugin.effect(host) }) function required(value: T | undefined): T { @@ -142,19 +144,16 @@ describe("AzurePlugin", () => { withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/azure", - options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure", baseURL: "https://proxy.example.com/openai" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -163,21 +162,17 @@ describe("AzurePlugin", () => { it.effect("rejects missing resourceName when baseURL is not configured", () => withEnv({ AZURE_RESOURCE_NAME: undefined }, () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const exit = yield* plugin - .trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/azure", - options: { name: "azure" }, - }, - {}, - ) + const exit = yield* aisdk + .runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/azure", + options: { name: "azure" }, + }) .pipe(Effect.exit) expect(exit._tag).toBe("Failure") }), @@ -187,20 +182,17 @@ describe("AzurePlugin", () => { it.effect("selects chat only for completion URLs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: { useCompletionUrls: true }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -208,20 +200,17 @@ describe("AzurePlugin", () => { it.effect("selects chat from per-call useCompletionUrls", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: { useCompletionUrls: true }, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: { useCompletionUrls: true }, + }) expect(calls).toEqual(["chat:deployment"]) }), ) @@ -229,21 +218,18 @@ describe("AzurePlugin", () => { it.effect("ignores model useCompletionUrls when per-call option is unset", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - request: { headers: {}, body: { useCompletionUrls: true } }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + request: { headers: {}, body: { useCompletionUrls: true } }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) }), ) @@ -251,32 +237,25 @@ describe("AzurePlugin", () => { it.effect("uses the legacy Azure selector order and provider guard", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - const ignored = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), - api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + const ignored = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + api: { id: ModelV2.ID.make("deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:deployment"]) expect(ignored.language).toBeUndefined() }), @@ -285,36 +264,29 @@ describe("AzurePlugin", () => { it.effect("falls back through the legacy Azure selector order", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const make = (method: string) => (id: string) => { calls.push(`${method}:${id}`) return { modelId: id, provider: method, specificationVersion: "v3" } } yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), - api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), - api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: make("languageModel") }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), + api: { id: ModelV2.ID.make("messages-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { messages: make("messages"), chat: make("chat"), languageModel: make("languageModel") }, + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), + api: { id: ModelV2.ID.make("language-deployment"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: make("languageModel") }, + options: {}, + }) expect(calls).toEqual(["messages:messages-deployment", "languageModel:language-deployment"]) }), ) diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index 5501ad39e3fc..f741041e5451 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: CerebrasPlugin.id, effect: CerebrasPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CerebrasPlugin.effect(host) }) void mock.module("@ai-sdk/cerebras", () => ({ @@ -59,26 +61,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("custom-cerebras"), - ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - ), - api: { - id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/cerebras", - options: { name: "custom-cerebras", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/cerebras", + options: { name: "custom-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([{ name: "custom-cerebras", apiKey: "test" }]) expect(result.sdk.languageModel("llama-4-scout-17b-16e-instruct").provider).toBe("custom-cerebras") }), @@ -88,26 +87,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("custom-cerebras"), - ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - ), - api: { - id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/cerebras", - options: { name: "configured-cerebras", apiKey: "test" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/cerebras", + options: { name: "configured-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([{ name: "configured-cerebras", apiKey: "test" }]) }), ) @@ -116,26 +112,23 @@ describe("CerebrasPlugin", () => { Effect.gen(function* () { cerebrasOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("custom-cerebras"), - ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - ), - api: { - id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/groq", - options: { name: "custom-cerebras", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("custom-cerebras"), + ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + ), + api: { + id: ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/groq", + options: { name: "custom-cerebras", apiKey: "test" }, + }) expect(cerebrasOptions).toEqual([]) expect(result.sdk).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index 31ce4448f018..5bc8a3d5ff13 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -12,8 +13,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: CloudflareAIGatewayPlugin.id, effect: CloudflareAIGatewayPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CloudflareAIGatewayPlugin.effect(host) }) function withEnv(vars: Record, fx: () => Effect.Effect) { @@ -111,19 +113,16 @@ describe("CloudflareAIGatewayPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk.languageModel("openai/gpt-5")).toBeDefined() }), ), @@ -134,27 +133,24 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - metadata: { invoked_by: "test", project: "opencode" }, - cacheTtl: 300, - cacheKey: "cache-key", - skipCache: true, - collectLog: false, - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + metadata: { invoked_by: "test", project: "opencode" }, + cacheTtl: 300, + cacheKey: "cache-key", + skipCache: true, + collectLog: false, }, - {}, - ) + }) expect(aiGatewayCalls).toHaveLength(1) expect(aiGatewayCalls[0]).toEqual({ @@ -181,25 +177,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - headers: { - "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + headers: { + "cf-aig-metadata": JSON.stringify({ invoked_by: "header", project: "opencode" }), }, }, - {}, - ) + }) expect(aiGatewayCalls[0]?.options).toMatchObject({ metadata: { invoked_by: "header", project: "opencode" }, @@ -213,25 +206,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gateway: "auth-gateway", - apiKey: "auth-token", - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gateway: "auth-gateway", + apiKey: "auth-token", }, - {}, - ) + }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "env-account", @@ -253,25 +243,22 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { - name: "cloudflare-ai-gateway", - accountId: "auth-account", - gatewayId: "auth-gateway", - apiKey: "auth-token", - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { + name: "cloudflare-ai-gateway", + accountId: "auth-account", + gatewayId: "auth-gateway", + apiKey: "auth-token", }, - {}, - ) + }) expect(aiGatewayCalls[0]).toMatchObject({ accountId: "auth-account", @@ -287,20 +274,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(aiGatewayCalls[0]).toMatchObject({ apiKey: "cf-aig-token" }) }), @@ -312,20 +296,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -338,20 +319,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -370,20 +348,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway", baseURL: "https://proxy.example/v1" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) @@ -396,27 +371,24 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("cloudflare-ai-gateway"), - ModelV2.ID.make("anthropic/claude-sonnet-4-5"), - ), - api: { - id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "ai-gateway-provider", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("cloudflare-ai-gateway"), + ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + ), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4-5"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "ai-gateway-provider", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk.languageModel("anthropic/claude-sonnet-4-5")).toEqual({ modelId: { unifiedModelID: "anthropic/claude-sonnet-4-5" }, @@ -434,20 +406,17 @@ describe("CloudflareAIGatewayPlugin", () => { Effect.gen(function* () { resetCalls() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-ai-gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-ai-gateway" }, + }) expect(result.sdk).toBeUndefined() expect(aiGatewayCalls).toHaveLength(0) diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index f6da837d8bac..c911f740f5e1 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: CloudflareWorkersAIPlugin.id, effect: CloudflareWorkersAIPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CloudflareWorkersAIPlugin.effect(host) }) function required(value: T | undefined): T { @@ -81,6 +83,7 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { @@ -89,18 +92,14 @@ describe("CloudflareWorkersAIPlugin", () => { ) yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, - }, - {}, - ) + const sdk = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { id: ModelV2.ID.make("@cf/model"), ...provider.api }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", headers: { custom: "header" } }, + }) expect(provider.api).toEqual({ type: "aisdk", package: "test-provider", @@ -134,24 +133,21 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://proxy.example/v1", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai", baseURL: "https://proxy.example/v1" }, + }) expect(cloudflareURL(result.sdk)).toBe("https://proxy.example/v1/chat/completions") }), ), @@ -181,29 +177,26 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://proxy.example/v1", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - apiKey: "auth-key", - baseURL: "https://proxy.example/v1", - headers: { custom: "header" }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://proxy.example/v1", }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + apiKey: "auth-key", + baseURL: "https://proxy.example/v1", + headers: { custom: "header" }, }, - {}, - ) + }) const headers = yield* Effect.promise(() => Promise.resolve(cloudflareHeaders(result.sdk))) expect(headers.authorization).toBe("Bearer env-key") expect(headers.custom).toBe("header") @@ -216,27 +209,24 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "cloudflare-workers-ai", - baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "cloudflare-workers-ai", + baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", }, - {}, - ) + }) expect(cloudflareURL(result.sdk)).toBe( "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/chat/completions", ) @@ -247,20 +237,17 @@ describe("CloudflareWorkersAIPlugin", () => { it.effect("selects languageModel with the API model ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("@cf/api-model"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(result.language).toBeDefined() expect(calls).toEqual(["languageModel:@cf/api-model"]) }), @@ -270,24 +257,21 @@ describe("CloudflareWorkersAIPlugin", () => { withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), - api: { - id: ModelV2.ID.make("@cf/model"), - type: "aisdk", - package: "@ai-sdk/anthropic", - url: "https://proxy.example/v1", - }, - }), - package: "@ai-sdk/anthropic", - options: { name: "cloudflare-workers-ai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + api: { + id: ModelV2.ID.make("@cf/model"), + type: "aisdk", + package: "@ai-sdk/anthropic", + url: "https://proxy.example/v1", + }, + }), + package: "@ai-sdk/anthropic", + options: { name: "cloudflare-workers-ai" }, + }) expect(result.sdk).toBeUndefined() }), ), diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts index f0d09b8411ea..0ee465d6f95d 100644 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ b/packages/core/test/plugin/provider-cohere.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: CoherePlugin.id, effect: CoherePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* CoherePlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -48,34 +50,27 @@ describe("CoherePlugin", () => { it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cohere" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cohere" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/cohere", - options: { name: "cohere" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), + api: { id: ModelV2.ID.make("command"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "cohere" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -83,19 +78,16 @@ describe("CoherePlugin", () => { it.effect("uses the model provider ID as the bundled SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), - api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/cohere", - options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/cohere", + options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, + }) expect(cohereOptions.at(-1)).toEqual({ name: "custom-cohere", @@ -109,21 +101,18 @@ describe("CoherePlugin", () => { it.effect("leaves language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = fakeSelectorSdk(calls) yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, - }), - sdk, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("command-r-plus"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }) expect(result.language).toBeUndefined() expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts index db7dd1042974..d428b9b7e082 100644 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ b/packages/core/test/plugin/provider-deepinfra.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -14,8 +15,9 @@ const deepinfraLanguageModels: string[] = [] const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: DeepInfraPlugin.id, effect: DeepInfraPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* DeepInfraPlugin.effect(host) }) void mock.module("@ai-sdk/deepinfra", () => ({ @@ -41,19 +43,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -62,19 +61,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "custom-deepinfra", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "custom-deepinfra", apiKey: "test" }, + }) expect(result.sdk.languageModel("model").provider).toBe("custom-deepinfra.chat") expect(deepinfraOptions).toEqual([{ name: "custom-deepinfra", apiKey: "test" }]) }), @@ -84,19 +80,16 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra", apiKey: "test" }, + }) expect(result.sdk.languageModel("model").provider).toBe("deepinfra.chat") expect(deepinfraOptions).toEqual([{ name: "deepinfra", apiKey: "test" }]) }), @@ -106,6 +99,7 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() const packages = [ "unmatched-package", @@ -114,33 +108,25 @@ describe("DeepInfraPlugin", () => { ] yield* Effect.forEach(packages, (item) => Effect.gen(function* () { - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: item, - options: { name: "deepinfra" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: item, + options: { name: "deepinfra" }, + }) expect(ignored.sdk).toBeUndefined() }), ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "@ai-sdk/deepinfra" }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) expect(result.sdk).toBeDefined() expect(deepinfraOptions).toEqual([{ name: "deepinfra" }]) }), @@ -150,31 +136,21 @@ describe("DeepInfraPlugin", () => { Effect.gen(function* () { resetDeepInfraMock() const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdkEvent = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("deepinfra"), - ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), - ), - api: { - id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), - type: "aisdk", - package: "@ai-sdk/deepinfra", - }, - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }, - {}, - ) - const result = yield* plugin.trigger( - "aisdk.language", - { model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }, - {}, - ) + const sdkEvent = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct")), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), + type: "aisdk", + package: "@ai-sdk/deepinfra", + }, + }), + package: "@ai-sdk/deepinfra", + options: { name: "deepinfra" }, + }) + const result = yield* aisdk.runLanguage({ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }) const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.provider).toBe("deepinfra.chat") expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 150c9ea84d6c..548b1a92e99e 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -17,7 +17,7 @@ import { PluginTestLayer } from "./fixture" const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const fixtureProviderPath = fileURLToPath(fixtureProvider) const it = testEffect(PluginTestLayer) -const itWithAISDK = testEffect(AISDK.layer.pipe(Layer.provideMerge(PluginTestLayer))) +const itWithAISDK = testEffect(AISDK.locationLayer.pipe(Layer.provideMerge(PluginTestLayer))) function npmEntrypoint(entrypoint?: string) { return Npm.Service.of({ @@ -29,11 +29,8 @@ function npmEntrypoint(entrypoint?: string) { const addPlugin = Effect.fn(function* (npm?: Npm.Interface) { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ - id: DynamicProviderPlugin.id, - effect: DynamicProviderPlugin.effect(npm ? { ...host, npm } : host), - }) + const host = yield* PluginHost.make(plugin) + yield* DynamicProviderPlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm ?? (yield* Npm.Service))) }) function tempEntrypoint(source: string) { @@ -51,20 +48,16 @@ function tempEntrypoint(source: string) { describe("DynamicProviderPlugin", () => { it.effect("creates an SDK from a provider factory export", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, - }), - package: fixtureProvider, - options: { name: "custom", marker: "dynamic" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + }) expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom" }) expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "dynamic", name: "custom" } }) }), @@ -72,68 +65,56 @@ describe("DynamicProviderPlugin", () => { it.effect("does not override an SDK already supplied by an earlier plugin", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const sdk = { marker: "existing" } yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, - }), - package: fixtureProvider, - options: { name: "custom", marker: "dynamic" }, - }, - { sdk }, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom", marker: "dynamic" }, + sdk, + }) expect(result.sdk).toBe(sdk) }), ) it.effect("injects the provider ID as the SDK factory name", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, - }), - package: fixtureProvider, - options: { name: "custom-provider", marker: "dynamic" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: fixtureProvider }, + }), + package: fixtureProvider, + options: { name: "custom-provider", marker: "dynamic" }, + }) expect(result.sdk.options).toEqual({ marker: "dynamic", name: "custom-provider" }) }), ) it.effect("loads npm packages through their resolved import entrypoint", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(npmEntrypoint(fixtureProviderPath)) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), - api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, - }), - package: "fixture-provider", - options: { name: "npm-provider", marker: "npm" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), + api: { id: ModelV2.ID.make("test-model"), type: "aisdk", package: "fixture-provider" }, + }), + package: "fixture-provider", + options: { name: "npm-provider", marker: "npm" }, + }) expect(result.sdk.languageModel("x")).toEqual({ modelID: "x", options: { marker: "npm", name: "npm-provider" } }) }), ) itWithAISDK.effect("wraps missing npm entrypoint failures as AISDK init errors", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin(npmEntrypoint()) const exit = yield* aisdk @@ -151,7 +132,6 @@ describe("DynamicProviderPlugin", () => { itWithAISDK.effect("wraps dynamic import failures as AISDK init errors", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service const aisdk = yield* AISDK.Service yield* addPlugin() const exit = yield* aisdk diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts index 619e184d83ef..9179a0b042e8 100644 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ b/packages/core/test/plugin/provider-gateway.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GatewayPlugin.id, effect: GatewayPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GatewayPlugin.effect(host) }) mock.module("@ai-sdk/gateway", () => ({ @@ -38,19 +40,16 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/gateway", - options: { name: "gateway" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "gateway" }, + }) expect(result.sdk).toBeDefined() expect(gatewayCalls).toHaveLength(1) }), @@ -60,24 +59,21 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), - api: { - id: ModelV2.ID.make("anthropic/claude-sonnet-4"), - type: "aisdk", - package: "test-provider", - }, - }), - package: "@ai-sdk/gateway", - options: { name: "vercel", apiKey: "test-key" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), + api: { + id: ModelV2.ID.make("anthropic/claude-sonnet-4"), + type: "aisdk", + package: "test-provider", + }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel", apiKey: "test-key" }, + }) expect(gatewayCalls).toEqual([{ name: "vercel", apiKey: "test-key" }]) expect(result.sdk.languageModel("anthropic/claude-sonnet-4").provider).toBe("vercel") @@ -88,35 +84,28 @@ describe("GatewayPlugin", () => { Effect.gen(function* () { gatewayCalls.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() for (const modelID of vercelGatewayModels) { - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/vercel", - options: { name: "vercel" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/vercel", + options: { name: "vercel" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/gateway", - options: { name: "vercel" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), + api: { id: ModelV2.ID.make(modelID), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/gateway", + options: { name: "vercel" }, + }) expect(result.sdk).toBeDefined() } diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index b8f615f93374..03d644132d27 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GithubCopilotPlugin.id, effect: GithubCopilotPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GithubCopilotPlugin.effect(host) }) function required(value: T | undefined): T { @@ -40,31 +42,24 @@ describe("GithubCopilotPlugin", () => { it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "github-copilot" }, - }, - {}, - ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/github-copilot", - options: { name: "github-copilot" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "github-copilot" }, + }) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/github-copilot", + options: { name: "github-copilot" }, + }) expect(ignored.sdk).toBeUndefined() expect(result.sdk).toBeDefined() }), @@ -73,20 +68,17 @@ describe("GithubCopilotPlugin", () => { it.effect("selects languageModel when responses and chat are absent", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), - api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4"]) }), ) @@ -94,20 +86,17 @@ describe("GithubCopilotPlugin", () => { it.effect("selects languageModel with the API model ID when responses and chat are absent", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4"]) }), ) @@ -115,68 +104,49 @@ describe("GithubCopilotPlugin", () => { it.effect("uses responses for gpt-5 models except gpt-5-mini", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), - api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), - api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), - api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), - api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), + api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), + api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), + api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([ "responses:gpt-5", "responses:gpt-5.1-codex", @@ -190,44 +160,33 @@ describe("GithubCopilotPlugin", () => { it.effect("uses the API model ID when selecting responses or chat", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), - api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), - api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), + api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), + api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:gpt-5", "chat:gpt-5-mini", "chat:claude-sonnet-4"]) }), ) @@ -265,20 +224,17 @@ describe("GithubCopilotPlugin", () => { it.effect("ignores non-Copilot providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index 1940bba937d3..f7c75a7d1d28 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GitLabPlugin.id, effect: GitLabPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GitLabPlugin.effect(host) }) function withEnv(vars: Record, effect: () => Effect.Effect) { @@ -63,19 +65,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - }), - package: "gitlab-ai-provider", - options: { name: "gitlab" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }) expect(gitlabSDKOptions).toHaveLength(1) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://gitlab.com") expect(gitlabSDKOptions[0].apiKey).toBe("env-token") @@ -103,19 +102,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - }), - package: "gitlab-ai-provider", - options: { name: "gitlab" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { name: "gitlab" }, + }) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://env.gitlab.example") }), ), @@ -131,31 +127,28 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - }), - package: "gitlab-ai-provider", - options: { - name: "gitlab", - instanceUrl: "https://configured.gitlab.example", - apiKey: "configured-token", - aiGatewayHeaders: { - "anthropic-beta": "configured-beta", - "x-gitlab-test": "1", - }, - featureFlags: { - duo_agent_platform: false, - custom_flag: true, - }, + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "gitlab-ai-provider", + options: { + name: "gitlab", + instanceUrl: "https://configured.gitlab.example", + apiKey: "configured-token", + aiGatewayHeaders: { + "anthropic-beta": "configured-beta", + "x-gitlab-test": "1", + }, + featureFlags: { + duo_agent_platform: false, + custom_flag: true, }, }, - {}, - ) + }) expect(gitlabSDKOptions[0].instanceUrl).toBe("https://configured.gitlab.example") expect(gitlabSDKOptions[0].apiKey).toBe("configured-token") expect(gitlabSDKOptions[0].aiGatewayHeaders).toMatchObject({ @@ -175,19 +168,16 @@ describe("GitLabPlugin", () => { Effect.gen(function* () { gitlabSDKOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai", - options: { name: "gitlab" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "gitlab" }, + }) expect(result.sdk).toBeUndefined() expect(gitlabSDKOptions).toHaveLength(0) }), @@ -196,30 +186,27 @@ describe("GitLabPlugin", () => { it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), - api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, - request: { - headers: {}, - body: { workflowRef: "ref", workflowDefinition: "definition" }, - }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, + request: { + headers: {}, + body: { workflowRef: "ref", workflowDefinition: "definition" }, }, - options: { featureFlags: { configured: true } }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } + }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([ ["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: "definition" }], ]) @@ -234,26 +221,23 @@ describe("GitLabPlugin", () => { it.effect("uses exact static workflow model ids when the provider recognizes them", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), - api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), + api: { id: ModelV2.ID.make("duo-workflow-exact"), type: "aisdk", package: "test-provider" }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } }, - options: { featureFlags: { configured: true } }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([ ["duo-workflow-exact", { featureFlags: { configured: true }, workflowDefinition: undefined }], ]) @@ -264,30 +248,27 @@ describe("GitLabPlugin", () => { it.effect("uses provider feature flags instead of request feature flags", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), - api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, - request: { - headers: {}, - body: { featureFlags: { request_flag: true } }, - }, - }), - sdk: { - workflowChat: (id: string, options: unknown) => { - calls.push([id, options]) - return { id, options } - }, - agenticChat: () => undefined, + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + api: { id: ModelV2.ID.make("duo-workflow-custom"), type: "aisdk", package: "test-provider" }, + request: { + headers: {}, + body: { featureFlags: { request_flag: true } }, + }, + }), + sdk: { + workflowChat: (id: string, options: unknown) => { + calls.push([id, options]) + return { id, options } }, - options: { featureFlags: { configured: true } }, + agenticChat: () => undefined, }, - {}, - ) + options: { featureFlags: { configured: true } }, + }) expect(calls).toEqual([["duo-workflow", { featureFlags: { configured: true }, workflowDefinition: undefined }]]) }), ) @@ -295,33 +276,30 @@ describe("GitLabPlugin", () => { it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: [string, unknown][] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), - api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, - request: { headers: { h: "v" }, body: {} }, - }), - sdk: { - workflowChat: () => undefined, - agenticChat: (id: string, options: unknown) => { - const selected = options as { - aiGatewayHeaders?: Record - featureFlags?: Record - } - calls.push([ - id, - { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, - ]) - }, + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + api: { id: ModelV2.ID.make("claude"), type: "aisdk", package: "test-provider" }, + request: { headers: { h: "v" }, body: {} }, + }), + sdk: { + workflowChat: () => undefined, + agenticChat: (id: string, options: unknown) => { + const selected = options as { + aiGatewayHeaders?: Record + featureFlags?: Record + } + calls.push([ + id, + { aiGatewayHeaders: { ...selected.aiGatewayHeaders }, featureFlags: { ...selected.featureFlags } }, + ]) }, - options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, }, - {}, - ) + options: { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }, + }) expect(calls).toEqual([ ["claude", { aiGatewayHeaders: { fallback: "header" }, featureFlags: { duo_agent_platform: true } }], ]) diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index fe9b0b0d9582..511cb7ac0560 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* (definition: typeof GoogleVertexAnthropicPlugin | typeof GoogleVertexPlugin) { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: definition.id, effect: definition.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* definition.effect(host) }) function withEnv(vars: Record, effect: () => Effect.Effect) { @@ -111,22 +113,19 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("google-vertex-anthropic"), - ModelV2.ID.make("claude-sonnet-4-5"), - ), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://aiplatform.googleapis.com/v1/projects/gcp-project/locations/global/publishers/anthropic/models", ) @@ -140,22 +139,19 @@ describe("GoogleVertexAnthropicPlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("google-vertex-anthropic"), - ModelV2.ID.make("claude-sonnet-4-5"), - ), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex-anthropic" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("google-vertex-anthropic"), + ModelV2.ID.make("claude-sonnet-4-5"), + ), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex-anthropic" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://cloud-location-aiplatform.googleapis.com/v1/projects/project/locations/cloud-location/publishers/anthropic/models", ) @@ -166,19 +162,16 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("creates SDKs for google-vertex Anthropic models with multi-region endpoints", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "eu" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe( "https://aiplatform.eu.rep.googleapis.com/v1/projects/project/locations/eu/publishers/anthropic/models", ) @@ -188,19 +181,16 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("keeps configured baseURL for google-vertex Anthropic models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "eu", baseURL: "https://proxy.example/v1" }, + }) expect(result.sdk.languageModel("claude-sonnet-4-5").config.baseURL).toBe("https://proxy.example/v1") }), ) @@ -208,32 +198,25 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("selects google-vertex Anthropic language models through V2 plugins", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin(GoogleVertexPlugin) yield* addPlugin(GoogleVertexAnthropicPlugin) - const sdkResult = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), - api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/google-vertex/anthropic", - options: { name: "google-vertex", project: "project", location: "us" }, - }, - {}, - ) - const languageResult = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), - api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, - }), - sdk: sdkResult.sdk, - options: {}, - }, - {}, - ) + const sdkResult = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/google-vertex/anthropic", + options: { name: "google-vertex", project: "project", location: "us" }, + }) + const languageResult = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: sdkResult.sdk, + options: {}, + }) const language = languageResult.language as unknown as { config: { baseURL: string }; modelId: string } expect(language.config.baseURL).toBe( "https://aiplatform.us.rep.googleapis.com/v1/projects/project/locations/us/publishers/anthropic/models", @@ -245,23 +228,17 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin(GoogleVertexAnthropicPlugin) - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("google-vertex-anthropic"), - ModelV2.ID.make(" claude-sonnet-4-5 "), - ), - api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: selector(calls) }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + api: { id: ModelV2.ID.make(" claude-sonnet-4-5 "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, + options: {}, + }) expect(calls).toEqual(["languageModel:claude-sonnet-4-5"]) }), ) @@ -269,20 +246,17 @@ describe("GoogleVertexAnthropicPlugin", () => { it.effect("ignores non Vertex Anthropic providers for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin(GoogleVertexAnthropicPlugin) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), - api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: selector(calls) }, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + api: { id: ModelV2.ID.make("claude-sonnet-4-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: selector(calls) }, + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index f5f62f8df79c..b66ce1435685 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, mock } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -16,8 +17,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GoogleVertexPlugin.id, effect: GoogleVertexPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GoogleVertexPlugin.effect(host) }) function required(value: T | undefined): T { @@ -154,6 +156,7 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const catalog = yield* Catalog.Service yield* catalog.transform((catalog) => catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => { @@ -166,22 +169,18 @@ describe("GoogleVertexPlugin", () => { ) yield* addPlugin() const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), - api: { - id: ModelV2.ID.make("gemini"), - type: "aisdk", - package: "@ai-sdk/google-vertex", - }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google-vertex" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }) expect(provider.request.body.project).toBe("vertex-project") expect(provider.api).toEqual({ @@ -293,23 +292,20 @@ describe("GoogleVertexPlugin", () => { Effect.gen(function* () { vertexOptions.length = 0 const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), - api: { - id: ModelV2.ID.make("gemini"), - type: "aisdk", - package: "@ai-sdk/google-vertex", - }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google-vertex" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/google-vertex", + }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google-vertex" }, + }) expect(vertexOptions).toHaveLength(1) expect(vertexOptions[0].project).toBe("env-project") expect(vertexOptions[0].location).toBe("env-location") @@ -323,8 +319,9 @@ describe("GoogleVertexPlugin", () => { googleAuthOptions.length = 0 const fetchCalls: { input: Parameters[0]; init?: RequestInit }[] = [] const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - yield* plugin.hook("aisdk.sdk", (evt) => + yield* aisdk.hook.sdk((evt) => Effect.promise(async () => { if (evt.model.providerID !== "google-vertex") return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -345,22 +342,18 @@ describe("GoogleVertexPlugin", () => { yield* Effect.acquireUseRelease( Effect.void, () => - plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), - api: { - id: ModelV2.ID.make("gemini"), - type: "aisdk", - package: "@ai-sdk/openai-compatible", - }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "google-vertex" }, - }, - {}, - ), + aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + api: { + id: ModelV2.ID.make("gemini"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "google-vertex" }, + }), () => Effect.sync(() => { ;(globalThis as typeof globalThis & { fetch: typeof fetch }).fetch = originalFetch @@ -377,20 +370,17 @@ describe("GoogleVertexPlugin", () => { it.effect("trims model IDs before selecting language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), - api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), + api: { id: ModelV2.ID.make(" gemini-2.5-pro "), type: "aisdk", package: "test-provider" }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(calls).toEqual(["languageModel:gemini-2.5-pro"]) }), ) diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index 1197957f5a9c..620f01e9ce39 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -12,27 +13,25 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GooglePlugin.id, effect: GooglePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GooglePlugin.effect(host) }) describe("GooglePlugin", () => { it.effect("creates a Google Generative AI SDK for @ai-sdk/google using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), - api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, - }), - package: "@ai-sdk/google", - options: { name: "custom-google", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }) expect(result.sdk).toBeDefined() expect(result.sdk?.languageModel("gemini").provider).toBe("custom-google") }), @@ -41,19 +40,16 @@ describe("GooglePlugin", () => { it.effect("ignores non-Google SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), - api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, - }), - package: "@ai-sdk/google-vertex", - options: { name: "google" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), + api: { id: ModelV2.ID.make("gemini"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google-vertex", + options: { name: "google" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -61,28 +57,21 @@ describe("GooglePlugin", () => { it.effect("uses default languageModel loading with provider ID parity", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdkEvent = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, - }), - package: "@ai-sdk/google", - options: { name: "custom-google", apiKey: "test" }, - }, - {}, - ) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: sdkEvent.model, - sdk: sdkEvent.sdk, - options: sdkEvent.options, - }, - {}, - ) + const sdkEvent = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gemini-api"), type: "aisdk", package: "@ai-sdk/google" }, + }), + package: "@ai-sdk/google", + options: { name: "custom-google", apiKey: "test" }, + }) + const result = yield* aisdk.runLanguage({ + model: sdkEvent.model, + sdk: sdkEvent.sdk, + options: sdkEvent.options, + }) const language = result.language ?? result.sdk.languageModel(result.model.api.id) expect(language.modelId).toBe("gemini-api") expect(language.provider).toBe("custom-google") diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts index dbc97205b264..4f469e95c0d0 100644 --- a/packages/core/test/plugin/provider-groq.test.ts +++ b/packages/core/test/plugin/provider-groq.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { createGroq } from "@ai-sdk/groq" import { Effect } from "effect" @@ -13,27 +14,25 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: GroqPlugin.id, effect: GroqPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* GroqPlugin.effect(host) }) describe("GroqPlugin", () => { it.effect("creates a Groq SDK for @ai-sdk/groq", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, - }), - package: "@ai-sdk/groq", - options: { name: "groq" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "groq" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -41,19 +40,16 @@ describe("GroqPlugin", () => { it.effect("ignores non-Groq SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "groq" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "groq" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -61,19 +57,16 @@ describe("GroqPlugin", () => { it.effect("only matches the bundled @ai-sdk/groq package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, - }), - package: "@ai-sdk/groq/compat", - options: { name: "groq" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq/compat", + options: { name: "groq" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -81,19 +74,16 @@ describe("GroqPlugin", () => { it.effect("matches the old bundled Groq SDK provider naming", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), - api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, - }), - package: "@ai-sdk/groq", - options: { name: "custom-groq", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), + api: { id: ModelV2.ID.make("llama"), type: "aisdk", package: "@ai-sdk/groq" }, + }), + package: "@ai-sdk/groq", + options: { name: "custom-groq", apiKey: "test" }, + }) const expected = createGroq({ name: "custom-groq", apiKey: "test" } as Parameters[0] & { name: string }).languageModel("llama") @@ -106,26 +96,23 @@ describe("GroqPlugin", () => { it.effect("uses the default languageModel(api.id) behavior", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters[0] & { name: string }) - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), - api: { - id: ModelV2.ID.make("llama-api"), - type: "aisdk", - package: "@ai-sdk/groq", - }, - }), - sdk, - options: { name: "groq", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), + api: { + id: ModelV2.ID.make("llama-api"), + type: "aisdk", + package: "@ai-sdk/groq", + }, + }), + sdk, + options: { name: "groq", apiKey: "test" }, + }) const language = result.language ?? sdk.languageModel(result.model.api.id) expect(language.modelId).toBe("llama-api") expect(language.provider).toBe("groq.chat") diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 5e7a7c2d2bb7..1df0fd315680 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -13,8 +13,8 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: KiloPlugin.id, effect: KiloPlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + yield* KiloPlugin.effect(host) }) describe("KiloPlugin", () => { diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 0fc22c5235d0..d7f9d0d73d4f 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -14,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: LLMGatewayPlugin.id, effect: LLMGatewayPlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + const integration = yield* Integration.Service + yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration)) }) describe("LLMGatewayPlugin", () => { diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts index f09e0e62c700..0544d5f50cd1 100644 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ b/packages/core/test/plugin/provider-mistral.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" @@ -13,27 +14,25 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: MistralPlugin.id, effect: MistralPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* MistralPlugin.effect(host) }) describe("MistralPlugin", () => { it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/mistral", - options: { name: "mistral" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -41,19 +40,16 @@ describe("MistralPlugin", () => { it.effect("ignores non-Mistral SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "mistral" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "mistral" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -61,25 +57,22 @@ describe("MistralPlugin", () => { it.effect("matches the old bundled Mistral SDK provider name for the bundled provider ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const providers: string[] = [] yield* addPlugin() - yield* plugin.hook("aisdk.sdk", (event) => + yield* aisdk.hook.sdk((event) => Effect.sync(() => { providers.push(event.sdk.languageModel("mistral-large").provider) }), ) - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/mistral", - options: { name: "mistral" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "mistral" }, + }) expect(result.sdk).toBeDefined() expect(providers).toEqual(["mistral.chat"]) }), @@ -88,25 +81,22 @@ describe("MistralPlugin", () => { it.effect("matches the old bundled Mistral SDK provider name for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const providers: string[] = [] yield* addPlugin() - yield* plugin.hook("aisdk.sdk", (event) => + yield* aisdk.hook.sdk((event) => Effect.sync(() => { providers.push(event.sdk.languageModel("mistral-large").provider) }), ) - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/mistral", - options: { name: "custom-mistral" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/mistral", + options: { name: "custom-mistral" }, + }) expect(providers).toEqual(["mistral.chat"]) }), ) @@ -114,6 +104,7 @@ describe("MistralPlugin", () => { it.effect("leaves Mistral language selection on the default sdk.languageModel(api.id) path", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] const sdk = { languageModel: (id: string) => { @@ -122,18 +113,14 @@ describe("MistralPlugin", () => { }, } yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, - }), - sdk, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("mistral-large"), type: "aisdk", package: "test-provider" }, + }), + sdk, + options: {}, + }) const language = result.language ?? sdk.languageModel(result.model.api.id) expect(calls).toEqual(["languageModel:mistral-large"]) expect(language).toBeDefined() diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index ee16e5a2beab..a1c05df3359a 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -13,8 +13,8 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: NvidiaPlugin.id, effect: NvidiaPlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + yield* NvidiaPlugin.effect(host) }) describe("NvidiaPlugin", () => { diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index c0601c2ba336..74adb0ef4371 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -12,39 +13,33 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: OpenAICompatiblePlugin.id, effect: OpenAICompatiblePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* OpenAICompatiblePlugin.effect(host) }) describe("OpenAICompatiblePlugin", () => { it.effect("preserves explicit includeUsage false and defaults it to true", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const defaulted = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom" }, - }, - {}, - ) - const disabled = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom", includeUsage: false }, - }, - {}, - ) + const defaulted = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom" }, + }) + const disabled = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom", includeUsage: false }, + }) expect(defaulted.options.includeUsage).toBe(true) expect(disabled.options.includeUsage).toBe(false) }), @@ -53,19 +48,16 @@ describe("OpenAICompatiblePlugin", () => { it.effect("defaults includeUsage for OpenAI-compatible package matches", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", - options: { name: "custom" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/@ai-sdk/openai-compatible-provider.js", + options: { name: "custom" }, + }) expect(result.options.includeUsage).toBe(true) }), ) @@ -73,55 +65,42 @@ describe("OpenAICompatiblePlugin", () => { it.effect("uses the provider ID as the OpenAI-compatible provider name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const observed: string[] = [] yield* addPlugin() - yield* plugin.hook("aisdk.sdk", (event) => + yield* aisdk.hook.sdk((event) => Effect.sync(() => { observed.push(event.sdk.languageModel("model").provider) }), ) - yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "custom-provider", baseURL: "https://example.com/v1" }, - }, - {}, - ) + yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "custom-provider", baseURL: "https://example.com/v1" }, + }) expect(observed).toEqual(["custom-provider.chat"]) }), ) it.effect("does not overwrite an SDK created by an earlier provider-specific plugin", () => Effect.gen(function* () { - const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const sentinel = { languageModel: (modelID: string) => ({ modelID }) } - yield* plugin.add({ - id: PluginV2.ID.make("sentinel"), - effect: Effect.succeed({ - "aisdk.sdk": (evt) => - Effect.sync(() => { - evt.sdk = sentinel - }), - }), + yield* aisdk.hook.sdk((event) => { + event.sdk = sentinel }) yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cloudflare-workers-ai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "cloudflare-workers-ai" }, + }) expect(result.sdk).toBe(sentinel) }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index a9911d2cc8a6..867de3a4810f 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -15,12 +16,10 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) const integrations = yield* Integration.Service - yield* plugin.add({ - id: OpenAIPlugin.id, - effect: OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)), - }) + yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations)) }) function required(value: T | undefined): T { @@ -63,19 +62,16 @@ describe("OpenAIPlugin", () => { it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai", - options: { name: "custom-openai", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "custom-openai", apiKey: "test" }, + }) expect(result.sdk?.responses("gpt-5").provider).toBe("custom-openai.responses") }), ) @@ -83,19 +79,16 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI SDK packages", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "openai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openai" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -103,20 +96,17 @@ describe("OpenAIPlugin", () => { it.effect("uses the Responses API for language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:gpt-5"]) expect(result.language).toBeDefined() }), @@ -125,20 +115,17 @@ describe("OpenAIPlugin", () => { it.effect("ignores non-OpenAI providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), - api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), + api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 750b71463cfd..b634d0fee3af 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -14,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: OpencodePlugin.id, effect: OpencodePlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + const integration = yield* Integration.Service + yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration)) }) function required(value: T | undefined): T { diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 5761827c4ea7..d05f5721c553 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -14,8 +15,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: OpenRouterPlugin.id, effect: OpenRouterPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* OpenRouterPlugin.effect(host) }) describe("OpenRouterPlugin", () => { @@ -47,34 +49,27 @@ describe("OpenRouterPlugin", () => { it.effect("creates an SDK only for the OpenRouter package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "openrouter" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "openrouter" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), - api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, - }), - package: "@openrouter/ai-sdk-provider", - options: { name: "custom" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), + api: { id: ModelV2.ID.make("openai/gpt-5"), type: "aisdk", package: "test-provider" }, + }), + package: "@openrouter/ai-sdk-provider", + options: { name: "custom" }, + }) expect(result.sdk).toBeDefined() }), ) diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts index eeb00093ebf4..7044b55e5aeb 100644 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ b/packages/core/test/plugin/provider-perplexity.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: PerplexityPlugin.id, effect: PerplexityPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* PerplexityPlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -34,19 +36,16 @@ describe("PerplexityPlugin", () => { it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/perplexity", - options: { name: "perplexity" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -54,19 +53,16 @@ describe("PerplexityPlugin", () => { it.effect("ignores packages that are not the bundled Perplexity package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/perplexity-compatible", - options: { name: "perplexity" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity-compatible", + options: { name: "perplexity" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -74,19 +70,16 @@ describe("PerplexityPlugin", () => { it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/perplexity", - options: { name: "perplexity" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "perplexity" }, + }) expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) @@ -94,19 +87,16 @@ describe("PerplexityPlugin", () => { it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/perplexity", - options: { name: "custom-perplexity" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/perplexity", + options: { name: "custom-perplexity" }, + }) expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") }), ) @@ -114,20 +104,17 @@ describe("PerplexityPlugin", () => { it.effect("leaves Perplexity language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("sonar"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 6892aaf6aa1c..ab48409902fe 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -19,8 +20,9 @@ const npm = Npm.Service.of({ const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: SapAICorePlugin.id, effect: SapAICorePlugin.effect({ ...host, npm }) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* SapAICorePlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm)) }) function withEnv(vars: Record, effect: () => Effect.Effect) { @@ -58,16 +60,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("sap-ai-core"), - package: fixtureProvider, - options: { name: "sap-ai-core", serviceKey: "service-key" }, - }, - {}, - ) + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "service-key" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBe("service-key") expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) }), @@ -84,16 +83,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("sap-ai-core"), - package: fixtureProvider, - options: { name: "sap-ai-core", serviceKey: "option-service-key" }, - }, - {}, - ) + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core", serviceKey: "option-service-key" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBe("env-service-key") expect(sdk.sdk.options).toEqual({ deploymentId: "deployment", resourceGroup: "resource-group" }) }), @@ -106,12 +102,13 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { model: model("sap-ai-core"), package: fixtureProvider, options: { name: "sap-ai-core" } }, - {}, - ) + const sdk = yield* aisdk.runSDK({ + model: model("sap-ai-core"), + package: fixtureProvider, + options: { name: "sap-ai-core" }, + }) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() expect(sdk.sdk.options).toEqual({}) }), @@ -121,13 +118,14 @@ describe("SapAICorePlugin", () => { it.effect("uses the callable SDK for language selection", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), { languageModel() { throw new Error("SAP AI Core should call the SDK directly") }, }) - const language = yield* plugin.trigger("aisdk.language", { model: model("sap-ai-core"), sdk, options: {} }, {}) + const language = yield* aisdk.runLanguage({ model: model("sap-ai-core"), sdk, options: {} }) expect(language.language as unknown).toEqual({ modelID: "sap-model", provider: "callable" }) }), ) @@ -138,27 +136,20 @@ describe("SapAICorePlugin", () => { () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const sdk = yield* plugin.trigger( - "aisdk.sdk", - { - model: model("openai"), - package: fixtureProvider, - options: { name: "openai", serviceKey: "service-key" }, - }, - {}, - ) - const language = yield* plugin.trigger( - "aisdk.language", - { - model: model("openai"), - sdk: () => { - throw new Error("SAP AI Core should ignore other providers") - }, - options: {}, + const sdk = yield* aisdk.runSDK({ + model: model("openai"), + package: fixtureProvider, + options: { name: "openai", serviceKey: "service-key" }, + }) + const language = yield* aisdk.runLanguage({ + model: model("openai"), + sdk: () => { + throw new Error("SAP AI Core should ignore other providers") }, - {}, - ) + options: {}, + }) expect(process.env.AICORE_SERVICE_KEY).toBeUndefined() expect(sdk.sdk).toBeUndefined() expect(language.language).toBeUndefined() diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index ca839fff5196..a2b6371a3dda 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect, it as bun_it } from "bun:test" import { Effect } from "effect" import { ModelV2 } from "@opencode-ai/core/model" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: SnowflakeCortexPlugin.id, effect: SnowflakeCortexPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* SnowflakeCortexPlugin.effect(host) }) function withEnv(vars: Record, effect: () => Effect.Effect) { @@ -50,19 +52,16 @@ describe("SnowflakeCortexPlugin", () => { it.effect("ignores non-snowflake-cortex providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), - api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai", - options: { name: "openai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), + api: { id: ModelV2.ID.make("gpt-4"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai", + options: { name: "openai" }, + }) expect(result.sdk).toBeUndefined() }), ) @@ -71,19 +70,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -93,23 +89,20 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "snowflake-cortex", - baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", - apiKey: "options-pat", - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + apiKey: "options-pat", }, - {}, - ) + }) expect(result.sdk).toBeDefined() }), ), @@ -119,19 +112,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: "oauth-token", SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.sdk).toBeDefined() }), ), @@ -141,23 +131,20 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_TOKEN: undefined, SNOWFLAKE_CORTEX_PAT: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { - name: "snowflake-cortex", - baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", - token: "options-token", - }, + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { + name: "snowflake-cortex", + baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1", + token: "options-token", }, - {}, - ) + }) expect(result.sdk).toBeDefined() }), ), @@ -167,19 +154,16 @@ describe("SnowflakeCortexPlugin", () => { withEnv({ SNOWFLAKE_CORTEX_PAT: "test-pat" }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), - api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + api: { id: ModelV2.ID.make("claude-sonnet-4-6"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "snowflake-cortex", baseURL: "https://test.snowflakecomputing.com/api/v2/cortex/v1" }, + }) expect(result.options.includeUsage).toBe(true) }), ), diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts index b780124a6b0c..439a26e66a3d 100644 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ b/packages/core/test/plugin/provider-togetherai.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: TogetherAIPlugin.id, effect: TogetherAIPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* TogetherAIPlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -34,19 +36,16 @@ describe("TogetherAIPlugin", () => { it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/togetherai", - options: { name: "togetherai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -54,34 +53,27 @@ describe("TogetherAIPlugin", () => { it.effect("matches the old bundled provider package exactly", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "file:///tmp/@ai-sdk/togetherai-provider.js", - options: { name: "togetherai" }, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/@ai-sdk/togetherai-provider.js", + options: { name: "togetherai" }, + }) expect(ignored.sdk).toBeUndefined() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/togetherai", - options: { name: "togetherai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "togetherai" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -89,20 +81,17 @@ describe("TogetherAIPlugin", () => { it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/togetherai", - options: { name: "custom-togetherai" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/togetherai", + options: { name: "custom-togetherai" }, + }) expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") }), @@ -111,28 +100,25 @@ describe("TogetherAIPlugin", () => { it.effect("defaults language selection to sdk.languageModel with the model API ID", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("togetherai"), - ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), - ), - api: { - id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), - type: "aisdk", - package: "test-provider", - }, - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty( + ProviderV2.ID.make("togetherai"), + ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + ), + api: { + id: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + type: "aisdk", + package: "test-provider", + }, + }), + sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, + options: {}, + }) expect(result.language).toBeUndefined() expect(calls).toEqual([]) diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts index 639543af5bcb..a8a7c1f0bd15 100644 --- a/packages/core/test/plugin/provider-venice.test.ts +++ b/packages/core/test/plugin/provider-venice.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: VenicePlugin.id, effect: VenicePlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* VenicePlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -34,19 +36,16 @@ describe("VenicePlugin", () => { it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "venice-ai-sdk-provider", - options: { name: "venice" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "venice-ai-sdk-provider", + options: { name: "venice" }, + }) expect(result.sdk).toBeDefined() }), ) @@ -54,19 +53,16 @@ describe("VenicePlugin", () => { it.effect("uses the model provider ID as the bundled Venice SDK name", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "venice-ai-sdk-provider", - options: { name: "custom-venice", apiKey: "test" }, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "venice-ai-sdk-provider", + options: { name: "custom-venice", apiKey: "test" }, + }) expect(result.sdk).toBeDefined() expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") }), @@ -75,31 +71,24 @@ describe("VenicePlugin", () => { it.effect("only handles the bundled venice-ai-sdk-provider package", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const similar = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "file:///tmp/venice-ai-sdk-provider.js", - options: { name: "venice" }, - }, - {}, - ) - const other = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, - }), - package: "@ai-sdk/openai-compatible", - options: { name: "venice" }, - }, - {}, - ) + const similar = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "file:///tmp/venice-ai-sdk-provider.js", + options: { name: "venice" }, + }) + const other = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), + api: { id: ModelV2.ID.make("model"), type: "aisdk", package: "test-provider" }, + }), + package: "@ai-sdk/openai-compatible", + options: { name: "venice" }, + }) expect(similar.sdk).toBeUndefined() expect(other.sdk).toBeUndefined() }), @@ -108,20 +97,17 @@ describe("VenicePlugin", () => { it.effect("leaves Venice language selection to the default languageModel fallback", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("alias"), type: "aisdk", package: "test-provider" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() }), diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index 5abc737dd021..3611172e9cab 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: VercelPlugin.id, effect: VercelPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* VercelPlugin.effect(host) }) describe("VercelPlugin", () => { @@ -55,19 +57,16 @@ describe("VercelPlugin", () => { it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const event = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), - api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, - }), - package: "@ai-sdk/vercel", - options: { name: "custom-vercel" }, - }, - {}, - ) + const event = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), + api: { id: ModelV2.ID.make("v0-1.0-md"), type: "aisdk", package: "@ai-sdk/vercel" }, + }), + package: "@ai-sdk/vercel", + options: { name: "custom-vercel" }, + }) expect(event.sdk).toBeDefined() expect(event.sdk.languageModel("v0-1.0-md").provider).toBe("vercel.chat") }), diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index a978381dea57..b5f79e4c092e 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -1,3 +1,4 @@ +import { AISDK } from "@opencode-ai/core/aisdk" import type { LanguageModelV3 } from "@ai-sdk/provider" import { describe, expect } from "bun:test" import { Effect } from "effect" @@ -13,8 +14,9 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: XAIPlugin.id, effect: XAIPlugin.effect(host) }) + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* XAIPlugin.effect(host) }) function fakeSelectorSdk(calls: string[]) { @@ -34,33 +36,26 @@ describe("XAIPlugin", () => { it.effect("creates an xAI SDK only for @ai-sdk/xai", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const ignored = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - package: "@ai-sdk/openai-compatible", - options: {}, - }, - {}, - ) - - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - package: "@ai-sdk/xai", - options: {}, - }, - {}, - ) + const ignored = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/openai-compatible", + options: {}, + }) + + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/xai", + options: {}, + }) expect(ignored.sdk).toBeUndefined() expect(typeof result.sdk?.responses).toBe("function") @@ -70,20 +65,17 @@ describe("XAIPlugin", () => { it.effect("creates xAI SDKs for custom provider IDs", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.sdk", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - package: "@ai-sdk/xai", - options: {}, - }, - {}, - ) + const result = yield* aisdk.runSDK({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + package: "@ai-sdk/xai", + options: {}, + }) expect(result.sdk.responses("grok-4").provider).toBe("xai.responses") }), @@ -92,21 +84,18 @@ describe("XAIPlugin", () => { it.effect("uses responses with the model api.id for xAI language models", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual(["responses:grok-4"]) expect(result.language).toBeDefined() @@ -116,21 +105,18 @@ describe("XAIPlugin", () => { it.effect("ignores non-xAI providers", () => Effect.gen(function* () { const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service const calls: string[] = [] yield* addPlugin() - const result = yield* plugin.trigger( - "aisdk.language", - { - model: new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), - api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }, - {}, - ) + const result = yield* aisdk.runLanguage({ + model: new ModelV2.Info({ + ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), + api: { id: ModelV2.ID.make("grok-4"), type: "aisdk", package: "@ai-sdk/xai" }, + }), + sdk: fakeSelectorSdk(calls), + options: {}, + }) expect(calls).toEqual([]) expect(result.language).toBeUndefined() diff --git a/packages/core/test/plugin/provider-zenmux.test.ts b/packages/core/test/plugin/provider-zenmux.test.ts index 3313cd048aee..b9d34a1f5bb9 100644 --- a/packages/core/test/plugin/provider-zenmux.test.ts +++ b/packages/core/test/plugin/provider-zenmux.test.ts @@ -13,8 +13,8 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service - const host = yield* PluginHost.make() - yield* plugin.add({ id: ZenmuxPlugin.id, effect: ZenmuxPlugin.effect(host) }) + const host = yield* PluginHost.make(plugin) + yield* ZenmuxPlugin.effect(host) }) function required(value: T | undefined): T { diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 9fefec820fe3..19ce82d81a6a 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -20,7 +20,7 @@ describe("SkillPlugin.Plugin", () => { it.effect("registers the built-in customize-opencode skill", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect(host({ skill })) + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) expect(yield* skill.list()).toContainEqual( expect.objectContaining({ diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 5e1aba1923c1..621f9a6766f9 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -1,7 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AbsolutePath } from "@opencode-ai/core/schema" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Reference } from "@opencode-ai/core/reference" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { SystemContext } from "@opencode-ai/core/system-context/index" @@ -36,7 +35,6 @@ describe("ReferenceGuidance", () => { ]), }), ), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) @@ -48,7 +46,6 @@ describe("ReferenceGuidance", () => { }).pipe( Effect.provide(ReferenceGuidance.layer), Effect.provide(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) @@ -71,7 +68,6 @@ describe("ReferenceGuidance", () => { ]), }), ), - Effect.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.void })), ), ) }) diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index fce6ea1087eb..9b35d9083ceb 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -2,7 +2,6 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { SystemContext } from "@opencode-ai/core/system-context" @@ -28,11 +27,8 @@ const denied = new SkillV2.Info({ content: "Denied guidance", }) -const layer = (list: () => SkillV2.Info[], wait: () => void = () => {}) => - SkillGuidance.layer.pipe( - Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })), - Layer.provide(Layer.mock(PluginBoot.Service, { wait: () => Effect.sync(wait) })), - ) +const layer = (list: () => SkillV2.Info[]) => + SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) }))) describe("SkillGuidance", () => { it.effect("renders described agent skills and reconciles the complete available list", () => { @@ -41,14 +37,12 @@ describe("SkillGuidance", () => { permissions: [{ action: "skill", resource: "denied", effect: "deny" }], }) let skills = [hidden, denied, effect] - let waited = 0 return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service const initialized = yield* guidance .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap(SystemContext.initialize)) - expect(waited).toBe(1) expect(initialized.baseline).toBe( [ "Skills provide specialized instructions and workflows for specific tasks.", @@ -71,14 +65,7 @@ describe("SkillGuidance", () => { _tag: "Updated", text: expect.stringContaining("No skills are currently available."), }) - }).pipe( - Effect.provide( - layer( - () => skills, - () => waited++, - ), - ), - ) + }).pipe(Effect.provide(layer(() => skills))) }) it.effect("omits guidance when the selected agent denies all skills", () => { diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index 70d522c3fc33..505cd724e7e3 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -35,7 +35,7 @@ describe("State", () => { }), ) - it.effect("runs effectful transforms during every rebuild", () => + it.effect("runs effectful transforms during every reload", () => Effect.gen(function* () { let value = "first" const state = State.create({ @@ -51,7 +51,7 @@ describe("State", () => { expect(state.get().values).toEqual(["first"]) value = "second" - yield* state.rebuild() + yield* state.reload() expect(state.get().values).toEqual(["second"]) }), ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 3a54be6c0b70..2848fc37aa1e 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -5,7 +5,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { PermissionV2 } from "@opencode-ai/core/permission" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SkillV2 } from "@opencode-ai/core/skill" @@ -42,17 +41,6 @@ describe("SkillTool", () => { let current = [info] const assertions: PermissionV2.AssertInput[] = [] let deny = false - let bootWaited = false - const boot = Layer.succeed( - PluginBoot.Service, - PluginBoot.Service.of({ - add: () => Effect.void, - wait: () => - Effect.sync(() => { - bootWaited = true - }), - }), - ) const permission = Layer.succeed( PermissionV2.Service, PermissionV2.Service.of({ @@ -71,7 +59,7 @@ describe("SkillTool", () => { SkillV2.Service, SkillV2.Service.of({ transform: (_transform) => Effect.die("unused"), - rebuild: () => Effect.die("unused"), + reload: () => Effect.die("unused"), sources: () => Effect.die("unused"), list: () => Effect.succeed(current), }), @@ -81,14 +69,12 @@ describe("SkillTool", () => { Layer.provide(registry), Layer.provide(permission), Layer.provide(FSUtil.defaultLayer), - Layer.provide(boot), Layer.provide(skills), ) - const layer = Layer.mergeAll(permission, skills, registry, boot, tool) + const layer = Layer.mergeAll(permission, skills, registry, tool) return yield* Effect.gen(function* () { const registry = yield* ToolRegistry.Service - expect(bootWaited).toBe(true) expect((yield* toolDefinitions(registry))[0]).toMatchObject({ name: "skill", description: SkillTool.description, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b1430314fffe..8e6480538ed6 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -28,7 +28,6 @@ import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" @@ -100,7 +99,6 @@ export const layer = Layer.effect( const cfg = yield* config.get() const skillDirs = yield* skill.dirs() const referenceDirs = yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) const whitelistedDirs = [ diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 02ad579acb03..87a67b4bc062 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -3,7 +3,6 @@ import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -13,7 +12,6 @@ export const V2Command = effectCmd({ instance: false, handler: () => Effect.gen(function* () { - yield* PluginBoot.Service.use((service) => service.wait()) const catalog = yield* Catalog.Service const providers = (yield* catalog.provider.available()).sort((a, b) => a.id.localeCompare(b.id)) const all = (yield* catalog.provider.all()).sort((a, b) => a.id.localeCompare(b.id)) diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 74401779d353..49b79018579b 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -19,7 +19,6 @@ import { Skill } from "@/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Reference } from "@opencode-ai/core/reference" export function provider(model: Provider.Model) { @@ -55,7 +54,6 @@ export const layer = Layer.effect( environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { const ctx = yield* InstanceState.context const references = yield* Effect.gen(function* () { - yield* (yield* PluginBoot.Service).wait() return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) return [ diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 16a0a6b5e8cd..ef2134750044 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -12,7 +12,8 @@ ".": "./src/index.ts", "./tool": "./src/tool.ts", "./tui": "./src/tui.ts", - "./v2/effect": "./src/v2/effect/index.ts" + "./v2/effect": "./src/v2/effect/index.ts", + "./v2/promise": "./src/v2/promise/index.ts" }, "files": [ "dist" diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index 4fbf469d8795..3da1d7b566d4 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -1,585 +1,111 @@ -# OpenCode V2 Plugin API +# OpenCode V2 Effect Plugin API -> Design proposal. The API shown here is the intended V2 model and is not fully implemented yet. +The Effect plugin API grants plugins two in-process capabilities: -This document explains how OpenCode V2 plugins contribute agents, commands, skills, integrations, providers, and models without importing `@opencode-ai/core`. +- `hook` installs behavior at an OpenCode extension point. +- `reload` reruns every transform hook for a stateful domain. -The design has four goals: +The public server client will be exposed separately. It is intentionally not part of `PluginContext` yet. -- Internal and external plugins use the same API. -- Plugin values use generated `@opencode-ai/sdk` types. -- Core may keep richer internal representations such as branded IDs and decoded Effect schemas. -- Plugins can react to changing data without reloading an entire Location. - -## Mental Model - -A plugin has two parts: - -1. A setup effect that loads data, starts scoped subscriptions, and returns hooks. -2. Singular transform hooks that describe the plugin's current contribution to a domain. - -```ts -export default defineEffectPlugin({ - id: "example", - effect: (ctx) => - Effect.gen(function* () { - return { - "agent.transform": (agent) => { - // Describe this plugin's agent contribution. - }, - } - }), -}) -``` - -A transform is not a one-time mutation. It is a replayable declaration. - -OpenCode may run it when: - -- The plugin is added. -- The plugin is removed or replaced. -- Another plugin affecting the same domain changes. -- The plugin explicitly invalidates the domain. - -Transforms must therefore be synchronous, deterministic, and safe to rerun. - -## Why Hooks Are Returned - -Each transform is a singular property of the plugin definition: - -```ts -return { - "catalog.transform": applyCatalog, -} -``` - -This makes it structurally clear that one plugin has at most one transform per domain. There is no ambiguous behavior from calling `transform()` multiple times during setup. - -Transforms from different plugins compose in plugin order. - -```text -models.dev catalog transform -→ config catalog transform -→ provider catalog transforms -→ user catalog transforms -→ core catalog finalizer -``` - -## Your First Plugin - -This plugin adds a reviewer agent. - -```ts -import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" -import { Effect } from "effect" - -export default defineEffectPlugin({ - id: "reviewer", - effect: () => - Effect.succeed({ - "agent.transform": (agent) => { - agent.update("reviewer", (item) => { - item.description = "Reviews code for correctness and regressions" - item.system = "Review the requested code. Prioritize bugs and behavioral regressions." - item.mode = "subagent" - item.hidden = false - }) - }, - }), -}) -``` - -The editor supplies a complete default agent when `reviewer` does not exist. The callback modifies that value using the generated SDK agent shape. - -When the plugin unloads, OpenCode rebuilds the agent registry without this transform. The reviewer disappears automatically. - -## Transform Editors - -Editors support ordered reads and writes while a domain is being rebuilt. - -```ts -"agent.transform": (agent) => { - const existing = agent.get("reviewer") - - agent.update("reviewer", (item) => { - item.description ??= existing?.description ?? "Reviews code" - }) -} -``` - -An editor is valid only during the transform call. Do not retain it in plugin state. - -Later plugins see mutations made by earlier plugins in the same rebuild. - -## Adding A Provider And Model - -This plugin contributes one provider and one model. +## Defining A Plugin ```ts -import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" +import { define } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export default defineEffectPlugin({ - id: "acme", - effect: () => - Effect.succeed({ - "catalog.transform": (catalog) => { - catalog.provider.update("acme", (provider) => { - provider.name = "Acme AI" - provider.api = { - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.acme.example/v1", - } - }) - - catalog.model.update("acme", "acme-chat", (model) => { - model.name = "Acme Chat" - model.family = "acme" - model.api = { - id: "acme-chat", - type: "aisdk", - package: "@ai-sdk/openai-compatible", - url: "https://api.acme.example/v1", - } - model.capabilities = { - tools: true, - input: ["text"], - output: ["text"], - } - model.time.released = Date.now() - model.status = "active" - model.enabled = true - model.limit = { - context: 128_000, - output: 16_384, - } - }) - }, - }), +export const Plugin = define({ + id: "example", + effect: Effect.fn(function* (ctx) { + yield* ctx.catalog.transform((catalog) => { + catalog.provider.update("example", (provider) => { + provider.name = "Example" + }) + }) + }), }) ``` -The provider and model values use generated SDK types. Core may encode and decode richer internal schema values at the plugin boundary. - -## Dynamic Data And Invalidation - -Some plugins depend on data that changes after setup. Examples include: - -- models.dev refreshes -- config file watchers -- skill directory watchers -- authentication state changes - -The plugin keeps the current data in its own scoped state. When that data changes, it invalidates each affected domain. - -```ts -let data = yield * loadData() - -return { - "catalog.transform": (catalog) => { - applyCatalog(data, catalog) - }, -} -``` - -After changing `data`: +Plugin setup registers hooks imperatively. It does not return a hook object. -```ts -data = yield * loadData() -yield * ctx.catalog.invalidate() -``` - -Invalidation does not mutate the current catalog in place. It requests a rebuild: - -```text -create fresh catalog state -→ replay every catalog transform in plugin order -→ run the core catalog finalizer -→ commit the new catalog -→ publish catalog.updated -``` +Configuration supplied for the plugin is available as `ctx.options`. -Repeated invalidations are serialized and may be coalesced. +Registrations are owned by the plugin scope. Closing the scope removes them automatically; a registration may also be removed early through `dispose`. -## Models.dev Example +## Transform Hooks -Models.dev is the main example of a dynamic plugin. It projects one changing source into the integration and catalog domains. +Transform hooks contribute to stateful domains: ```ts -import { defineEffectPlugin } from "@opencode-ai/plugin/v2/effect" -import { Effect, Stream } from "effect" - -export default defineEffectPlugin({ - id: "models-dev", - effect: (ctx) => - Effect.gen(function* () { - const modelsDev = yield* ModelsDev.Service - const events = yield* EventV2.Service - let data = yield* modelsDev.get() - - yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( - Stream.runForEach( - Effect.fn(function* () { - data = yield* modelsDev.get() - yield* ctx.integration.invalidate() - yield* ctx.catalog.invalidate() - }), - ), - Effect.forkScoped({ startImmediately: true }), - ) - - return { - "integration.transform": (integration) => { - for (const provider of Object.values(data)) { - if (provider.env.length === 0) continue - - integration.update(provider.id, (item) => { - item.name = provider.name - }) - - integration.method.update({ - integrationID: provider.id, - method: { type: "key" }, - }) - - integration.method.update({ - integrationID: provider.id, - method: { - type: "env", - names: [...provider.env], - }, - }) - } - }, - - "catalog.transform": (catalog) => { - for (const provider of Object.values(data)) { - applyProvider(provider, catalog) - } - }, - } - }), -}) -``` - -`ModelsDev.Service` and `ModelsDev.Event` are privileged internal dependencies in this example. The integration and catalog contributions still use the same hooks available to external plugins. - -This design intentionally does not require a special multi-domain transform. The two domains rebuild independently. If strict cross-domain atomic publication becomes a requirement, it should be designed separately rather than making every transform combinatorial. - -## Config File Watching - -A config plugin can project one parsed config snapshot into several independent domains. - -```ts -export default defineEffectPlugin({ - id: "config", - effect: (ctx) => - Effect.gen(function* () { - let config = yield* loadConfig() - - yield* watchConfig.pipe( - Stream.runForEach( - Effect.fn(function* () { - config = yield* loadConfig() - yield* ctx.agent.invalidate() - yield* ctx.command.invalidate() - yield* ctx.catalog.invalidate() - yield* ctx.integration.invalidate() - yield* ctx.reference.invalidate() - yield* ctx.skill.invalidate() - }), - ), - Effect.forkScoped, - ) - - return { - "agent.transform": (agent) => applyAgentConfig(config, agent), - "command.transform": (command) => applyCommandConfig(config, command), - "catalog.transform": (catalog) => applyProviderConfig(config, catalog), - "integration.transform": (integration) => applyIntegrationConfig(config, integration), - "reference.transform": (reference) => applyReferenceConfig(config, reference), - "skill.transform": (skill) => applySkillConfig(config, skill), - } - }), -}) +yield * + ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) + }) ``` -The watcher performs I/O. The transforms only project the latest in-memory snapshot. - -## Skill Directory Watching +OpenCode rebuilds the domain when a transform is registered or disposed. A rebuild starts from fresh domain state and runs every active transform in registration order. -A skill plugin follows the same pattern. +Available transform hooks are namespaced by domain: ```ts -export default defineEffectPlugin({ - id: "workspace-skills", - effect: (ctx) => - Effect.gen(function* () { - let sources = yield* discoverSkills() - - yield* watchSkillDirectories.pipe( - Stream.runForEach( - Effect.fn(function* () { - sources = yield* discoverSkills() - yield* ctx.skill.invalidate() - }), - ), - Effect.forkScoped, - ) - - return { - "skill.transform": (skill) => { - for (const source of sources) skill.source(source) - }, - } - }), -}) +ctx.agent.transform +ctx.catalog.transform +ctx.command.transform +ctx.integration.transform +ctx.reference.transform +ctx.skill.transform ``` -Rebuilding the source registry may not be enough if discovered skill contents are cached separately. Domain invalidation must include all materialized state owned by that domain. - ## Runtime Hooks -Transform hooks build registry state. Runtime hooks intercept live operations. - -```ts -return { - "catalog.transform": (catalog) => { - // Synchronous and replayable. - }, - - "aisdk.sdk": Effect.fn(function* (event) { - // Runs when OpenCode needs an AI SDK provider. - }), - - "aisdk.language": Effect.fn(function* (event) { - // Runs when OpenCode selects a language model implementation. - }), -} -``` - -Runtime hooks may perform Effects appropriate to the operation. Transform hooks must remain replay-safe. - -## Integration Authentication - -Executable registrations may be installed during an integration transform. +Runtime hooks intercept live operations rather than rebuilding domain state: ```ts -return { - "integration.transform": (integration) => { - integration.update("openai", (item) => { - item.name = "OpenAI" - }) - - integration.method.update({ - integrationID: "openai", - method: { - id: "chatgpt-browser", - type: "oauth", - label: "ChatGPT Pro/Plus (browser)", - }, - authorize: browserAuthorize, - refresh: refreshCredential, - }) - }, -} -``` - -Replay installs callback values. It must not start OAuth, open a server, or refresh credentials. Those effects run later when core invokes the stored implementation. - -## Reading Other Domains - -A transform may need information from another committed domain. - -```ts -"agent.transform": (agent) => { - if (!anthropicAvailable) return - - agent.update("anthropic-reviewer", (item) => { - item.model = { - providerID: "anthropic", - id: "claude-sonnet", - } - }) -} -``` - -Load or subscribe to the dependency during setup, keep a local snapshot, and invalidate the dependent domain when the snapshot changes. - -```ts -let anthropicAvailable = yield * readAnthropicAvailability() - yield * - catalogChanges.pipe( - Stream.runForEach( - Effect.fn(function* () { - anthropicAvailable = yield* readAnthropicAvailability() - yield* ctx.agent.invalidate() - }), - ), - Effect.forkScoped, + ctx.aisdk.sdk( + Effect.fn(function* (event) { + if (event.package !== "@ai-sdk/xai") return + const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) + event.sdk = mod.createXai(event.options) + }), ) -``` - -This keeps transform callbacks synchronous and avoids hidden dependency tracking. - -## Plugin Order -OpenCode's default distribution uses an opinionated order. - -```text -1. Built-in agents, commands, and skills -2. Base data sources such as models.dev -3. Configuration projections -4. Provider-specific normalization and authentication -5. External user plugins -6. Core domain finalization -``` - -For the catalog: - -```text -models.dev -→ config provider overrides -→ built-in provider normalization -→ user catalog transforms -→ policy and validation -→ commit -→ catalog.updated -``` - -Ordering is observable behavior. Later transforms see and may override earlier transforms. - -## Core Finalization - -Plugin transforms and core finalization are different concepts. - -Transforms describe configurable plugin contributions. Core finalization enforces domain invariants. - -Catalog finalization may: - -- Validate the materialized catalog. -- Apply provider-use policy. -- Build indexes. -- Commit the new snapshot. -- Publish `catalog.updated` after the new snapshot is visible. - -Reference finalization may materialize Git-backed references. Integration finalization may update connection projections and publish events. - -Core finalizers always run after plugin transforms for that domain. - -## Add, Remove, And Replace - -When a plugin is added, OpenCode invalidates every domain for which it returned a transform. - -When a plugin is removed, OpenCode removes its hooks and invalidates those domains. Rebuilding from base state automatically removes the plugin's prior mutations. - -When a plugin is replaced, OpenCode swaps its hooks, preserves the intended plugin order, and invalidates the affected domains. - -No plugin-specific undo callback is required. - -## Effect API - -The Effect API exposes Effect-native setup, runtime hooks, scopes, interruption, and typed failures. - -```ts -export type EffectPlugin = (ctx: EffectPluginContext) => Effect.Effect +yield * + ctx.aisdk.language((event) => { + if (event.model.providerID !== "xai") return + event.language = event.sdk.responses(event.model.api.id) + }) ``` -The setup scope owns: - -- Event subscriptions -- Watchers -- Background fibers -- Plugin hooks - -Closing the scope unloads the plugin and invalidates its transformed domains. +Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. -## Promise API +## Reloading A Domain -The Promise API uses the same SDK values, hook names, editors, and lifecycle semantics. +When data captured by a transform changes, reload the affected domain: ```ts -export default definePlugin({ - id: "reviewer", - plugin: async () => ({ - "agent.transform": (agent) => { - agent.update("reviewer", (item) => { - item.description = "Reviews code" - item.mode = "subagent" - item.hidden = false - }) - }, - }), -}) -``` +let data = yield * loadCatalog() -Promise plugins receive Promise-returning host capabilities: +yield * + ctx.catalog.transform((catalog) => { + applyCatalog(data, catalog) + }) -```ts -await ctx.catalog.invalidate() +data = yield * loadCatalog() +yield * ctx.catalog.reload() ``` -Core implements the Promise API by running the canonical Effect capabilities. It manages the plugin scope automatically. - -## Rules For Transform Hooks - -Transform hooks must: - -- Be synchronous. -- Be deterministic for their captured snapshot. -- Avoid network, filesystem, process, and database I/O. -- Avoid publishing events. -- Avoid invalidating a domain while that domain is rebuilding. -- Avoid retaining the editor after returning. +Reload belongs to the domain, not an individual registration. `ctx.catalog.reload()` reruns every active catalog transform and publishes the rebuilt catalog. -Transform hooks may: - -- Read the editor's current materialized state. -- Add, update, and remove domain entries. -- Install executable callback values for later use. -- Read immutable or plugin-owned captured data. - -## Runtime Requirements - -The plugin runtime must provide these guarantees: - -- Hooks replay in deterministic plugin order. -- Only one rebuild per domain runs at a time. -- Repeated invalidations may be coalesced. -- Rebuilds use fresh temporary state. -- Failed rebuilds leave the previous committed state intact. -- Core finalization runs after all plugin transforms. -- Update events publish only after the new state is visible. -- Plugin add, remove, and replacement invalidate affected domains automatically. -- A transform cannot invalidate the domain currently running it. - -## Summary - -Use setup for effects and transforms for declarations. +Available reload operations are: ```ts -effect: (ctx) => - Effect.gen(function* () { - let data = yield* loadData() - - yield* watchData.pipe( - Stream.runForEach( - Effect.fn(function* () { - data = yield* loadData() - yield* ctx.catalog.invalidate() - }), - ), - Effect.forkScoped, - ) - - return { - "catalog.transform": (catalog) => { - applyCatalog(data, catalog) - }, - } - }) +ctx.agent.reload() +ctx.catalog.reload() +ctx.command.reload() +ctx.integration.reload() +ctx.reference.reload() +ctx.skill.reload() ``` - -The plugin owns changing source data. The runtime owns hook ordering, replay, invalidation, cleanup, and commit. Core services own their state and finalization. diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index 11a8c6c20edf..9ded7b831dc1 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -1,6 +1,5 @@ import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export interface AgentDraft { list(): readonly AgentV2Info[] @@ -10,8 +9,6 @@ export interface AgentDraft { remove(id: string): void } -export interface Agent extends Transformable { - get(id: string): Effect.Effect - default(): Effect.Effect - list(): Effect.Effect -} +export type AgentHooks = Hooks<{ + transform: AgentDraft +}> diff --git a/packages/plugin/src/v2/effect/aisdk.ts b/packages/plugin/src/v2/effect/aisdk.ts index 579de82496e2..ddc292e38429 100644 --- a/packages/plugin/src/v2/effect/aisdk.ts +++ b/packages/plugin/src/v2/effect/aisdk.ts @@ -1,21 +1,18 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Hookable } from "./registration.js" +import type { Hooks } from "./registration.js" -export interface AISDKHooks { - readonly sdk: (event: { +export type AISDKHooks = Hooks<{ + sdk: { readonly model: ModelV2Info readonly package: string readonly options: Record sdk?: any - }) => Effect.Effect | void - readonly language: (event: { + } + language: { readonly model: ModelV2Info readonly sdk: any readonly options: Record language?: LanguageModelV3 - }) => Effect.Effect | void -} - -export interface AISDK extends Hookable {} + } +}> diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 1d44717aefde..704ef16bf63e 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,6 +1,5 @@ import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export interface CatalogProviderRecord { readonly provider: ProviderV2Info @@ -25,17 +24,6 @@ export interface CatalogDraft { } } -export interface Catalog extends Transformable { - readonly provider: { - get(id: string): Effect.Effect - list(): Effect.Effect - available(): Effect.Effect - } - readonly model: { - get(providerID: string, modelID: string): Effect.Effect - list(): Effect.Effect - available(): Effect.Effect - default(): Effect.Effect - small(providerID: string): Effect.Effect - } -} +export type CatalogHooks = Hooks<{ + transform: CatalogDraft +}> diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts index fcb90d19685f..0afd4bafe04e 100644 --- a/packages/plugin/src/v2/effect/command.ts +++ b/packages/plugin/src/v2/effect/command.ts @@ -1,6 +1,5 @@ import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export interface CommandDraft { list(): readonly CommandV2Info[] @@ -9,7 +8,6 @@ export interface CommandDraft { remove(name: string): void } -export interface Command extends Transformable { - get(name: string): Effect.Effect - list(): Effect.Effect -} +export type CommandHooks = Hooks<{ + transform: CommandDraft +}> diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts new file mode 100644 index 000000000000..76ba7967401a --- /dev/null +++ b/packages/plugin/src/v2/effect/context.ts @@ -0,0 +1,22 @@ +import type { PluginOptions } from "../options.js" +import type { AgentHooks } from "./agent.js" +import type { AISDKHooks } from "./aisdk.js" +import type { CatalogHooks } from "./catalog.js" +import type { CommandHooks } from "./command.js" +import type { IntegrationHooks } from "./integration.js" +import type { PluginHooks } from "./plugin.js" +import type { ReferenceHooks } from "./reference.js" +import type { SkillHooks } from "./skill.js" +import type { Reload } from "./registration.js" + +export interface PluginContext { + readonly options: PluginOptions + readonly agent: AgentHooks & Reload + readonly aisdk: AISDKHooks + readonly catalog: CatalogHooks & Reload + readonly command: CommandHooks & Reload + readonly integration: IntegrationHooks & Reload + readonly plugin: PluginHooks & Reload + readonly reference: ReferenceHooks & Reload + readonly skill: SkillHooks & Reload +} diff --git a/packages/plugin/src/v2/effect/host.ts b/packages/plugin/src/v2/effect/host.ts deleted file mode 100644 index 707f744b35f2..000000000000 --- a/packages/plugin/src/v2/effect/host.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { Agent } from "./agent.js" -import type { AISDK } from "./aisdk.js" -import type { Catalog } from "./catalog.js" -import type { Command } from "./command.js" -import type { Event } from "./event.js" -import type { FileSystem } from "./filesystem.js" -import type { Integration } from "./integration.js" -import type { Location } from "./location.js" -import type { Npm } from "./npm.js" -import type { Path } from "./path.js" -import type { Reference } from "./reference.js" -import type { Skill } from "./skill.js" - -export interface PluginHost { - readonly agent: Agent - readonly aisdk: AISDK - readonly catalog: Catalog - readonly command: Command - readonly event: Event - readonly filesystem: FileSystem - readonly integration: Integration - readonly location: Location - readonly npm: Npm - readonly path: Path - readonly reference: Reference - readonly skill: Skill -} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 46c4574515fd..928649c0508f 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,17 +1,3 @@ -export type { PluginHost } from "./host.js" +export type { PluginContext } from "./context.js" export { define } from "./plugin.js" -export type { Plugin } from "./plugin.js" -export type { Registration } from "./registration.js" -export type { Agent, AgentDraft } from "./agent.js" -export type { AISDK, AISDKHooks } from "./aisdk.js" -export type { Catalog, CatalogDraft, CatalogProviderRecord } from "./catalog.js" -export type { Command, CommandDraft } from "./command.js" -export type { Event, EventMap } from "./event.js" -export type { FileSystem } from "./filesystem.js" -export type { Integration, IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "./integration.js" -export type { Location } from "./location.js" -export type { Npm } from "./npm.js" -export type { Path } from "./path.js" -export type { Reference, ReferenceDraft } from "./reference.js" -export type { Hookable, Transform, Transformable } from "./registration.js" -export type { Skill, SkillDraft, SkillSource } from "./skill.js" +export type { Plugin, PluginDraft } from "./plugin.js" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 2acb08b57930..5caec6888e41 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -4,8 +4,7 @@ import type { IntegrationKeyMethod, IntegrationOAuthMethod, } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod export type IntegrationMethodRegistration = @@ -30,7 +29,6 @@ export interface IntegrationDraft { } } -export interface Integration extends Transformable { - get(id: string): Effect.Effect - list(): Effect.Effect -} +export type IntegrationHooks = Hooks<{ + transform: IntegrationDraft +}> diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 09c919ad6b06..75008d92e0d4 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,11 +1,28 @@ import type { Effect, Scope } from "effect" -import type { PluginHost } from "./host.js" +import type { PluginContext } from "./context.js" +import type { PluginOptions } from "../options.js" +import type { Hooks } from "./registration.js" -export interface Plugin { +export interface Plugin { readonly id: string - readonly effect: (host: PluginHost) => Effect.Effect + readonly effect: (context: PluginContext) => Effect.Effect } -export function define(plugin: Plugin) { +export function define(plugin: Plugin) { return plugin } + +export interface PluginRef { + readonly package: string + readonly options?: PluginOptions +} + +export interface PluginDraft { + list(): readonly Plugin[] + add(plugin: Plugin): void + remove(id: string): void +} + +export type PluginHooks = Hooks<{ + transform: PluginDraft +}> diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts index 389674cff7e2..c1e2630476ef 100644 --- a/packages/plugin/src/v2/effect/reference.ts +++ b/packages/plugin/src/v2/effect/reference.ts @@ -1,6 +1,5 @@ -import type { ReferenceGitSource, ReferenceInfo, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" +import type { Hooks } from "./registration.js" export interface ReferenceDraft { add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void @@ -8,6 +7,6 @@ export interface ReferenceDraft { list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] } -export interface Reference extends Transformable { - list(): Effect.Effect -} +export type ReferenceHooks = Hooks<{ + transform: ReferenceDraft +}> diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts index 05aa0c4b6062..dfe562639764 100644 --- a/packages/plugin/src/v2/effect/registration.ts +++ b/packages/plugin/src/v2/effect/registration.ts @@ -1,16 +1,15 @@ import type { Effect, Scope } from "effect" -export type Transform = (draft: Draft) => Effect.Effect | void - export interface Registration { readonly dispose: Effect.Effect } -export interface Transformable { - transform(callback: Transform): Effect.Effect - rebuild(): Effect.Effect +export interface Reload { + readonly reload: () => Effect.Effect } -export interface Hookable { - hook(name: Name, callback: Hooks[Name]): Effect.Effect +export type Hooks = { + readonly [Name in keyof Spec]: ( + callback: (input: Spec[Name]) => Effect.Effect | void, + ) => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts index d25a71f0d3ec..88dd3867d18f 100644 --- a/packages/plugin/src/v2/effect/skill.ts +++ b/packages/plugin/src/v2/effect/skill.ts @@ -1,6 +1,5 @@ import type { SkillV2Info } from "@opencode-ai/sdk/v2/types" -import type { Effect } from "effect" -import type { Transformable } from "./registration.js" +import type { Hooks } from "./registration.js" export type SkillSource = | { readonly type: "directory"; readonly path: string } @@ -12,7 +11,6 @@ export interface SkillDraft { list(): readonly SkillSource[] } -export interface Skill extends Transformable { - sources(): Effect.Effect - list(): Effect.Effect -} +export type SkillHooks = Hooks<{ + transform: SkillDraft +}> diff --git a/packages/plugin/src/v2/options.ts b/packages/plugin/src/v2/options.ts new file mode 100644 index 000000000000..2b210f943b2e --- /dev/null +++ b/packages/plugin/src/v2/options.ts @@ -0,0 +1 @@ +export type PluginOptions = Readonly> diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md new file mode 100644 index 000000000000..e91b93fbdfc4 --- /dev/null +++ b/packages/plugin/src/v2/promise/README.md @@ -0,0 +1,103 @@ +# OpenCode V2 Promise Plugin API + +The Promise plugin API is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities: + +- `hook` installs behavior at an OpenCode extension point. +- `reload` reruns every transform hook for a stateful domain. + +The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects. + +## Defining A Plugin + +```ts +import { define } from "@opencode-ai/plugin/v2/promise" + +export const Plugin = define({ + id: "example", + setup: async (ctx) => { + await ctx.catalog.transform((catalog) => { + catalog.provider.update("example", (provider) => { + provider.name = "Example" + }) + }) + }, +}) +``` + +Plugin setup registers hooks imperatively. It does not return a hook object. + +Configuration supplied for the plugin is available as `ctx.options`. + +A registration may be removed early through `dispose`: + +```ts +const registration = await ctx.catalog.transform(applyCatalog) +await registration.dispose() +``` + +## Transform Hooks + +Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work: + +```ts +await ctx.agent.transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code for regressions" + item.mode = "subagent" + }) +}) +``` + +Available transform hooks are namespaced by domain: + +```ts +ctx.agent.transform +ctx.catalog.transform +ctx.command.transform +ctx.integration.transform +ctx.reference.transform +ctx.skill.transform +``` + +## Runtime Hooks + +Runtime hooks intercept live operations: + +```ts +await ctx.aisdk.sdk(async (event) => { + if (event.package !== "@ai-sdk/xai") return + const mod = await import("@ai-sdk/xai") + event.sdk = mod.createXai(event.options) +}) + +await ctx.aisdk.language((event) => { + if (event.model.providerID !== "xai") return + event.language = event.sdk.responses(event.model.api.id) +}) +``` + +## Reloading A Domain + +When data captured by a transform changes, reload the affected domain: + +```ts +let data = await loadCatalog() + +await ctx.catalog.transform((catalog) => { + applyCatalog(data, catalog) +}) + +data = await loadCatalog() +await ctx.catalog.reload() +``` + +Available reload operations are: + +```ts +ctx.agent.reload() +ctx.catalog.reload() +ctx.command.reload() +ctx.integration.reload() +ctx.reference.reload() +ctx.skill.reload() +``` diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts new file mode 100644 index 000000000000..bec589146ae2 --- /dev/null +++ b/packages/plugin/src/v2/promise/agent.ts @@ -0,0 +1,8 @@ +import type { AgentDraft } from "../effect/agent.js" +import type { Hooks } from "./registration.js" + +export type { AgentDraft } + +export type AgentHooks = Hooks<{ + transform: AgentDraft +}> diff --git a/packages/plugin/src/v2/promise/aisdk.ts b/packages/plugin/src/v2/promise/aisdk.ts new file mode 100644 index 000000000000..ddc292e38429 --- /dev/null +++ b/packages/plugin/src/v2/promise/aisdk.ts @@ -0,0 +1,18 @@ +import type { LanguageModelV3 } from "@ai-sdk/provider" +import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" +import type { Hooks } from "./registration.js" + +export type AISDKHooks = Hooks<{ + sdk: { + readonly model: ModelV2Info + readonly package: string + readonly options: Record + sdk?: any + } + language: { + readonly model: ModelV2Info + readonly sdk: any + readonly options: Record + language?: LanguageModelV3 + } +}> diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts new file mode 100644 index 000000000000..70842e94b71f --- /dev/null +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -0,0 +1,8 @@ +import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js" +import type { Hooks } from "./registration.js" + +export type { CatalogDraft, CatalogProviderRecord } + +export type CatalogHooks = Hooks<{ + transform: CatalogDraft +}> diff --git a/packages/plugin/src/v2/promise/command.ts b/packages/plugin/src/v2/promise/command.ts new file mode 100644 index 000000000000..cdc5f8268a1e --- /dev/null +++ b/packages/plugin/src/v2/promise/command.ts @@ -0,0 +1,8 @@ +import type { CommandDraft } from "../effect/command.js" +import type { Hooks } from "./registration.js" + +export type { CommandDraft } + +export type CommandHooks = Hooks<{ + transform: CommandDraft +}> diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts new file mode 100644 index 000000000000..76ba7967401a --- /dev/null +++ b/packages/plugin/src/v2/promise/context.ts @@ -0,0 +1,22 @@ +import type { PluginOptions } from "../options.js" +import type { AgentHooks } from "./agent.js" +import type { AISDKHooks } from "./aisdk.js" +import type { CatalogHooks } from "./catalog.js" +import type { CommandHooks } from "./command.js" +import type { IntegrationHooks } from "./integration.js" +import type { PluginHooks } from "./plugin.js" +import type { ReferenceHooks } from "./reference.js" +import type { SkillHooks } from "./skill.js" +import type { Reload } from "./registration.js" + +export interface PluginContext { + readonly options: PluginOptions + readonly agent: AgentHooks & Reload + readonly aisdk: AISDKHooks + readonly catalog: CatalogHooks & Reload + readonly command: CommandHooks & Reload + readonly integration: IntegrationHooks & Reload + readonly plugin: PluginHooks & Reload + readonly reference: ReferenceHooks & Reload + readonly skill: SkillHooks & Reload +} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts new file mode 100644 index 000000000000..41254707f3dd --- /dev/null +++ b/packages/plugin/src/v2/promise/index.ts @@ -0,0 +1,17 @@ +export type { PluginContext } from "./context.js" +export type { PluginOptions } from "../options.js" +export { define } from "./plugin.js" +export type { Plugin, PluginDraft, PluginHooks, PluginRef } from "./plugin.js" +export type { Registration, Reload } from "./registration.js" +export type { AgentDraft, AgentHooks } from "./agent.js" +export type { AISDKHooks } from "./aisdk.js" +export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" +export type { CommandDraft, CommandHooks } from "./command.js" +export type { + IntegrationDraft, + IntegrationHooks, + IntegrationMethod, + IntegrationMethodRegistration, +} from "./integration.js" +export type { ReferenceDraft, ReferenceHooks } from "./reference.js" +export type { SkillDraft, SkillHooks, SkillSource } from "./skill.js" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts new file mode 100644 index 000000000000..06afa09d238a --- /dev/null +++ b/packages/plugin/src/v2/promise/integration.ts @@ -0,0 +1,8 @@ +import type { IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "../effect/integration.js" +import type { Hooks } from "./registration.js" + +export type { IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } + +export type IntegrationHooks = Hooks<{ + transform: IntegrationDraft +}> diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts new file mode 100644 index 000000000000..f7ff2ad45c77 --- /dev/null +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -0,0 +1,18 @@ +import type { PluginContext } from "./context.js" +import type { PluginDraft, PluginRef } from "../effect/plugin.js" +import type { Hooks } from "./registration.js" + +export interface Plugin { + readonly id: string + readonly setup: (context: PluginContext) => Promise | void +} + +export function define(plugin: Plugin) { + return plugin +} + +export type { PluginDraft, PluginRef } + +export type PluginHooks = Hooks<{ + transform: PluginDraft +}> diff --git a/packages/plugin/src/v2/promise/reference.ts b/packages/plugin/src/v2/promise/reference.ts new file mode 100644 index 000000000000..f4b7f8b8393d --- /dev/null +++ b/packages/plugin/src/v2/promise/reference.ts @@ -0,0 +1,8 @@ +import type { ReferenceDraft } from "../effect/reference.js" +import type { Hooks } from "./registration.js" + +export type { ReferenceDraft } + +export type ReferenceHooks = Hooks<{ + transform: ReferenceDraft +}> diff --git a/packages/plugin/src/v2/promise/registration.ts b/packages/plugin/src/v2/promise/registration.ts new file mode 100644 index 000000000000..5e0ae7f480a3 --- /dev/null +++ b/packages/plugin/src/v2/promise/registration.ts @@ -0,0 +1,11 @@ +export interface Registration { + readonly dispose: () => Promise +} + +export interface Reload { + readonly reload: () => Promise +} + +export type Hooks = { + readonly [Name in keyof Spec]: (callback: (input: Spec[Name]) => Promise | void) => Promise +} diff --git a/packages/plugin/src/v2/promise/skill.ts b/packages/plugin/src/v2/promise/skill.ts new file mode 100644 index 000000000000..fbfa1a4a6378 --- /dev/null +++ b/packages/plugin/src/v2/promise/skill.ts @@ -0,0 +1,8 @@ +import type { SkillDraft, SkillSource } from "../effect/skill.js" +import type { Hooks } from "./registration.js" + +export type { SkillDraft, SkillSource } + +export type SkillHooks = Hooks<{ + transform: SkillDraft +}> diff --git a/packages/server/src/handlers/agent.ts b/packages/server/src/handlers/agent.ts index cbde76dbce3b..3be2c9d5ea9d 100644 --- a/packages/server/src/handlers/agent.ts +++ b/packages/server/src/handlers/agent.ts @@ -1,5 +1,4 @@ import { AgentV2 } from "@opencode-ai/core/agent" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" @@ -8,7 +7,6 @@ import { response } from "../groups/location" export const AgentHandler = HttpApiBuilder.group(Api, "server.agent", (handlers) => handlers.handle("agent.list", () => Effect.gen(function* () { - yield* PluginBoot.Service.use((plugin) => plugin.wait()) return yield* response(AgentV2.Service.use((agent) => agent.all())) }), ), diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 71a3f9d85000..542717351b9a 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -1,24 +1,15 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { ServiceUnavailableError } from "../errors" import { response } from "../groups/location" -const catalogUnavailable = new ServiceUnavailableError({ - message: "Model catalog is unavailable", - service: "catalog", -}) - export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => Effect.gen(function* () { return handlers.handle( "model.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* response(catalog.model.available()) }), ) diff --git a/packages/server/src/handlers/provider.ts b/packages/server/src/handlers/provider.ts index 8b1c9959e8e0..4d84179ed36c 100644 --- a/packages/server/src/handlers/provider.ts +++ b/packages/server/src/handlers/provider.ts @@ -1,17 +1,11 @@ import { Catalog } from "@opencode-ai/core/catalog" -import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" -import { ProviderNotFoundError, ServiceUnavailableError } from "../errors" +import { ProviderNotFoundError } from "../errors" import { response } from "../groups/location" -const catalogUnavailable = new ServiceUnavailableError({ - message: "Provider catalog is unavailable", - service: "catalog", -}) - export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (handlers) => Effect.gen(function* () { return handlers @@ -19,8 +13,6 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.list", Effect.fn(function* () { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) return yield* response(catalog.provider.available()) }), ) @@ -28,8 +20,6 @@ export const ProviderHandler = HttpApiBuilder.group(Api, "server.provider", (han "provider.get", Effect.fn(function* (ctx) { const catalog = yield* Catalog.Service - const pluginBoot = yield* PluginBoot.Service - yield* pluginBoot.wait().pipe(Effect.catchDefect(() => Effect.fail(catalogUnavailable))) const provider = yield* catalog.provider.get(ctx.params.providerID) if (!provider) return yield* new ProviderNotFoundError({ From 49d3f8680285affe15b95c1bdd92faf4c9c68719 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 22 Jun 2026 23:08:26 +0000 Subject: [PATCH 010/554] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 40 ++++++------- packages/sdk/openapi.json | 76 ++++++++++++------------- 2 files changed, 58 insertions(+), 58 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f71b010420d8..ef1c0142fb32 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -50,10 +50,10 @@ export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable | EventFileEdited - | EventPluginAdded + | EventReferenceUpdated | EventPermissionV2Asked | EventPermissionV2Replied - | EventReferenceUpdated + | EventPluginAdded | EventProjectDirectoriesUpdated | EventFileWatcherUpdated | EventPtyCreated @@ -1238,9 +1238,9 @@ export type GlobalEvent = { } | { id: string - type: "plugin.added" + type: "reference.updated" properties: { - id: string + [key: string]: unknown } } | { @@ -1269,9 +1269,9 @@ export type GlobalEvent = { } | { id: string - type: "reference.updated" + type: "plugin.added" properties: { - [key: string]: unknown + id: string } } | { @@ -2787,10 +2787,10 @@ export type V2Event = | V2EventInstallationUpdated | V2EventInstallationUpdateAvailable | V2EventFileEdited - | V2EventPluginAdded + | V2EventReferenceUpdated | V2EventPermissionV2Asked | V2EventPermissionV2Replied - | V2EventReferenceUpdated + | V2EventPluginAdded | V2EventProjectDirectoriesUpdated | V2EventFileWatcherUpdated | V2EventPtyCreated @@ -5164,7 +5164,7 @@ export type V2EventFileEdited = { } } -export type V2EventPluginAdded = { +export type V2EventReferenceUpdated = { id: string metadata?: { [key: string]: unknown @@ -5175,9 +5175,9 @@ export type V2EventPluginAdded = { version: number } location?: LocationRef - type: "plugin.added" + type: "reference.updated" data: { - id: string + [key: string]: unknown } } @@ -5225,7 +5225,7 @@ export type V2EventPermissionV2Replied = { } } -export type V2EventReferenceUpdated = { +export type V2EventPluginAdded = { id: string metadata?: { [key: string]: unknown @@ -5236,9 +5236,9 @@ export type V2EventReferenceUpdated = { version: number } location?: LocationRef - type: "reference.updated" + type: "plugin.added" data: { - [key: string]: unknown + id: string } } @@ -6518,11 +6518,11 @@ export type EventFileEdited = { } } -export type EventPluginAdded = { +export type EventReferenceUpdated = { id: string - type: "plugin.added" + type: "reference.updated" properties: { - id: string + [key: string]: unknown } } @@ -6552,11 +6552,11 @@ export type EventPermissionV2Replied = { } } -export type EventReferenceUpdated = { +export type EventPluginAdded = { id: string - type: "reference.updated" + type: "plugin.added" properties: { - [key: string]: unknown + id: string } } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 14c8b94dd0ef..932cb8a49549 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -14777,7 +14777,7 @@ "$ref": "#/components/schemas/EventFileEdited" }, { - "$ref": "#/components/schemas/EventPluginAdded" + "$ref": "#/components/schemas/EventReferenceUpdated" }, { "$ref": "#/components/schemas/EventPermissionV2Asked" @@ -14786,7 +14786,7 @@ "$ref": "#/components/schemas/EventPermissionV2Replied" }, { - "$ref": "#/components/schemas/EventReferenceUpdated" + "$ref": "#/components/schemas/EventPluginAdded" }, { "$ref": "#/components/schemas/EventProjectDirectoriesUpdated" @@ -18526,17 +18526,11 @@ }, "type": { "type": "string", - "enum": ["plugin.added"] + "enum": ["reference.updated"] }, "properties": { "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false + "properties": {} } }, "required": ["id", "type", "properties"], @@ -18635,11 +18629,17 @@ }, "type": { "type": "string", - "enum": ["reference.updated"] + "enum": ["plugin.added"] }, "properties": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } }, "required": ["id", "type", "properties"], @@ -23119,7 +23119,7 @@ "$ref": "#/components/schemas/V2EventFileEdited" }, { - "$ref": "#/components/schemas/V2EventPluginAdded" + "$ref": "#/components/schemas/V2EventReferenceUpdated" }, { "$ref": "#/components/schemas/V2EventPermissionV2Asked" @@ -23128,7 +23128,7 @@ "$ref": "#/components/schemas/V2EventPermissionV2Replied" }, { - "$ref": "#/components/schemas/V2EventReferenceUpdated" + "$ref": "#/components/schemas/V2EventPluginAdded" }, { "$ref": "#/components/schemas/V2EventProjectDirectoriesUpdated" @@ -30431,7 +30431,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventPluginAdded": { + "V2EventReferenceUpdated": { "type": "object", "properties": { "id": { @@ -30462,17 +30462,11 @@ }, "type": { "type": "string", - "enum": ["plugin.added"] + "enum": ["reference.updated"] }, "data": { "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false + "properties": {} } }, "required": ["id", "type", "data"], @@ -30606,7 +30600,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventReferenceUpdated": { + "V2EventPluginAdded": { "type": "object", "properties": { "id": { @@ -30637,11 +30631,17 @@ }, "type": { "type": "string", - "enum": ["reference.updated"] + "enum": ["plugin.added"] }, "data": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } }, "required": ["id", "type", "data"], @@ -34370,7 +34370,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventPluginAdded": { + "EventReferenceUpdated": { "type": "object", "properties": { "id": { @@ -34379,17 +34379,11 @@ }, "type": { "type": "string", - "enum": ["plugin.added"] + "enum": ["reference.updated"] }, "properties": { "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": ["id"], - "additionalProperties": false + "properties": {} } }, "required": ["id", "type", "properties"], @@ -34479,7 +34473,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventReferenceUpdated": { + "EventPluginAdded": { "type": "object", "properties": { "id": { @@ -34488,11 +34482,17 @@ }, "type": { "type": "string", - "enum": ["reference.updated"] + "enum": ["plugin.added"] }, "properties": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": false } }, "required": ["id", "type", "properties"], From 23fd5907be901c0b6727e7646823d4168d43389f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 19:15:26 -0400 Subject: [PATCH 011/554] fix(opencode): normalize CLI test line endings --- packages/opencode/test/lib/cli-process.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 9c34e336bb27..6b16ab74b544 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -237,8 +237,8 @@ export function withCliFixture( ) return { exitCode: result.exitCode, - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), + stdout: normalizeLines(result.stdout.toString()), + stderr: normalizeLines(result.stderr.toString()), durationMs: Date.now() - start, } }) @@ -299,8 +299,8 @@ export function withCliFixture( interrupt: () => proc.kill("SIGINT"), result: Effect.promise(async () => ({ exitCode: await proc.exited, - stdout: await stdout, - stderr: await stderr, + stdout: normalizeLines(await stdout), + stderr: normalizeLines(await stderr), durationMs: Date.now() - start, })), } satisfies RunHandle @@ -479,6 +479,10 @@ function parseJsonEvents(stdout: string): Array> { .map((line) => JSON.parse(line) as Record) } +function normalizeLines(value: string) { + return value.replaceAll("\r\n", "\n") +} + // Convenience for the common assertion pattern. Dumps stderr/stdout when // the exit code doesn't match — saves debugging time on CI failures. function expectExit(result: RunResult, expected: number, label = "opencode") { From cd97de7391e9589c5586928e185752c2fd7dd9ab Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 19:49:00 -0400 Subject: [PATCH 012/554] refactor(plugin): consolidate internal registration --- packages/core/src/location-layer.ts | 4 +- packages/core/src/plugin/boot.ts | 101 ------------------------ packages/core/src/plugin/internal.ts | 111 +++++++++++++++++++++++---- packages/core/src/plugin/promise.ts | 2 +- 4 files changed, 99 insertions(+), 119 deletions(-) delete mode 100644 packages/core/src/plugin/boot.ts diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index cdaefe2cff16..89ca95002570 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -7,7 +7,7 @@ import { Catalog } from "./catalog" import { Integration } from "./integration" import { CommandV2 } from "./command" import { AgentV2 } from "./agent" -import { PluginBoot } from "./plugin/boot" +import { PluginInternal } from "./plugin/internal" import { Project } from "./project" import { ProjectCopy } from "./project/copy" import { ProjectDirectories } from "./project/directories" @@ -65,7 +65,7 @@ export class LocationServiceMap extends LayerMap.Service()(" Integration.locationLayer, CommandV2.locationLayer, AgentV2.locationLayer, - PluginBoot.locationLayer, + PluginInternal.locationLayer, ProjectCopy.locationLayer, FileSystem.locationLayer, Watcher.locationLayer, diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts deleted file mode 100644 index 34b52417d8c5..000000000000 --- a/packages/core/src/plugin/boot.ts +++ /dev/null @@ -1,101 +0,0 @@ -export * as PluginBoot from "./boot" - -import { Effect, Layer } from "effect" -import { Integration } from "../integration" -import { AgentV2 } from "../agent" -import { AISDK } from "../aisdk" -import { Catalog } from "../catalog" -import { CommandV2 } from "../command" -import { Config } from "../config" -import { ConfigAgentPlugin } from "../config/plugin/agent" -import { ConfigCommandPlugin } from "../config/plugin/command" -import { ConfigSkillPlugin } from "../config/plugin/skill" -import { ConfigReferencePlugin } from "../config/plugin/reference" -import { ConfigExternalPlugin } from "../config/plugin/external" -import { EventV2 } from "../event" -import { FSUtil } from "../fs-util" -import { FileSystem } from "../filesystem" -import { Global } from "../global" -import { Location } from "../location" -import { ModelsDev } from "../models-dev" -import { Npm } from "../npm" -import { PluginV2 } from "../plugin" -import { AgentPlugin } from "./agent" -import { CommandPlugin } from "./command" -import { SkillPlugin } from "./skill" -import { ConfigProviderPlugin } from "../config/plugin/provider" -import { ModelsDevPlugin } from "./models-dev" -import { ProviderPlugins } from "./provider" -import { SkillV2 } from "../skill" -import { Reference } from "../reference" -import { State } from "../state" -import { PluginHost } from "./host" -import { PluginInternal } from "./internal" - -export const locationLayer = Layer.effectDiscard( - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const commands = yield* CommandV2.Service - const plugin = yield* PluginV2.Service - const integration = yield* Integration.Service - const agents = yield* AgentV2.Service - const config = yield* Config.Service - const location = yield* Location.Service - const modelsDev = yield* ModelsDev.Service - const npm = yield* Npm.Service - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const filesystem = yield* FileSystem.Service - const global = yield* Global.Service - const skill = yield* SkillV2.Service - const reference = yield* Reference.Service - const host = yield* PluginHost.make(plugin) - - const add = (input: PluginInternal.Plugin) => - input - .effect({ ...host, options: {} }) - .pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Integration.Service, integration), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(FileSystem.Service, filesystem), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, reference), - ) - - yield* State.batch( - Effect.gen(function* () { - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - yield* add(ModelsDevPlugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) - for (const item of ProviderPlugins) yield* add(item) - yield* add(ConfigExternalPlugin.Plugin) - }), - ).pipe(Effect.withSpan("PluginBoot.boot")) - }), -).pipe( - Layer.provideMerge(PluginV2.locationLayer), - Layer.provideMerge(AISDK.locationLayer), - Layer.provideMerge(Integration.locationLayer), - Layer.provideMerge(Catalog.locationLayer), - Layer.provideMerge(CommandV2.locationLayer), - Layer.provideMerge(Config.locationLayer), - Layer.provideMerge(AgentV2.locationLayer), - Layer.provideMerge(SkillV2.locationLayer), - Layer.provideMerge(Reference.locationLayer), - Layer.provideMerge(FileSystem.locationLayer), -) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index ba7e248f684a..9b66a7b6ad9e 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,21 +1,35 @@ export * as PluginInternal from "./internal" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" -import type { Effect, Scope } from "effect" -import type { AgentV2 } from "../agent" -import type { Catalog } from "../catalog" -import type { CommandV2 } from "../command" -import type { Config } from "../config" -import type { EventV2 } from "../event" -import type { FileSystem } from "../filesystem" -import type { FSUtil } from "../fs-util" -import type { Global } from "../global" -import type { Integration } from "../integration" -import type { Location } from "../location" -import type { ModelsDev } from "../models-dev" -import type { Npm } from "../npm" -import type { Reference } from "../reference" -import type { SkillV2 } from "../skill" +import { Effect, Layer, Scope } from "effect" +import { AgentV2 } from "../agent" +import { Catalog } from "../catalog" +import { CommandV2 } from "../command" +import { Config } from "../config" +import { ConfigAgentPlugin } from "../config/plugin/agent" +import { ConfigCommandPlugin } from "../config/plugin/command" +import { ConfigExternalPlugin } from "../config/plugin/external" +import { ConfigProviderPlugin } from "../config/plugin/provider" +import { ConfigReferencePlugin } from "../config/plugin/reference" +import { ConfigSkillPlugin } from "../config/plugin/skill" +import { EventV2 } from "../event" +import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { Integration } from "../integration" +import { Location } from "../location" +import { ModelsDev } from "../models-dev" +import { Npm } from "../npm" +import { PluginV2 } from "../plugin" +import { Reference } from "../reference" +import { SkillV2 } from "../skill" +import { State } from "../state" +import { AgentPlugin } from "./agent" +import { CommandPlugin } from "./command" +import { PluginHost } from "./host" +import { ModelsDevPlugin } from "./models-dev" +import { ProviderPlugins } from "./provider" +import { SkillPlugin } from "./skill" export type Requirements = | AgentV2.Service @@ -41,3 +55,70 @@ export interface Plugin { export function define(plugin: Plugin) { return plugin } + +export const locationLayer = Layer.effectDiscard( + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const commands = yield* CommandV2.Service + const plugin = yield* PluginV2.Service + const integration = yield* Integration.Service + const agents = yield* AgentV2.Service + const config = yield* Config.Service + const location = yield* Location.Service + const modelsDev = yield* ModelsDev.Service + const npm = yield* Npm.Service + const events = yield* EventV2.Service + const fs = yield* FSUtil.Service + const filesystem = yield* FileSystem.Service + const global = yield* Global.Service + const skill = yield* SkillV2.Service + const reference = yield* Reference.Service + const host = yield* PluginHost.make(plugin) + + const wrap = (input: Plugin) => ({ + id: input.id, + effect: (context: PluginContext) => + input + .effect(context) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), + }) + + yield* State.batch( + Effect.gen(function* () { + yield* plugin.transform((plugins) => { + plugins.add(wrap(AgentPlugin.Plugin)) + plugins.add(wrap(CommandPlugin.Plugin)) + plugins.add(wrap(SkillPlugin.Plugin)) + plugins.add(wrap(ModelsDevPlugin)) + plugins.add(wrap(ConfigProviderPlugin.Plugin)) + plugins.add(wrap(ConfigAgentPlugin.Plugin)) + plugins.add(wrap(ConfigCommandPlugin.Plugin)) + plugins.add(wrap(ConfigSkillPlugin.Plugin)) + plugins.add(wrap(ConfigReferencePlugin.Plugin)) + for (const item of ProviderPlugins) plugins.add(wrap(item)) + }) + + yield* wrap(ConfigExternalPlugin.Plugin).effect(host) + }), + ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + }), +).pipe( + Layer.provideMerge(PluginV2.locationLayer), + Layer.provideMerge(Config.locationLayer), + Layer.provideMerge(FileSystem.locationLayer), +) diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 58fb4ba0dcb8..9543220ae8a5 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -10,7 +10,7 @@ type HostRegistration = { readonly dispose: Effect.Effect } /** * Adapts a Promise plugin into an Effect plugin so the existing Effect-only - * loader (`PluginV2` / `PluginBoot`) can run it unchanged. + * loader (`PluginV2` / `PluginInternal`) can run it unchanged. * * Hook registrations created during the async `setup` attach to the plugin's * scope, so unloading the plugin disposes them. The captured fiber context From 975b1132f1bdfe24caa27e45100f27683cc7748a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 20:10:29 -0400 Subject: [PATCH 013/554] refactor(plugin): use direct runtime registry --- packages/core/src/config/plugin/external.ts | 10 +-- packages/core/src/plugin.ts | 82 ++++++++++----------- packages/core/src/plugin/host.ts | 6 +- packages/core/src/plugin/internal.ts | 82 ++++++++++----------- packages/core/src/plugin/promise.ts | 7 +- packages/core/test/location-layer.test.ts | 29 ++++---- packages/core/test/plugin.test.ts | 35 +++++---- packages/core/test/plugin/host.ts | 4 +- packages/plugin/src/v2/effect/context.ts | 4 +- packages/plugin/src/v2/effect/index.ts | 2 +- packages/plugin/src/v2/effect/plugin.ts | 18 +---- packages/plugin/src/v2/promise/context.ts | 4 +- packages/plugin/src/v2/promise/index.ts | 2 +- packages/plugin/src/v2/promise/plugin.ts | 11 +-- 14 files changed, 129 insertions(+), 167 deletions(-) diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index d81d9f7c8713..d8ace63b3dd8 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -36,12 +36,6 @@ export const Plugin = define({ const fs = yield* FSUtil.Service const location = yield* Location.Service const npm = yield* Npm.Service - const loaded: EffectPlugin[] = [] - - yield* ctx.plugin.transform((plugins) => { - for (const plugin of loaded) plugins.add(plugin) - }) - yield* Effect.gen(function* () { const configured: { package: string; options?: Record }[] = [] @@ -86,14 +80,12 @@ export const Plugin = define({ const mod = yield* Effect.promise(() => import(entrypoint)) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) - loaded.push({ + yield* ctx.plugin.add({ id: plugin.id, effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }), }) }).pipe(Effect.ignoreCause) } - - yield* ctx.plugin.reload() }).pipe(Effect.forkScoped({ startImmediately: true })) }), }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 85c51ea7dba2..bbaa7dead113 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,7 +1,7 @@ export * as PluginV2 from "./plugin" import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" -import type { Plugin, PluginDraft } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" import { Catalog } from "./catalog" @@ -27,8 +27,8 @@ export const Event = { } export interface Interface { - readonly transform: State.Transform - readonly reload: State.Reload + readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Plugin") {} @@ -40,57 +40,49 @@ export const layer = Layer.effect( const locks = KeyedMutex.makeUnsafe() const scope = yield* Scope.make() const active = new Map() + const loading = new Set() let host: Parameters[0] - const attach = Effect.fn("Plugin.attach")(function* (plugin: Plugin, host: Parameters[0]) { - const id = ID.make(plugin.id) + const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) { + if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) + yield* locks.withLock(id)( - Effect.gen(function* () { - const existing = active.get(id) - if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) + Effect.sync(() => loading.add(id)).pipe( + Effect.andThen( + State.batch( + Effect.gen(function* () { + const existing = active.get(id) + active.delete(id) + if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore) - const child = yield* Scope.fork(scope) - yield* plugin.effect(host).pipe( - Scope.provide(child), - Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), - Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), - ) - active.set(id, child) - yield* events.publish(Event.Added, { id }) - }), + const child = yield* Scope.fork(scope) + yield* effect(host).pipe( + Scope.provide(child), + Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), + ) + active.set(id, child) + yield* events.publish(Event.Added, { id }) + }), + ), + ), + Effect.ensuring(Effect.sync(() => loading.delete(id))), + ), ) }) - const detach = Effect.fn("Plugin.detach")(function* (id: ID) { - yield* locks.withLock(id)( - Effect.gen(function* () { - const current = active.get(id) - active.delete(id) - if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) - }), - ) - }) + const remove = Effect.fn("Plugin.remove")(function* (id: ID) { + if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`) - const state = State.create, PluginDraft>({ - initial: () => new Map(), - draft: (draft) => ({ - list: () => Array.from(draft.values()), - add: (plugin) => draft.set(ID.make(plugin.id), plugin), - remove: (id) => draft.delete(ID.make(id)), - }), - finalize: (draft) => + yield* locks.withLock(id)( State.batch( Effect.gen(function* () { - const desired = new Set() - for (const plugin of draft.list()) desired.add(ID.make(plugin.id)) - - for (const id of active.keys()) { - if (!desired.has(id)) yield* detach(id) - } - - for (const plugin of draft.list()) yield* attach(plugin, host) - }).pipe(Effect.withSpan("Plugin.reconcile")), + const current = active.get(id) + active.delete(id) + if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) + }), ), + ) }) yield* Effect.addFinalizer((exit) => @@ -101,8 +93,8 @@ export const layer = Layer.effect( ) const service = Service.of({ - transform: state.transform, - reload: state.reload, + add, + remove, }) host = yield* PluginHost.make(service) return service diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 27afc14d2af5..f6c0e24e0200 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -8,7 +8,7 @@ import { Catalog } from "../catalog" import { CommandV2 } from "../command" import { Integration } from "../integration" import { ModelV2 } from "../model" -import type { PluginV2 } from "../plugin" +import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" import { Reference } from "../reference" import { SkillV2 } from "../skill" @@ -126,8 +126,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int ), }, plugin: { - reload: plugin.reload, - transform: plugin.transform, + add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect), + remove: (id) => plugin.remove(PluginV2.ID.make(id)), }, reference: { reload: reference.reload, diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 9b66a7b6ad9e..6aeef4dc3d2d 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -23,10 +23,8 @@ import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { Reference } from "../reference" import { SkillV2 } from "../skill" -import { State } from "../state" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" -import { PluginHost } from "./host" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillPlugin } from "./skill" @@ -73,49 +71,45 @@ export const locationLayer = Layer.effectDiscard( const global = yield* Global.Service const skill = yield* SkillV2.Service const reference = yield* Reference.Service - const host = yield* PluginHost.make(plugin) + const add = (input: Plugin) => { + const loaded = { + id: input.id, + effect: (context: PluginContext) => + input + .effect(context) + .pipe( + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(CommandV2.Service, commands), + Effect.provideService(Integration.Service, integration), + Effect.provideService(AgentV2.Service, agents), + Effect.provideService(Config.Service, config), + Effect.provideService(Location.Service, location), + Effect.provideService(ModelsDev.Service, modelsDev), + Effect.provideService(Npm.Service, npm), + Effect.provideService(EventV2.Service, events), + Effect.provideService(FSUtil.Service, fs), + Effect.provideService(FileSystem.Service, filesystem), + Effect.provideService(Global.Service, global), + Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, reference), + ), + } + return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) + } - const wrap = (input: Plugin) => ({ - id: input.id, - effect: (context: PluginContext) => - input - .effect(context) - .pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Integration.Service, integration), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(FileSystem.Service, filesystem), - Effect.provideService(Global.Service, global), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, reference), - ), - }) - - yield* State.batch( - Effect.gen(function* () { - yield* plugin.transform((plugins) => { - plugins.add(wrap(AgentPlugin.Plugin)) - plugins.add(wrap(CommandPlugin.Plugin)) - plugins.add(wrap(SkillPlugin.Plugin)) - plugins.add(wrap(ModelsDevPlugin)) - plugins.add(wrap(ConfigProviderPlugin.Plugin)) - plugins.add(wrap(ConfigAgentPlugin.Plugin)) - plugins.add(wrap(ConfigCommandPlugin.Plugin)) - plugins.add(wrap(ConfigSkillPlugin.Plugin)) - plugins.add(wrap(ConfigReferencePlugin.Plugin)) - for (const item of ProviderPlugins) plugins.add(wrap(item)) - }) - - yield* wrap(ConfigExternalPlugin.Plugin).effect(host) - }), - ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + yield* Effect.gen(function* () { + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) + yield* add(ModelsDevPlugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + yield* add(ConfigReferencePlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) }), ).pipe( Layer.provideMerge(PluginV2.locationLayer), diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index 9543220ae8a5..49a19af4c509 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -67,8 +67,11 @@ export function fromPromise(plugin: Plugin) { reload: () => run(host.integration.reload()), }, plugin: { - transform: transform(host.plugin), - reload: () => run(host.plugin.reload()), + add: (input) => { + const child = fromPromise(input) + return run(host.plugin.add(child)) + }, + remove: (id) => run(host.plugin.remove(id)), }, reference: { transform: transform(host.reference), diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 67e811558e5f..5bb064b5f81c 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -197,22 +197,19 @@ describe("LocationServiceMap", () => { Effect.flatMap((dir) => Effect.gen(function* () { const plugins = yield* PluginV2.Service - yield* plugins.transform((draft) => - draft.add( - define({ - id: "reviewer", - effect: (ctx) => - ctx.agent - .transform((agent) => { - agent.update("reviewer", (item) => { - item.description = "Reviews code" - item.mode = "subagent" - }) - }) - .pipe(Effect.asVoid), - }), - ), - ) + const reviewer = define({ + id: "reviewer", + effect: (ctx) => + ctx.agent + .transform((agent) => { + agent.update("reviewer", (item) => { + item.description = "Reviews code" + item.mode = "subagent" + }) + }) + .pipe(Effect.asVoid), + }) + yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect) expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({ description: "Reviews code", diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index a662ed7ca001..b787c754d8c7 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -9,35 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) describe("PluginV2", () => { - it.effect("reconciles transformed plugins", () => + it.effect("adds, replaces, and removes plugins", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service const agents = yield* AgentV2.Service let description = "first" - const registration = yield* plugins.transform((draft) => { - draft.add( - define({ - id: "managed", - effect: (ctx) => - ctx.agent - .transform((agents) => - agents.update("configured", (agent) => { - agent.description = description - }), - ) - .pipe(Effect.asVoid), - }), - ) - }) + const managed = () => + define({ + id: "managed", + effect: (ctx) => + ctx.agent + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = description + }), + ) + .pipe(Effect.asVoid), + }) + + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first") description = "second" - yield* plugins.reload() + yield* plugins.add(PluginV2.ID.make("managed"), managed().effect) expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second") - yield* registration.dispose + yield* plugins.remove(PluginV2.ID.make("managed")) expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined() }), ) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 02bce652c273..62fa8391e36a 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -33,8 +33,8 @@ export function host(overrides: Overrides = {}): PluginContext { reload: () => Effect.die("unused integration.reload"), }, plugin: overrides.plugin ?? { - transform: () => Effect.die("unused plugin.transform"), - reload: () => Effect.die("unused plugin.reload"), + add: () => Effect.die("unused plugin.add"), + remove: () => Effect.die("unused plugin.remove"), }, reference: overrides.reference ?? { transform: () => Effect.die("unused reference.transform"), diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts index 76ba7967401a..9089334ee3bb 100644 --- a/packages/plugin/src/v2/effect/context.ts +++ b/packages/plugin/src/v2/effect/context.ts @@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" import type { IntegrationHooks } from "./integration.js" -import type { PluginHooks } from "./plugin.js" +import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" @@ -16,7 +16,7 @@ export interface PluginContext { readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload readonly integration: IntegrationHooks & Reload - readonly plugin: PluginHooks & Reload + readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload } diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 928649c0508f..f13614a54da5 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,3 +1,3 @@ export type { PluginContext } from "./context.js" export { define } from "./plugin.js" -export type { Plugin, PluginDraft } from "./plugin.js" +export type { Plugin } from "./plugin.js" diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 75008d92e0d4..7352797b8241 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,7 +1,5 @@ import type { Effect, Scope } from "effect" import type { PluginContext } from "./context.js" -import type { PluginOptions } from "../options.js" -import type { Hooks } from "./registration.js" export interface Plugin { readonly id: string @@ -12,17 +10,7 @@ export function define(plugin: Plugin) { return plugin } -export interface PluginRef { - readonly package: string - readonly options?: PluginOptions +export interface PluginDomain { + readonly add: (plugin: Plugin) => Effect.Effect + readonly remove: (id: string) => Effect.Effect } - -export interface PluginDraft { - list(): readonly Plugin[] - add(plugin: Plugin): void - remove(id: string): void -} - -export type PluginHooks = Hooks<{ - transform: PluginDraft -}> diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts index 76ba7967401a..9089334ee3bb 100644 --- a/packages/plugin/src/v2/promise/context.ts +++ b/packages/plugin/src/v2/promise/context.ts @@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js" import type { CatalogHooks } from "./catalog.js" import type { CommandHooks } from "./command.js" import type { IntegrationHooks } from "./integration.js" -import type { PluginHooks } from "./plugin.js" +import type { PluginDomain } from "./plugin.js" import type { ReferenceHooks } from "./reference.js" import type { SkillHooks } from "./skill.js" import type { Reload } from "./registration.js" @@ -16,7 +16,7 @@ export interface PluginContext { readonly catalog: CatalogHooks & Reload readonly command: CommandHooks & Reload readonly integration: IntegrationHooks & Reload - readonly plugin: PluginHooks & Reload + readonly plugin: PluginDomain readonly reference: ReferenceHooks & Reload readonly skill: SkillHooks & Reload } diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 41254707f3dd..23394fa012ae 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -1,7 +1,7 @@ export type { PluginContext } from "./context.js" export type { PluginOptions } from "../options.js" export { define } from "./plugin.js" -export type { Plugin, PluginDraft, PluginHooks, PluginRef } from "./plugin.js" +export type { Plugin, PluginDomain } from "./plugin.js" export type { Registration, Reload } from "./registration.js" export type { AgentDraft, AgentHooks } from "./agent.js" export type { AISDKHooks } from "./aisdk.js" diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index f7ff2ad45c77..ab59fb95fc94 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,6 +1,4 @@ import type { PluginContext } from "./context.js" -import type { PluginDraft, PluginRef } from "../effect/plugin.js" -import type { Hooks } from "./registration.js" export interface Plugin { readonly id: string @@ -11,8 +9,7 @@ export function define(plugin: Plugin) { return plugin } -export type { PluginDraft, PluginRef } - -export type PluginHooks = Hooks<{ - transform: PluginDraft -}> +export interface PluginDomain { + readonly add: (plugin: Plugin) => Promise + readonly remove: (id: string) => Promise +} From ef2357915eef1f10d145abd64754622fc668f02b Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 22 Jun 2026 20:31:36 -0400 Subject: [PATCH 014/554] fix(tui): scope file autocomplete to session (#33458) --- .github/workflows/test.yml | 2 +- packages/tui/src/app.tsx | 11 ++-- .../tui/src/component/prompt/autocomplete.tsx | 51 ++++++++++++------- packages/tui/src/context/location.tsx | 14 +++++ packages/tui/src/context/path-format.tsx | 30 +++-------- packages/tui/src/routes/session/index.tsx | 11 ++-- 6 files changed, 70 insertions(+), 49 deletions(-) create mode 100644 packages/tui/src/context/location.tsx diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5776e1bf242e..13a76c0a1f0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,7 +65,7 @@ jobs: - name: Run unit tests timeout-minutes: 20 - run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=none + run: GITHUB_ACTIONS=false bun turbo test env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 17a9a554c2e4..39aa35993f76 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -35,6 +35,7 @@ import { SDKProvider, useSDK } from "./context/sdk" import { StartupLoading } from "./component/startup-loading" import { SyncProvider, useSync } from "./context/sync" import { DataProvider } from "./context/data" +import { LocationProvider } from "./context/location" import { LocalProvider, useLocal } from "./context/local" import { DialogModel } from "./component/dialog-model" import { useConnected } from "./component/use-connected" @@ -303,10 +304,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + + + diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index f2916173da3a..2a16017a2081 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -13,6 +13,7 @@ import { useData } from "../../context/data" import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" import { useTuiConfig } from "../../config" +import { useLocation } from "../../context/location" import { useTheme, selectedForeground } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" @@ -21,6 +22,7 @@ import type { PromptInfo } from "../../prompt/history" import { useFrecency } from "../../prompt/frecency" import { useBindings, useCommandSlashes, useOpencodeModeStack } from "../../keymap" import { displayCharAt, mentionTriggerIndex } from "../../prompt/display" +import type { FileSystemEntry } from "@opencode-ai/sdk/v2" function removeLineRange(input: string) { const hashIndex = input.lastIndexOf("#") @@ -94,6 +96,7 @@ export function Autocomplete(props: { const frecency = useFrecency() const tuiConfig = useTuiConfig() const paths = useTuiPaths() + const location = useLocation() const [store, setStore] = createStore({ index: 0, selected: 0, @@ -236,16 +239,18 @@ export function Autocomplete(props: { } } - function createFilePart(item: string, lineRange?: { startLine: number; endLine?: number }) { - const baseDir = (sync.path.directory || paths.cwd).replace(/\/+$/, "") - const fullPath = path.isAbsolute(item) ? item : path.join(baseDir, item) - const urlObj = pathToFileURL(fullPath) + function createFilePart( + item: FileSystemEntry, + filePath: string, + lineRange?: { startLine: number; endLine?: number }, + ) { + const urlObj = pathToFileURL(filePath) const filename = - lineRange && !item.endsWith("/") - ? `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` - : item + lineRange && item.type !== "directory" + ? `${item.path}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}` + : item.path - if (lineRange && !item.endsWith("/")) { + if (lineRange && item.type !== "directory") { urlObj.searchParams.set("start", String(lineRange.startLine)) if (lineRange.endLine !== undefined) { urlObj.searchParams.set("end", String(lineRange.endLine)) @@ -254,10 +259,9 @@ export function Autocomplete(props: { return { filename, - url: urlObj.href, part: { type: "file" as const, - mime: "text/plain", + mime: item.mime, filename, url: urlObj.href, source: { @@ -267,7 +271,7 @@ export function Autocomplete(props: { end: 0, value: "", }, - path: item, + path: item.path, }, }, } @@ -284,7 +288,7 @@ export function Autocomplete(props: { }) function normalizeMentionPath(filePath: string) { - const baseDir = sync.path.directory || paths.cwd + const baseDir = location()?.directory || sync.path.directory || paths.cwd const absolute = path.resolve(filePath) const relative = path.relative(baseDir, absolute) @@ -301,7 +305,11 @@ export function Autocomplete(props: { startLine: input.lineStart, endLine: input.lineEnd > input.lineStart ? input.lineEnd : undefined, } - const { filename, part } = createFilePart(item, lineRange) + const { filename, part } = createFilePart( + { path: item, type: "file", mime: "text/plain" }, + input.filePath, + lineRange, + ) const index = store.visible === "@" ? store.index : props.input().cursorOffset setStore("visible", false) @@ -310,17 +318,20 @@ export function Autocomplete(props: { } const [files] = createResource( - () => search(), - async (query) => { + () => ({ query: search(), location: location() }), + async (input) => { if (!store.visible || store.visible === "/") return [] if (referenceMatch()) return [] - const { lineRange, baseQuery } = extractLineRange(query ?? "") + const { lineRange, baseQuery } = extractLineRange(input.query ?? "") // Get files from SDK const result = await sdk.client.v2.fs.find({ query: baseQuery, limit: "20", - location: { workspace: project.workspace.current() }, + location: { + directory: input.location?.directory, + workspace: input.location?.workspaceID ?? project.workspace.current(), + }, }) const options: AutocompleteOption[] = [] @@ -331,7 +342,11 @@ export function Autocomplete(props: { const width = props.anchor().width - 4 options.push( ...result.data.data.map((item): AutocompleteOption => { - const { filename, url, part } = createFilePart(item.path, lineRange) + const { filename, part } = createFilePart( + item, + path.join(result.data.location.directory, item.path), + lineRange, + ) return { display: Locale.truncateMiddle(filename, width), value: filename, diff --git a/packages/tui/src/context/location.tsx b/packages/tui/src/context/location.tsx new file mode 100644 index 000000000000..0f3fca13594b --- /dev/null +++ b/packages/tui/src/context/location.tsx @@ -0,0 +1,14 @@ +import type { LocationRef } from "@opencode-ai/sdk/v2" +import { createContext, useContext, type Accessor, type ParentProps } from "solid-js" + +const context = createContext>() + +export function LocationProvider(props: ParentProps<{ location?: LocationRef }>) { + return props.location}>{props.children} +} + +export function useLocation() { + const value = useContext(context) + if (!value) throw new Error("Location context must be used within a LocationProvider") + return value +} diff --git a/packages/tui/src/context/path-format.tsx b/packages/tui/src/context/path-format.tsx index 8cf77aab9460..52b1bee89702 100644 --- a/packages/tui/src/context/path-format.tsx +++ b/packages/tui/src/context/path-format.tsx @@ -1,31 +1,15 @@ import path from "path" -import { createContext, useContext, type ParentProps } from "solid-js" import { abbreviateHome } from "../runtime" +import { useLocation } from "./location" import { useTuiPaths } from "./runtime" -const context = createContext<{ - path: () => string - format: (input?: string) => string -}>() - -export function PathFormatterProvider(props: ParentProps<{ path: string | undefined }>) { - const paths = useTuiPaths() - return ( - props.path || paths.cwd, - format: (input) => formatPath(input, props.path || paths.cwd, paths.home), - }} - > - {props.children} - - ) -} - export function usePathFormatter() { - const value = useContext(context) - if (!value) throw new Error("PathFormatter context must be used within a PathFormatterProvider") - return value + const paths = useTuiPaths() + const location = useLocation() + return { + path: () => location()?.directory || paths.cwd, + format: (input?: string) => formatPath(input, location()?.directory || paths.cwd, paths.home), + } } function formatPath(input: string | undefined, base: string, home: string) { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e3758374c870..11d8b47655e0 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -80,7 +80,8 @@ import { usePluginRuntime } from "../../plugin/runtime" import { DialogRetryAction } from "../../component/dialog-retry-action" import { getRevertDiffFiles } from "../../util/revert-diff" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" -import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" +import { usePathFormatter } from "../../context/path-format" +import { LocationProvider } from "../../context/location" addDefaultParsers(parsers.parsers) @@ -193,6 +194,10 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)) + const location = createMemo(() => { + const current = session() + return current ? { directory: current.directory, workspaceID: current.workspaceID } : undefined + }) createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) @@ -1138,7 +1143,7 @@ export function Session() { createEffect(on(() => route.sessionID, toBottom)) return ( - + - + ) } From fbf889db83c83db8f4304279cdb5c0398efb5143 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:57:21 -0500 Subject: [PATCH 015/554] fix(tui): preserve worker rejection handling (#33448) Co-authored-by: Dax Raad --- packages/opencode/src/cli/tui/worker.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 9f33cd5b934b..4cf6b2d446b3 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -13,6 +13,13 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy Heap.start() +const onUnhandledRejection = (_error: unknown) => {} + +const onUncaughtException = (_error: Error) => {} + +process.on("unhandledRejection", onUnhandledRejection) +process.on("uncaughtException", onUncaughtException) + // Subscribe to global events and forward them via RPC GlobalBus.on("event", (event) => { Rpc.emit("global.event", event) @@ -65,6 +72,8 @@ export const rpc = { async shutdown() { await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) + process.off("unhandledRejection", onUnhandledRejection) + process.off("uncaughtException", onUncaughtException) }, } From d29f5eba92af1da86777714cd0176b917d2f6404 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:18:06 -0500 Subject: [PATCH 016/554] refactor(core): remove shell description input (#32823) --- .../app/e2e/smoke/session-timeline.fixture.ts | 5 ++- .../app/e2e/smoke/session-timeline.spec.ts | 12 +++++ packages/core/src/tool/bash.ts | 3 -- packages/core/test/tool-bash.test.ts | 5 +-- packages/opencode/src/acp/tool.ts | 2 +- packages/opencode/src/cli/cmd/run/tool.ts | 15 +++---- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/tool/shell.ts | 12 +---- packages/opencode/src/tool/shell/prompt.ts | 20 ++------- .../test/cli/run/scrollback.surface.test.ts | 39 +++++++++++++--- .../test/cli/run/session-data.test.ts | 2 - .../test/cli/run/session-replay.test.ts | 1 - packages/opencode/test/session/prompt.test.ts | 1 - .../test/session/snapshot-tool-race.test.ts | 1 - .../__snapshots__/parameters.test.ts.snap | 18 -------- .../opencode/test/tool/parameters.test.ts | 11 ++--- packages/opencode/test/tool/shell.test.ts | 45 +------------------ packages/tui/src/routes/session/index.tsx | 42 ++++++++++------- .../tui/src/routes/session/permission.tsx | 4 +- .../inline-tool-wrap-snapshot.test.tsx.snap | 2 - .../tui/inline-tool-wrap-snapshot.test.tsx | 1 - packages/ui/src/components/basic-tool.tsx | 6 ++- packages/ui/src/components/message-part.tsx | 10 ++--- .../timeline-playground.stories.tsx | 4 +- .../web/src/components/share/content-bash.tsx | 3 +- packages/web/src/components/share/part.tsx | 1 - specs/v2/schema-changelog.md | 16 +++++++ 27 files changed, 124 insertions(+), 161 deletions(-) diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 1fc8571db44f..5a9933e9986e 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -139,7 +139,7 @@ function toolPart( status: "completed", input, output: lorem(index * 23 + partIndex, outputLength), - title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed", + title: tool === "bash" ? input.command : input.filePath || input.path || input.pattern || "completed", metadata, time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 }, }, @@ -201,7 +201,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), ...(index % 7 === 0 - ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] + ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), @@ -295,6 +295,7 @@ export const fixture = { .filter(renderable) .map((part) => part.id), ), + expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!.id, }, } diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index 925614cc28f9..a03a750743d3 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -327,6 +327,18 @@ test.describe("smoke: session timeline", () => { const expectedMessageIDs = fixture.expected.targetMessageIDs await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors) await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors) + + const shell = page.locator(`[data-timeline-part-id="${fixture.expected.expandedShellPartID}"]`) + const shellTrigger = shell.locator('[data-slot="collapsible-trigger"]') + const shellSubtitle = shell.locator('[data-slot="basic-tool-tool-subtitle"]') + await expect(shellSubtitle).toHaveCount(0) + await expect(shell.locator('[data-slot="bash-pre"]')).toContainText("$ bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "false") + await expect(shellSubtitle).toHaveText("bun typecheck") + await shellTrigger.click() + await expect(shellTrigger).toHaveAttribute("aria-expanded", "true") + await expect(shellSubtitle).toHaveCount(0) }) }) diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index bd6f175adae0..f86cbdd69e50 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -28,9 +28,6 @@ export const Input = Schema.Struct({ .annotate({ description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, }), - description: Schema.String.pipe(Schema.optional).annotate({ - description: "Concise description of the command's purpose", - }), }) const Output = Schema.Struct({ diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 0fb2cd73551b..9bbea5f0c3a4 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -134,10 +134,9 @@ describe("BashTool", () => { const definitions = yield* toolDefinitions(registry) expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") + expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description") expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) - expect( - yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })), - ).toEqual({ + expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({ result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, output: { structured: { diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts index d0e57cc2ecf2..84ad2fdb722c 100644 --- a/packages/opencode/src/acp/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -266,7 +266,7 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { // For shell tools, surface the actual command as the title so it stays visible // before output lands; non-shell tools keep their model-provided title. function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) { - if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName + if (isShell(toolName)) return shellCommand(input) ?? fallback ?? toolName return fallback || toolName } diff --git a/packages/opencode/src/cli/cmd/run/tool.ts b/packages/opencode/src/cli/cmd/run/tool.ts index 52b29528b279..9a717ba4e6c3 100644 --- a/packages/opencode/src/cli/cmd/run/tool.ts +++ b/packages/opencode/src/cli/cmd/run/tool.ts @@ -623,20 +623,18 @@ function snapQuestion(p: ToolProps): ToolSnapshot { function scrollBashStart(p: ToolProps): string { const cmd = p.input.command ?? "" - const desc = p.input.description || "Shell" const wd = p.input.workdir ?? "" - const dir = wd && wd !== "." ? toolPath(wd) : "" - if (cmd && desc === "Shell" && !dir) { + const formatted = wd && wd !== "." ? toolPath(wd) : "" + const dir = formatted === "." ? "" : formatted + if (cmd && !dir) { return `$ ${cmd}` } - const title = dir && !desc.includes(dir) ? `${desc} in ${dir}` : desc - if (!cmd) { - return `# ${title}` + return dir ? `# Running in ${dir}` : "" } - return `# ${title}\n$ ${cmd}` + return `# Running in ${dir}\n$ ${cmd}` } function scrollBashProgress(p: ToolProps): string { @@ -968,11 +966,10 @@ function permList(p: ToolPermissionProps): ToolPermissionInfo { } function permBash(p: ToolPermissionProps): ToolPermissionInfo { - const title = p.input.description || "Shell command" const cmd = p.input.command || "" return { icon: "#", - title, + title: "Shell command", lines: cmd ? [`$ ${cmd}`] : p.patterns.map((item) => `- ${item}`), } } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dad796c998ad..a1f4d95c33c1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -542,7 +542,7 @@ export const layer = Layer.effect( time: { ...part.state.time, end: completed }, input: part.state.input, title: "", - metadata: { output, description: "" }, + metadata: { output }, output, } yield* sessions.updatePart(part) @@ -569,7 +569,7 @@ export const layer = Layer.effect( Effect.gen(function* () { output += chunk if (part.state.status === "running") { - part.state.metadata = { output, description: "" } + part.state.metadata = { output } yield* sessions.updatePart(part) } }), diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 620378dc1044..1d3fb075fb68 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -263,7 +263,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole const ask = Effect.fn("ShellTool.ask")(function* ( ctx: Tool.Context, scan: Scan, - input: { command: string; description: string }, + input: { command: string }, ) { if (scan.dirs.size > 0) { const directories = Array.from(scan.dirs) @@ -277,7 +277,6 @@ const ask = Effect.fn("ShellTool.ask")(function* ( always: globs, metadata: { command: input.command, - description: input.description, directories, patterns: globs, }, @@ -291,7 +290,6 @@ const ask = Effect.fn("ShellTool.ask")(function* ( always: Array.from(scan.always), metadata: { command: input.command, - description: input.description, }, }) }) @@ -438,7 +436,6 @@ export const ShellTool = Tool.define( cwd: string env: NodeJS.ProcessEnv timeout: number - description: string }, ctx: Tool.Context, ) { @@ -482,7 +479,6 @@ export const ShellTool = Tool.define( yield* ctx.metadata({ metadata: { output: "", - description: input.description, }, }) @@ -523,7 +519,6 @@ export const ShellTool = Tool.define( ctx.metadata({ metadata: { output: last, - description: input.description, }, }), ), @@ -534,7 +529,6 @@ export const ShellTool = Tool.define( return ctx.metadata({ metadata: { output: last, - description: input.description, }, }) }), @@ -593,11 +587,10 @@ export const ShellTool = Tool.define( output += "\n\n\n" + meta.join("\n") + "\n" } return { - title: input.description, + title: input.command, metadata: { output: last || preview(output), exit: code, - description: input.description, truncated: cut, ...(cut && file ? { outputPath: file } : {}), }, @@ -646,7 +639,6 @@ export const ShellTool = Tool.define( cwd, env: yield* shellEnv(ctx, cwd), timeout, - description: params.description, }, ctx, ) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index bec50d98d9b3..b576b7729767 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -7,30 +7,22 @@ import { ShellID } from "./id" const PS = new Set(["powershell", "pwsh"]) const CMD = new Set(["cmd"]) -const descriptions = { - bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - powershell: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', - cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', -} - export type Limits = { maxLines: number maxBytes: number } -export function parameterSchema(description: string) { +export function parameterSchema() { return Schema.Struct({ command: Schema.String.annotate({ description: "The command to execute" }), timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), workdir: Schema.optional(Schema.String).annotate({ description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, }), - description: Schema.String.annotate({ description }), }) } -export const Parameters = parameterSchema(descriptions.bash) +export const Parameters = parameterSchema() export type Parameters = Schema.Schema.Type function renderPrompt(template: string, values: Record) { @@ -103,7 +95,6 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -155,7 +146,6 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -205,7 +195,6 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms. - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: @@ -242,7 +231,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul gitCommandRestriction: "git commands", createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, - parameterDescription: descriptions.cmd, } } if (isPowerShell) { @@ -264,7 +252,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul ## Summary - <1-3 bullet points> '@`, - parameterDescription: descriptions.powershell, } } return { @@ -280,7 +267,6 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' ## Summary <1-3 bullet points>`, - parameterDescription: descriptions.bash, } } @@ -300,7 +286,7 @@ export function render(name: string, platform: NodeJS.Platform, limits: Limits, createPrInstruction: selected.createPrInstruction, createPrExample: selected.createPrExample, }), - parameters: parameterSchema(selected.parameterDescription), + parameters: parameterSchema(), } } diff --git a/packages/opencode/test/cli/run/scrollback.surface.test.ts b/packages/opencode/test/cli/run/scrollback.surface.test.ts index f1500ec44ae5..52ff5a354d58 100644 --- a/packages/opencode/test/cli/run/scrollback.surface.test.ts +++ b/packages/opencode/test/cli/run/scrollback.surface.test.ts @@ -589,6 +589,38 @@ test("coalesces same-line tool progress into one snapshot", async () => { } }) +test("omits the current directory from bash titles", async () => { + const out = await setup() + + try { + await out.scrollback.append( + toolCommit({ + tool: "bash", + phase: "start", + toolState: "running", + state: { + status: "running", + input: { + command: "pwd", + workdir: process.cwd(), + }, + time: { start: 1 }, + }, + }), + ) + + const commits = claim(out.renderer) + try { + expect(render(commits)).toContain("$ pwd") + expect(render(commits)).not.toContain("Running in .") + } finally { + destroy(commits) + } + } finally { + out.scrollback.destroy() + } +}) + test("renders completed bash output with one blank line after the command and before the next group", async () => { const out = await setup() @@ -615,7 +647,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1 }, }, @@ -633,7 +664,6 @@ test("renders completed bash output with one blank line after the command and be input: { command: "git status", workdir: "/tmp/demo", - description: "Show git status", }, time: { start: 1, end: 2 }, }, @@ -645,6 +675,7 @@ test("renders completed bash output with one blank line after the command and be take() const output = lines.join("\n") + expect(output).toContain("# Running in /tmp/demo\n$ git status") expect(output).toContain("$ git status\n\nOn branch demo") expect(output).toContain("nothing to commit, working tree clean\n\noc-run-dev ahead 1") expect(output).not.toContain("nothing to commit, working tree clean\n\n\noc-run-dev ahead 1") @@ -677,7 +708,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, time: { start: 1 }, }, @@ -695,7 +725,6 @@ test("inserts a spacer before the next tool after completed multiline bash outpu input: { command: "pwd; ls -la", workdir: "/tmp/demo", - description: "Lists current directory files", }, output: ["/tmp/demo", "pwd; ls -la", "/tmp/demo", "total 4", "", ""].join("\n"), title: "pwd; ls -la", @@ -755,7 +784,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, time: { start: 1 }, }, @@ -805,7 +833,6 @@ test("does not double-space before completed bash output when inline tool header input: { command: "ls", workdir: "src/cli/cmd/run", - description: "Lists files in run directory", }, output: ["src/cli/cmd/run", "ls", "demo.ts", "entry.body.ts", "", ""].join("\n"), title: "ls", diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index 705e0250dea1..b685fb679f30 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -435,7 +435,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, @@ -490,7 +489,6 @@ describe("run session data", () => { title: "", metadata: { output: "/tmp/demo\n", - description: "", }, time: { start: 1, end: 2 }, }, diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts index 18ae82d4da6a..e3356d18313c 100644 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ b/packages/opencode/test/cli/run/session-replay.test.ts @@ -238,7 +238,6 @@ function shellAssistantMessage(id: string, parentID: string): SessionMessages[nu title: "", metadata: { output: "account.ts\n", - description: "", }, time: { start: 200, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 9c22e0041023..d049041fa025 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1811,7 +1811,6 @@ unix( yield* llm.tool("bash", { command: 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; printf truncation-ready; sleep 30', - description: "Print many lines", timeout: 30_000, workdir: path.resolve(dir), }) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 8a3701e12518..98233f3527d2 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -139,7 +139,6 @@ it.live("tool execution produces non-empty session diff (snapshot race)", () => const command = `echo 'snapshot race test content' > ${path.join(dir, "race-test.txt")}` yield* llm.toolMatch((hit) => JSON.stringify(hit.body).includes("create the file"), "bash", { command, - description: "create test file", }) yield* llm.textMatch((hit) => JSON.stringify(hit.body).includes("bash"), "done") diff --git a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap index b187b191c1f5..51ff867ea44d 100644 --- a/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap +++ b/packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap @@ -24,23 +24,6 @@ exports[`tool parameters JSON Schema (wire shape) bash 1`] = ` "description": "The command to execute", "type": "string", }, - "description": { - "description": -"Clear, concise description of what this command does in 5-10 words. Examples: -Input: ls -Output: Lists files in current directory - -Input: git status -Output: Shows working tree status - -Input: npm install -Output: Installs package dependencies - -Input: mkdir foo -Output: Creates directory 'foo'" -, - "type": "string", - }, "timeout": { "description": "Optional timeout in milliseconds", "exclusiveMinimum": 0, @@ -55,7 +38,6 @@ Output: Creates directory 'foo'" }, "required": [ "command", - "description", ], "type": "object", } diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index 4e56c61d23c4..9c540daad085 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -106,19 +106,16 @@ describe("tool parameters", () => { }) describe("shell", () => { - test("accepts minimum: command + description", () => { - expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + test("accepts command", () => { + expect(parse(Shell, { command: "ls" })).toEqual({ command: "ls" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description", () => { - expect(accepts(Shell, { command: "ls" })).toBe(false) - }) test("rejects missing command", () => { - expect(accepts(Shell, { description: "list" })).toBe(false) + expect(accepts(Shell, {})).toBe(false) }) }) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 2ea1d1458966..a3c6ca27bb6f 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -182,7 +182,6 @@ describe("tool.shell", () => { Effect.gen(function* () { const result = yield* run({ command: "echo test", - description: "Echo test message", }) expect(result.metadata.exit).toBe(0) expect(result.metadata.output).toContain("test") @@ -204,7 +203,6 @@ describe("tool.shell", () => { const result = yield* bash.execute( { command: "echo fallback", - description: "Echo fallback text", }, ctx, ) @@ -227,7 +225,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo hello", - description: "Echo hello", }, capture(requests), ) @@ -249,7 +246,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "echo foo && echo bar", - description: "Echo twice", }, capture(requests), ) @@ -273,7 +269,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Host foo; if ($?) { Write-Host bar }", - description: "Check PowerShell conditional", }, capture(requests), ) @@ -303,7 +298,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Remove-Item -Recurse tmp", - description: "Remove a temp directory", }, capture(requests, err), ), @@ -331,7 +325,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${file}`, - description: "Read wildcard path", }, capture(requests, err), ), @@ -359,7 +352,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `echo $(cat "${file}")`, - description: "Read nested bash file", }, capture(requests), ) @@ -389,7 +381,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Copy-Item -PassThru "${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini" ./out`, - description: "Copy Windows ini", }, capture(requests, err), ), @@ -415,7 +406,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `Write-Output $(Get-Content ${file})`, - description: "Read nested PowerShell file", }, capture(requests), ) @@ -446,7 +436,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "C:../outside.txt"', - description: "Read drive-relative file", }, capture(requests, err), ), @@ -474,7 +463,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$HOME/.ssh/config"', - description: "Read home config", }, capture(requests, err), ), @@ -503,7 +491,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PWD/../outside.txt"', - description: "Read pwd-relative file", }, capture(requests, err), ), @@ -531,7 +518,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: 'Get-Content "$PSHOME/outside.txt"', - description: "Read pshome file", }, capture(requests, err), ), @@ -567,7 +553,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path "${root}$env:${key}\\Windows\\win.ini"`, - description: "Read Windows ini with missing env", }, capture(requests, err), ), @@ -598,7 +583,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Get-Content $env:WINDIR/win.ini", - description: "Read Windows ini from env", }, capture(requests), ) @@ -626,7 +610,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `Get-Content -Path FileSystem::${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`, - description: "Read Windows ini from FileSystem provider", }, capture(requests, err), ), @@ -655,7 +638,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "Get-Content ${env:WINDIR}/win.ini", - description: "Read Windows ini from braced env", }, capture(requests, err), ), @@ -682,7 +664,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Set-Location C:/Windows", - description: "Change location", }, capture(requests), ) @@ -710,7 +691,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "Write-Output ('a' * 3)", - description: "Write repeated text", }, capture(requests), ) @@ -736,7 +716,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, - description: "Read Windows ini with cmd", }, capture(requests), ) @@ -761,7 +740,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cd ../", - description: "Change to parent directory", }, capture(requests, err), ), @@ -786,7 +764,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: os.tmpdir(), - description: "Echo from temp dir", }, capture(requests, err), ), @@ -817,7 +794,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: dir, - description: "Echo from external dir", }, capture(requests, err), ), @@ -850,7 +826,6 @@ describe("tool.shell permissions", () => { { command: "echo ok", workdir: "/tmp", - description: "Echo from Git Bash tmp", }, capture(requests, err), ), @@ -878,7 +853,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: "cat /tmp/opencode-does-not-exist", - description: "Read Git Bash tmp file", }, capture(requests, err), ), @@ -910,7 +884,6 @@ describe("tool.shell permissions", () => { yield* fail( { command: `cat ${filepath}`, - description: "Read external file", }, capture(requests, err), ), @@ -922,7 +895,6 @@ describe("tool.shell permissions", () => { expect(extDirReq!.always).toContain(expected) expect(extDirReq!.metadata).toMatchObject({ command: `cat ${filepath}`, - description: "Read external file", directories: [outerTmp], patterns: [expected], }) @@ -942,7 +914,6 @@ describe("tool.shell permissions", () => { yield* run( { command: `rm -rf ${path.join(tmp, "nested")}`, - description: "Remove nested dir", }, capture(requests), ) @@ -963,7 +934,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "git log --oneline -5", - description: "Git log", }, capture(requests), ) @@ -985,7 +955,6 @@ describe("tool.shell permissions", () => { yield* run( { command: "cd .", - description: "Stay in current directory", }, capture(requests), ) @@ -1006,7 +975,7 @@ describe("tool.shell permissions", () => { const requests: Array> = [] expect( yield* fail( - { command: "echo test > output.txt", description: "Redirect test output" }, + { command: "echo test > output.txt" }, capture(requests, err), ), ).toMatchObject({ message: err.message }) @@ -1025,7 +994,7 @@ describe("tool.shell permissions", () => { tmp, Effect.gen(function* () { const requests: Array> = [] - yield* run({ command: "ls -la", description: "List" }, capture(requests)) + yield* run({ command: "ls -la" }, capture(requests)) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.always[0]).toBe("ls *") @@ -1047,7 +1016,6 @@ describe("tool.shell abort", () => { const res = yield* run( { command: `echo before && sleep 30`, - description: "Long running command", }, { ...ctx, @@ -1078,7 +1046,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `sleep 60`, - description: "Timeout test", timeout: 500, }) expect(result.output).toContain("shell tool terminated command after exceeding timeout") @@ -1099,7 +1066,6 @@ describe("tool.shell abort", () => { const result = yield* tool.execute( { command: `sleep 60`, - description: "Default timeout test", }, ctx, ) @@ -1116,7 +1082,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `echo stdout_msg && echo stderr_msg >&2`, - description: "Stderr test", }) expect(result.output).toContain("stdout_msg") expect(result.output).toContain("stderr_msg") @@ -1132,7 +1097,6 @@ describe("tool.shell abort", () => { Effect.gen(function* () { const result = yield* run({ command: `exit 42`, - description: "Non-zero exit", }) expect(result.metadata.exit).toBe(42) }), @@ -1147,7 +1111,6 @@ describe("tool.shell abort", () => { const result = yield* run( { command: `echo first && sleep 0.1 && echo second`, - description: "Streaming test", }, { ...ctx, @@ -1174,7 +1137,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 500 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1190,7 +1152,6 @@ describe("tool.shell truncation", () => { const byteCount = Truncate.MAX_BYTES + 10000 const result = yield* run({ command: fill("bytes", byteCount), - description: "Generate bytes exceeding limit", }) mustTruncate(result) expect(result.output).toMatch(/\.\.\.output truncated\.\.\./) @@ -1205,7 +1166,6 @@ describe("tool.shell truncation", () => { Effect.gen(function* () { const result = yield* run({ command: fill("lines", 1), - description: "Generate one line", }) expect((result.metadata as { truncated?: boolean }).truncated).toBe(false) expect(result.output).toContain("1") @@ -1220,7 +1180,6 @@ describe("tool.shell truncation", () => { const lineCount = Truncate.MAX_LINES + 100 const result = yield* run({ command: fill("lines", lineCount), - description: "Generate lines for file check", }) mustTruncate(result) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 11d8b47655e0..b368110f4dbd 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1994,7 +1994,7 @@ export function InlineToolRow(props: { } function BlockTool(props: { - title: string + title?: string children: JSX.Element onClick?: () => void part?: ToolPart @@ -2023,15 +2023,19 @@ function BlockTool(props: { props.onClick?.() }} > - - {props.title} - - } - > - {props.title.replace(/^# /, "")} + + {(title) => ( + + {title()} + + } + > + {title().replace(/^# /, "")} + + )} {props.children} @@ -2059,15 +2063,15 @@ function Shell(props: ToolProps) { const workdirDisplay = createMemo(() => { const workdir = stringValue(props.input.workdir) if (!workdir || workdir === ".") return undefined - return pathFormatter.format(workdir) + const formatted = pathFormatter.format(workdir) + if (formatted === ".") return undefined + return formatted }) const title = createMemo(() => { - const desc = stringValue(props.input.description) ?? "Shell" const wd = workdirDisplay() - if (!wd) return `# ${desc}` - if (desc.includes(wd)) return `# ${desc}` - return `# ${desc} in ${wd}` + if (!wd) return + return `# Running in ${wd}` }) return ( @@ -2076,11 +2080,15 @@ function Shell(props: ToolProps) { setExpanded((prev) => !prev) : undefined} > - $ {stringValue(props.input.command)} + $ {stringValue(props.input.command)}} + > + {stringValue(props.input.command)} + {limited()} diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index c787a84a8594..766ed3c116ce 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -269,12 +269,10 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory? } if (permission === "bash") { - const title = - typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" return { icon: "#", - title, + title: "Shell command", body: ( diff --git a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap index 13110be0bc7d..46c48ef325f5 100644 --- a/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap +++ b/packages/tui/test/cli/tui/__snapshots__/inline-tool-wrap-snapshot.test.tsx.snap @@ -27,8 +27,6 @@ exports[`TUI inline tool wrapping snapshots expanded tool errors under the tool exports[`TUI inline tool wrapping keeps separation after a shell output block 1`] = ` " - # List files - $ ls file.ts diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 00e4ef5c981d..8ba730906ae0 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -62,7 +62,6 @@ function ShellOutput() { paddingLeft={2} gap={1} > - # List files $ ls file.ts diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 213a48e11f39..2477ff99b521 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,4 +1,4 @@ -import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type JSX } from "solid-js" +import { createEffect, For, Match, on, onCleanup, onMount, Show, Switch, type Accessor, type JSX } from "solid-js" import { animate, type AnimationPlaybackControls } from "motion" import { useI18n } from "../context/i18n" import { createStore } from "solid-js/store" @@ -24,7 +24,7 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { export interface BasicToolProps { icon: IconProps["name"] - trigger: TriggerTitle | JSX.Element + trigger: TriggerTitle | JSX.Element | ((open: Accessor) => JSX.Element) children?: JSX.Element status?: string hideDetails?: boolean @@ -89,6 +89,7 @@ export function BasicTool(props: BasicToolProps) { const ready = () => state.ready const pending = () => props.status === "pending" || props.status === "running" const hasChildren = () => (props.defer ? "children" in props : props.children) + const dynamicTrigger = typeof props.trigger === "function" ? props.trigger(open) : undefined let cancelReady: (() => void) | undefined @@ -187,6 +188,7 @@ export function BasicTool(props: BasicToolProps) {

+ {dynamicTrigger} {(title) => (
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index bbc3d2956cb3..47ae745e4c68 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -422,7 +422,7 @@ export function getToolInfo( return { icon: "console", title: i18n.t("ui.tool.shell"), - subtitle: input.description, + subtitle: input.command, } case "edit": return { @@ -1905,18 +1905,18 @@ ToolRegistry.register({ (
- - + +
- } + )} >
diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index 3a59c839a756..a189e3d0f3da 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -316,10 +316,10 @@ const TOOL_SAMPLES = { }, bash: { tool: "bash", - input: { command: "bun test --filter session", description: "Run session tests" }, + input: { command: "bun test --filter session" }, output: "bun test v1.3.14\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", - title: "Run session tests", + title: "bun test --filter session", metadata: { command: "bun test --filter session" }, }, edit: { diff --git a/packages/web/src/components/share/content-bash.tsx b/packages/web/src/components/share/content-bash.tsx index f8130ecc6410..14fd6df6c2cc 100644 --- a/packages/web/src/components/share/content-bash.tsx +++ b/packages/web/src/components/share/content-bash.tsx @@ -6,7 +6,6 @@ import { codeToHtml } from "shiki" interface Props { command: string output: string - description?: string expand?: boolean } @@ -45,7 +44,7 @@ export function ContentBash(props: Props) {
- {props.description} + Shell
diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index 34cfe3f42f43..67099196a07a 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -616,7 +616,6 @@ export function BashTool(props: ToolProps) { ) } diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index cb90e305e8b7..bfe7efd89c6b 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -714,6 +714,22 @@ Compatibility: - Foreground V2 bash execution is unchanged. - Reintroduce background bash only with durable status observation, completion delivery, and explicit cancellation semantics. +## 2026-06-18: Remove Bash Description Input + +Affected schema: + +- V1 and Core V2 model-facing `bash` tool parameters. + +Change: + +- Remove the V1 required and V2 optional `description` parameter. +- Derive shell presentation from the command or a generic shell label instead of model-authored description metadata. + +Compatibility: + +- Existing persisted tool calls may still contain `description`, but new tool definitions no longer expose or require it. +- Shell command execution behavior is unchanged. + ## 2026-06-04: Add Durable Session Context Snapshots Affected schema: From 237595e2421473b871f613de87e5588d8aa8acae Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 02:19:31 +0000 Subject: [PATCH 017/554] chore: generate --- packages/app/e2e/smoke/session-timeline.fixture.ts | 4 +--- packages/opencode/src/tool/shell.ts | 6 +----- packages/opencode/test/tool/shell.test.ts | 9 +++------ packages/tui/src/routes/session/index.tsx | 5 +---- 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 5a9933e9986e..58d50e3312ea 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -200,9 +200,7 @@ function turn(index: number): Message[] { ...(index % 8 === 0 ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), - ...(index % 7 === 0 - ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] - : []), + ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), ...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []), ...(index % 13 === 0 diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 1d3fb075fb68..1e4423e01774 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -260,11 +260,7 @@ const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boole return tree }) -const ask = Effect.fn("ShellTool.ask")(function* ( - ctx: Tool.Context, - scan: Scan, - input: { command: string }, -) { +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan, input: { command: string }) { if (scan.dirs.size > 0) { const directories = Array.from(scan.dirs) const globs = directories.map((dir) => { diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index a3c6ca27bb6f..7b98e721d369 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -973,12 +973,9 @@ describe("tool.shell permissions", () => { Effect.gen(function* () { const err = new Error("stop after permission") const requests: Array> = [] - expect( - yield* fail( - { command: "echo test > output.txt" }, - capture(requests, err), - ), - ).toMatchObject({ message: err.message }) + expect(yield* fail({ command: "echo test > output.txt" }, capture(requests, err))).toMatchObject({ + message: err.message, + }) const bashReq = requests.find((r) => r.permission === "bash") expect(bashReq).toBeDefined() expect(bashReq!.patterns).toContain("echo test > output.txt") diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b368110f4dbd..b57a1e5ddae2 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2083,10 +2083,7 @@ function Shell(props: ToolProps) { onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined} > - $ {stringValue(props.input.command)}} - > + $ {stringValue(props.input.command)}}> {stringValue(props.input.command)} From d2c866bf701e6ef96a748c5be4a4ffd3f9f0b8dc Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 22:49:08 -0400 Subject: [PATCH 018/554] test(core): speed up test setup --- packages/core/test/preload.ts | 4 ++++ packages/core/test/process/process.test.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/test/preload.ts b/packages/core/test/preload.ts index 8a7fd8ca7f28..39b237d70a42 100644 --- a/packages/core/test/preload.ts +++ b/packages/core/test/preload.ts @@ -1 +1,5 @@ +import path from "path" + process.env.OPENCODE_DB = ":memory:" +process.env.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "plugin", "fixtures", "models-dev.json") +process.env.OPENCODE_DISABLE_MODELS_FETCH = "true" diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index f8377f718aa5..925112414091 100644 --- a/packages/core/test/process/process.test.ts +++ b/packages/core/test/process/process.test.ts @@ -144,7 +144,7 @@ describe("AppProcess", () => { const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)` return Effect.gen(function* () { const svc = yield* AppProcess.Service - const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "1 second" })) + const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "250 millis" })) expect(Exit.isFailure(exit)).toBe(true) expect(yield* waitForFile(ready)).toMatch(/^\d+$/) expect(yield* waitForFile(settled)).toBe("settled") From 4a710e467997e27405956a80d80fce4eda774178 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 22:49:08 -0400 Subject: [PATCH 019/554] fix(core): await plugin readiness --- packages/core/src/plugin.ts | 53 +++++++++++++++++-- packages/core/test/plugin.test.ts | 30 ++++++++++- packages/opencode/src/agent/agent.ts | 2 + packages/opencode/src/session/system.ts | 2 + .../test/server/httpapi-reference.test.ts | 21 +++++--- 5 files changed, 98 insertions(+), 10 deletions(-) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index bbaa7dead113..4bb40934a21a 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,6 +1,6 @@ export * as PluginV2 from "./plugin" -import { Context, Effect, Exit, Layer, Schema, Scope } from "effect" +import { Context, Deferred, Effect, Exit, Layer, Schema, Scope } from "effect" import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "./agent" import { AISDK } from "./aisdk" @@ -29,6 +29,7 @@ export const Event = { export interface Interface { readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect readonly remove: (id: ID) => Effect.Effect + readonly wait: (id: ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Plugin") {} @@ -41,13 +42,18 @@ export const layer = Layer.effect( const scope = yield* Scope.make() const active = new Map() const loading = new Set() + const waiters = new Map>>() + const failures = new Map>() let host: Parameters[0] const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) { if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`) yield* locks.withLock(id)( - Effect.sync(() => loading.add(id)).pipe( + Effect.sync(() => { + loading.add(id) + failures.delete(id) + }).pipe( Effect.andThen( State.batch( Effect.gen(function* () { @@ -61,11 +67,22 @@ export const layer = Layer.effect( Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }), Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), ) - active.set(id, child) yield* events.publish(Event.Added, { id }) + active.set(id, child) + yield* Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.succeed(waiter, undefined), { + discard: true, + }) + waiters.delete(id) }), ), ), + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) return Effect.void + failures.set(id, exit) + return Effect.forEach(waiters.get(id) ?? [], (waiter) => Deferred.done(waiter, exit), { + discard: true, + }).pipe(Effect.ensuring(Effect.sync(() => waiters.delete(id)))) + }), Effect.ensuring(Effect.sync(() => loading.delete(id))), ), ) @@ -79,12 +96,41 @@ export const layer = Layer.effect( Effect.gen(function* () { const current = active.get(id) active.delete(id) + failures.delete(id) if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore) }), ), ) }) + const wait = Effect.fn("Plugin.wait")(function* (id: ID) { + const waiter = yield* Deferred.make() + const pending = yield* locks.withLock(id)( + Effect.sync(() => { + if (active.has(id)) return false + const failure = failures.get(id) + if (failure) return failure + const current = waiters.get(id) ?? new Set() + current.add(waiter) + waiters.set(id, current) + return true + }), + ) + if (!pending) return + if (typeof pending !== "boolean") return yield* pending + yield* Deferred.await(waiter).pipe( + Effect.ensuring( + locks.withLock(id)( + Effect.sync(() => { + const current = waiters.get(id) + current?.delete(waiter) + if (current?.size === 0) waiters.delete(id) + }), + ), + ), + ) + }) + yield* Effect.addFinalizer((exit) => Effect.gen(function* () { active.clear() @@ -95,6 +141,7 @@ export const layer = Layer.effect( const service = Service.of({ add, remove, + wait, }) host = yield* PluginHost.make(service) return service diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index b787c754d8c7..1e278976539b 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { Effect, Exit, Fiber } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -9,6 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture" const it = testEffect(PluginTestLayer) describe("PluginV2", () => { + it.effect("waits for a plugin and returns immediately once active", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const id = PluginV2.ID.make("waited") + const waiting = yield* plugins.wait(id).pipe(Effect.forkChild) + + yield* plugins.add(id, () => Effect.void) + yield* Fiber.join(waiting) + yield* plugins.wait(id) + }), + ) + + it.effect("propagates plugin activation defects to waiters", () => + Effect.gen(function* () { + const plugins = yield* PluginV2.Service + const id = PluginV2.ID.make("failed") + const waiting = yield* plugins.wait(id).pipe(Effect.exit, Effect.forkChild) + + const added = yield* plugins.add(id, () => Effect.die("boom")).pipe(Effect.exit) + const pending = yield* Fiber.join(waiting) + const later = yield* plugins.wait(id).pipe(Effect.exit) + + expect(Exit.isFailure(added)).toBe(true) + expect(Exit.isFailure(pending)).toBe(true) + expect(Exit.isFailure(later)).toBe(true) + }), + ) + it.effect("adds, replaces, and removes plugins", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 8e6480538ed6..dfb838fac3c2 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -30,6 +30,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" +import { PluginV2 } from "@opencode-ai/core/plugin" export const Info = Schema.Struct({ name: Schema.String, @@ -99,6 +100,7 @@ export const layer = Layer.effect( const cfg = yield* config.get() const skillDirs = yield* skill.dirs() const referenceDirs = yield* Effect.gen(function* () { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) const whitelistedDirs = [ diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 49b79018579b..35ef47ab122f 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -20,6 +20,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Reference } from "@opencode-ai/core/reference" +import { PluginV2 } from "@opencode-ai/core/plugin" export function provider(model: Provider.Model) { if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) @@ -54,6 +55,7 @@ export const layer = Layer.effect( environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { const ctx = yield* InstanceState.context const references = yield* Effect.gen(function* () { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) return [ diff --git a/packages/opencode/test/server/httpapi-reference.test.ts b/packages/opencode/test/server/httpapi-reference.test.ts index dcb260588b7d..bf6151268165 100644 --- a/packages/opencode/test/server/httpapi-reference.test.ts +++ b/packages/opencode/test/server/httpapi-reference.test.ts @@ -4,6 +4,8 @@ import { Server } from "../../src/server/server" import { Global } from "@opencode-ai/core/global" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { Effect } from "effect" +import { pollWithTimeout } from "../lib/effect" afterEach(async () => { await disposeAllInstances() @@ -24,12 +26,19 @@ describe("reference HttpApi", () => { }, }) - const response = await Server.Default().app.request("/api/reference", { - headers: { "x-opencode-directory": tmp.path }, - }) - - expect(response.status).toBe(200) - const body = await response.json() + const body = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await Server.Default().app.request("/api/reference", { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + const body = await response.json() + return body.data.length === 0 ? undefined : body + }), + "references were not loaded", + ), + ) expect(body).toMatchObject({ location: { directory: tmp.path } }) expect(body.data).toEqual([ { From af14fefc96b11ecb0c53ac63ce37f5b3cf740eb7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 23:01:05 -0400 Subject: [PATCH 020/554] test(opencode): relax compaction readiness timeout --- packages/opencode/test/session/compaction.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 63276bfe197d..f2ee0b65d72a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1250,7 +1250,7 @@ describe("session.compaction.process", () => { }) .pipe(Effect.forkChild) - yield* Deferred.await(ready).pipe(Effect.timeout("1 second")) + yield* Deferred.await(ready).pipe(Effect.timeout("5 seconds")) const start = Date.now() yield* Fiber.interrupt(fiber) const exit = yield* Fiber.await(fiber).pipe(Effect.timeout("250 millis")) @@ -1263,6 +1263,7 @@ describe("session.compaction.process", () => { }).pipe(withCompaction({ llm: stub.layer })) }, { git: true }, + { timeout: 10_000 }, ) itCompaction.instance( From ed75ce9eccb6d97a4ee0f79d3db8cb33b47b0661 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 22 Jun 2026 23:39:26 -0400 Subject: [PATCH 021/554] fix(core): prioritize reference plugin registration --- packages/core/src/plugin/internal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 6aeef4dc3d2d..e170a85f11d1 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -98,6 +98,7 @@ export const locationLayer = Layer.effectDiscard( } yield* Effect.gen(function* () { + yield* add(ConfigReferencePlugin.Plugin) yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) yield* add(SkillPlugin.Plugin) @@ -106,7 +107,6 @@ export const locationLayer = Layer.effectDiscard( yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin) - yield* add(ConfigReferencePlugin.Plugin) for (const item of ProviderPlugins) yield* add(item) yield* add(ConfigExternalPlugin.Plugin) }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) From 6ea3e6698add79d85d731515fa4365d87e0216ab Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 00:10:36 -0400 Subject: [PATCH 022/554] fix(opencode): scope reference readiness wait --- packages/opencode/src/agent/agent.ts | 10 ++++++---- packages/opencode/src/session/system.ts | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index dfb838fac3c2..cb2393813273 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -99,10 +99,12 @@ export const layer = Layer.effect( Effect.fn("Agent.state")(function* (ctx) { const cfg = yield* config.get() const skillDirs = yield* skill.dirs() - const referenceDirs = yield* Effect.gen(function* () { - yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) - return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) - }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) + const referenceDirs = Object.keys(cfg.references ?? cfg.reference ?? {}).length + ? yield* Effect.gen(function* () { + yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) + return (yield* (yield* Reference.Service).list()).map((reference) => reference.path) + }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) + : [] const whitelistedDirs = [ Truncate.GLOB, path.join(Global.Path.tmp, "*"), diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 35ef47ab122f..49b79018579b 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -20,7 +20,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Reference } from "@opencode-ai/core/reference" -import { PluginV2 } from "@opencode-ai/core/plugin" export function provider(model: Provider.Model) { if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) @@ -55,7 +54,6 @@ export const layer = Layer.effect( environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { const ctx = yield* InstanceState.context const references = yield* Effect.gen(function* () { - yield* (yield* PluginV2.Service).wait(PluginV2.ID.make("core/config-reference")) return (yield* (yield* Reference.Service).list()).filter((reference) => reference.description !== undefined) }).pipe(Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(ctx.directory) })))) return [ From 3c5632e110780cb5f1f923ad8d0b35866457f31a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 00:26:48 -0400 Subject: [PATCH 023/554] test(opencode): stabilize Windows CLI subprocesses --- packages/opencode/test/cli/help/help-snapshots.test.ts | 3 +-- packages/opencode/test/lib/cli-process.ts | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index a2626b113dac..3a14d0d7ecc7 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -13,7 +13,6 @@ // version (changes per release), so we'd snapshot a moving target. import { describe, expect } from "bun:test" import { Effect } from "effect" -import { EOL } from "os" import { cliIt } from "../../lib/cli-process" import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot" @@ -101,7 +100,7 @@ describe("opencode CLI help-text snapshots", () => { Effect.gen(function* () { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) - expect(topLevel.stderr.endsWith(EOL)).toBe(true) + expect(topLevel.stderr.endsWith("\n")).toBe(true) expect(topLevel.stderr).toContain("--mini") expect(topLevel.stderr).not.toContain("--thinking") expect(topLevel.stderr).not.toContain("--variant") diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 6b16ab74b544..dd03c786a453 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -517,5 +517,10 @@ export const cliIt = { name: string, body: (input: CliFixture) => Effect.Effect, opts?: number | TestOptions, - ) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts), + ) => + (process.platform === "win32" ? test : test.concurrent)( + name, + () => Effect.runPromise(Effect.scoped(withCliFixture(body))), + opts, + ), } From 81851ca6b9c22bc340abe52691eab666be26dd50 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 00:41:02 -0400 Subject: [PATCH 024/554] test(opencode): stabilize Windows async readiness --- .../opencode/test/server/httpapi-file.test.ts | 20 ++++++++++++++----- packages/opencode/test/session/prompt.test.ts | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index 55377f847712..ed882ade4652 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Context } from "effect" +import { Context, Effect } from "effect" import path from "path" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { pollWithTimeout } from "../lib/effect" const context = Context.empty() as Context.Context @@ -55,17 +56,26 @@ describe("file HttpApi", () => { await using tmp = await tmpdir({ git: true }) await Bun.write(path.join(tmp.path, "hello.txt"), "needle") - const [text, files, symbols] = await Promise.all([ + const [text, symbols] = await Promise.all([ request(FilePaths.findText, tmp.path, { pattern: "needle" }), - request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }), request(FilePaths.findSymbol, tmp.path, { query: "hello" }), ]) + const files = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }) + const body = await response.json() + return body.includes("hello.txt") ? { response, body } : undefined + }), + "file search index was not ready", + ), + ) expect(text.status).toBe(200) expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) - expect(files.status).toBe(200) - expect(await files.json()).toContain("hello.txt") + expect(files.response.status).toBe(200) + expect(files.body).toContain("hello.txt") expect(symbols.status).toBe(200) expect(await symbols.json()).toEqual([]) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index d049041fa025..a01cc9d1231e 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1630,7 +1630,7 @@ it.instance( expect(yield* llm.calls).toBe(1) }), { git: true }, - 3_000, + 10_000, ) it.instance( From 9b01f15f1d9dbd6b9ad6035255cd040d8f9a8ec3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 23 Jun 2026 00:56:14 -0400 Subject: [PATCH 025/554] test(opencode): retry Windows CLI cleanup --- packages/opencode/test/lib/cli-process.ts | 12 ++++++++---- packages/opencode/test/server/httpapi-sdk.test.ts | 9 +++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index dd03c786a453..d3a4493ce17d 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -20,7 +20,7 @@ import { test, type TestOptions } from "bun:test" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" -import { Deferred, Duration, Effect, Layer, Queue, Scope, Stream } from "effect" +import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcess } from "effect/unstable/process" import path from "node:path" @@ -192,9 +192,13 @@ export function withCliFixture( const fs = yield* FSUtil.Service const appProc = yield* AppProcess.Service - // FileSystem.makeTempDirectoryScoped handles both creation and scope-tied - // cleanup — replaces the old mkdir + addFinalizer pair. - const home = yield* fs.makeTempDirectoryScoped({ prefix: "oc-cli-" }) + const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" }) + yield* Effect.addFinalizer(() => + fs.remove(home, { recursive: true }).pipe( + Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), + Effect.ignore, + ), + ) const configJson = JSON.stringify(testProviderConfig(llm.url)) const env = isolatedEnv(home, configJson) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 895659a6f7c8..63cc3edb2c18 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -22,7 +22,7 @@ import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture" -import { awaitWithTimeout, testEffect } from "../lib/effect" +import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" import { testProviderConfig } from "../lib/test-provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -389,7 +389,12 @@ describe("HttpApi SDK", () => { workspaceID, onRequest: (value) => (request = value), }) - const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" })) + const found = yield* pollWithTimeout( + call(() => sdk.v2.fs.find({ query: "hello", type: "file" })).pipe( + Effect.map((result) => (result.data?.data.length ? result : undefined)), + ), + "SDK file search index was not ready", + ) const url = new URL(request!.url) expect(found.response.status).toBe(200) From 40db33c4153bbea5776f90f707a48129b3b2d872 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 04:57:55 +0000 Subject: [PATCH 026/554] chore: generate --- packages/opencode/test/lib/cli-process.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index d3a4493ce17d..6de9033ffe38 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -194,10 +194,9 @@ export function withCliFixture( const home = yield* fs.makeTempDirectory({ prefix: "oc-cli-" }) yield* Effect.addFinalizer(() => - fs.remove(home, { recursive: true }).pipe( - Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), - Effect.ignore, - ), + fs + .remove(home, { recursive: true }) + .pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore), ) const configJson = JSON.stringify(testProviderConfig(llm.url)) From 0c4f508c507dc34e335f9ce8ff2f12f5fe50c5df Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:19:18 +0800 Subject: [PATCH 027/554] feat(app): add server-keyed session routes (#32570) --- .../app/e2e/smoke/session-timeline.fixture.ts | 2 + .../app/e2e/smoke/session-timeline.spec.ts | 3 +- packages/app/src/app.tsx | 261 ++++++++++---- .../app/src/components/prompt-input/submit.ts | 7 +- packages/app/src/components/titlebar.tsx | 105 +++--- packages/app/src/context/comments.tsx | 5 +- packages/app/src/context/file.tsx | 3 +- packages/app/src/context/layout.tsx | 16 +- packages/app/src/context/notification.tsx | 8 +- packages/app/src/context/permission.tsx | 8 +- packages/app/src/context/prompt.tsx | 6 +- packages/app/src/context/tabs.tsx | 28 +- packages/app/src/context/terminal.tsx | 12 +- packages/app/src/pages/directory-layout.tsx | 30 +- packages/app/src/pages/home.tsx | 22 +- packages/app/src/pages/layout-new.tsx | 38 +++ packages/app/src/pages/layout.tsx | 321 ++++++++---------- packages/app/src/pages/session.tsx | 12 +- .../composer/session-composer-region.tsx | 7 +- .../app/src/pages/session/session-layout.ts | 14 +- .../app/src/pages/session/terminal-panel.tsx | 8 +- .../session/timeline/message-timeline.tsx | 17 +- .../pages/session/use-session-commands.tsx | 11 +- packages/app/src/utils/session-route.test.ts | 39 +++ packages/app/src/utils/session-route.ts | 25 ++ 25 files changed, 630 insertions(+), 378 deletions(-) create mode 100644 packages/app/src/pages/layout-new.tsx create mode 100644 packages/app/src/utils/session-route.test.ts create mode 100644 packages/app/src/utils/session-route.ts diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 58d50e3312ea..3dce37cafd9d 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -21,6 +21,7 @@ const words = [ "vector", ] +const serverKey = "http://127.0.0.1:4096" const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" const directory = "C:/OpenCode/SmokeProject" @@ -240,6 +241,7 @@ function orderedParts(message: Message) { export const fixture = { directory, + serverKey, project: { id: projectID, worktree: directory, diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index a03a750743d3..dba7eae7e363 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -718,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin } async function switchTitlebarSession(page: Page, sessionID: string, title: string) { - const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` + console.log(process.env) + const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}` const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() await expect(tab).toBeVisible() await tab.click() diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 75f0c6b44465..e2f6108fd2bc 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" -import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query" import { Effect } from "effect" import { type Component, @@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web" import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" -import { ServerSDKProvider } from "@/context/server-sdk" +import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSyncProvider } from "@/context/server-sync" import { GlobalProvider } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" @@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" -import Layout from "@/pages/layout" +import LegacyLayout from "@/pages/layout" +import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" +import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route" -const HomeRoute = lazy(() => import("@/pages/home")) +const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome }))) +const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome }))) const Session = lazy(() => import("@/pages/session")) const NewSession = lazy(() => import("@/pages/new-session")) @@ -64,6 +67,10 @@ const SessionRoute = Object.assign( const server = useServer() const tabs = useTabs() + if (params.id && settings.general.newLayoutDesigns()) { + return + } + // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) // is replaced by a draft at /new-session?draftId=… createEffect(() => { @@ -82,29 +89,55 @@ const SessionRoute = Object.assign( { preload: Session.preload }, ) +const TargetSessionRoute = Object.assign( + () => { + const sdk = useSDK() + const serverSDK = useServerSDK() + return ( + + + + + + ) + }, + { preload: Session.preload }, +) + // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // server via ServerKey, then provide the server-scoped shell (Permission/Layout/ // Notification/Models + the visual Layout) for that server. -function SelectedServerLayout(props: ParentProps) { +function SelectedServerProviders(props: ParentProps) { return ( - - {props.children} - + {props.children} ) } +function LegacyServerLayout(props: ParentProps) { + return ( + + {props.children} + + ) +} + // Wraps /new-session. It resolves the draft's target server and provides the // server-scoped shell for that server — without ServerKey, so the page never depends // on the globally "selected" server. -function DraftServerLayout(props: ParentProps) { +function TargetServerLayout(props: ParentProps) { const server = useServer() const tabs = useTabs() + const params = useParams<{ serverKey?: string }>() const [search] = useSearchParams<{ draftId?: string }>() const conn = createMemo(() => { + if (params.serverKey) { + const key = requireServerKey(params.serverKey) + return server.list.find((item) => ServerConnection.key(item) === key) + } const id = search.draftId if (!id) return undefined const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id) @@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) { return ( - {props.children} + {props.children} ) } +function TargetDirectoryLayout(props: ParentProps) { + const params = useParams<{ serverKey?: string; id?: string }>() + const [search] = useSearchParams<{ draftId?: string }>() + const settings = useSettings() + const tabs = useTabs() + const serverSDK = useServerSDK() + const serverKey = createMemo(() => { + if (params.serverKey) return requireServerKey(params.serverKey) + if (!search.draftId) return undefined + return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server + }) + + const resolved = useQuery(() => ({ + queryKey: [serverSDK().scope, "session-route", params.id] as const, + enabled: !!params.serverKey && !!params.id, + placeholderData: keepPreviousData, + queryFn: async () => { + const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data! + const root = await rootSession(session, (sessionID) => + serverSDK() + .client.session.get({ sessionID }) + .then((result) => result.data!), + ) + return { session, rootID: root.id } + }, + })) + const resolvedDirectory = createMemo(() => { + if (params.serverKey) return resolved.data?.session.directory + if (!search.draftId) return undefined + return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory + }) + const directory = createMemo((prev) => prev ?? resolvedDirectory()) + const home = () => !params.serverKey && !search.draftId + const targetDirectory = () => directory()! + + createEffect(() => { + const current = resolved.data + const key = serverKey() + if (!current || !key) return + tabs.addSessionTab({ + server: key, + sessionId: current.rootID, + }) + }) + + return ( + (home() ? undefined : directory())} sessionID={() => params.id}> + + }> + + } + > + + + + {props.children} + + + + + + + + + ) +} + function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() return ( }> - {(draftID) => } + ) } -function ResolvedDraftRoute(props: { draftID: string }) { - const tabs = useTabs() - const draft = createMemo(() => - tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID), - ) - - // Key on the directory so retargeting the draft's project re-instantiates the - // directory-scoped providers while keeping the same draft id. The draft's target - // server is provided by DraftServerLayout, so changing only the server updates the - // SDK/sync hooks without remounting the composer. - const directory = () => draft()?.directory - +function ResolvedDraftRoute() { return ( - - {(dir) => ( - - - - - - - - )} - + + + ) } @@ -210,32 +293,51 @@ function BodyDesignClass() { // shell (router root) so they stay mounted regardless of the active server/route. function SharedProviders(props: ParentProps) { return ( - + <> {props.children} - + ) } // Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside // each per-route server layout so they resolve to that route's server (selected vs // draft). The Layout remounts when crossing between those groups. -function ServerScopedShell(props: ParentProps) { +type ServerScopedShellProps = ParentProps<{ + directory?: () => string | undefined + sessionID?: () => string | undefined +}> + +function ServerScopedProviders(props: ServerScopedShellProps) { return ( - + - - - {props.children} - + + {props.children} ) } +function LegacyServerScopedShell(props: ServerScopedShellProps) { + return ( + + {props.children} + + ) +} + +function NewServerScopedShell(props: ServerScopedShellProps) { + return ( + + {props.children} + + ) +} + function SessionProviders(props: ParentProps) { return ( @@ -439,28 +541,61 @@ export function AppInterface(props: { servers={props.servers} > - - ( - - {routerProps.children} - - )} - > - - - - } /> - - - - - - - - + + + + ( + + {routerProps.children} + + )} + > + + + + + ) } + +function Routes() { + const settings = useSettings() + + return ( + <> + + {} + + } /> + + + + + + { + <> + + + { + const server = useServer() + const { id } = useParams() + + return + }} + /> + + + } + + + + + + ) +} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index a95f0a603076..621723ae1105 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { local.session.promote(sessionDirectory, session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) const draftID = search.draftId - if (draftID) - tabs.promoteDraft(draftID, { - server: server.key, - dirBase64: base64Encode(sessionDirectory), - sessionId: session.id, - }) + if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id }) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 82771432041b..3312856391f3 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" -import { useServerSync } from "@/context/server-sync" import { base64Encode } from "@opencode-ai/core/util/encode" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" @@ -38,10 +37,11 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { useGlobal } from "@/context/global" -import { decode64 } from "@/utils/base64" import { ServerConnection, useServer } from "@/context/server" -import { tabHref, useTabs, type Tab } from "@/context/tabs" +import { tabHref, useTabs } from "@/context/tabs" import "./titlebar.css" +import { useServerSDK } from "@/context/server-sdk" +import { Session } from "@opencode-ai/sdk/v2" type TauriDesktopWindow = { startDragging?: () => Promise @@ -252,7 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { {(_) => { - const serverSync = useServerSync() + const serverSdk = useServerSDK() const navigate = useNavigate() const layout = useLayout() @@ -268,6 +268,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs + const [session] = createResource( + () => { + const route = layout.route() + return route.type === "session" ? route : undefined + }, + (route) => + serverSdk() + .client.session.get({ sessionID: route.sessionId }) + .then((x) => x.data) + .catch(() => {}), + ) const matchRoute = (route: LayoutRoute) => { if (route.type === "home") return @@ -280,10 +291,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, ) if (main) return main - const sync = serverSync().createDirSyncContext(route.dir) - const session = sync.session.get(route.sessionId) - if (session?.parentID) { - const parentID = session.parentID + const s = session() + if (s?.parentID) { + const parentID = s.parentID const parent = tabsStore.find( (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, ) @@ -304,15 +314,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { } if (route.type === "session") { - const sync = serverSync().createDirSyncContext(route.dir) - const session = sync.session.get(route.sessionId) - if (!session) return - const sessionId = session.parentID ?? session.id - const next = { - server: route.server ?? server.key, - dirBase64: route.dirBase64, - sessionId, - } + const s = session() + if (!s) return + const sessionId = s.parentID ?? s.id + const next = { server: route.server ?? server.key, sessionId } tabsStoreActions.addSessionTab(next) } }) @@ -495,25 +500,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { ) } + const [session] = createResource( + () => tab.sessionId, + (sessionID) => + serverSdk() + .client.session.get({ sessionID }) + .then((x) => x.data) + .catch(() => undefined), + ) + return ( <> {divider()} - { - tabs.select(tab) - - ref.scrollIntoView({ behavior: "instant" }) - }} - onClose={() => tabsStoreActions.removeTab(i())} - active={currentTab() === tab} - activeServer={tab.server === server.key} - forceTruncate={tabsAreOverflowing()} - /> + + {(session) => ( + { + tabs.select(tab) + + ref.scrollIntoView({ behavior: "instant" }) + }} + onClose={() => tabsStoreActions.removeTab(i())} + active={currentTab() === tab} + activeServer={tab.server === server.key} + forceTruncate={tabsAreOverflowing()} + /> + )} + ) }} @@ -793,7 +811,6 @@ function TabNavItem(props: { ref?: HTMLDivElement href: string server: ServerConnection.Key - directory: string sessionId?: string hideClose?: boolean onClose: () => void @@ -801,31 +818,19 @@ function TabNavItem(props: { active?: boolean activeServer: boolean forceTruncate?: boolean + session: Session }) { const closeTab = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() props.onClose() } + const global = useGlobal() const serverCtx = createMemo(() => { const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) if (conn) return global.createServerCtx(conn) }) - const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory)) - - const [session] = createResource( - () => { - const ctx = dirSyncCtx() - if (!ctx || !props.sessionId) return - return [props.sessionId, ctx] as const - }, - async ([sessionId, dirSyncCtx]) => { - await dirSyncCtx.session.sync(sessionId).catch(() => {}) - return dirSyncCtx.session.get(sessionId) - }, - { initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined }, - ) return (
- + {(session) => { const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) @@ -853,7 +858,7 @@ function TabNavItem(props: { diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 71186a55f74a..afc59b595627 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "./server-sdk" import type { ServerScope } from "@/utils/server-scope" import { createScopedCache } from "@/utils/scoped-cache" import { uuid } from "@/utils/uuid" import type { SelectedLineRange } from "@/context/file" +import { useSDK } from "./sdk" export type LineComment = { id: string @@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont gate: false, init: () => { const params = useParams() + const sdk = useSDK() const serverSDK = useServerSDK() const cache = createScopedCache( (key) => { @@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont return cache.get(key).value } - const session = createMemo(() => load(params.dir!, params.id)) + const session = createMemo(() => load(base64Encode(sdk().directory), params.id)) return { ready: () => session().ready(), diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 14ed7466c188..f7668c19492b 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { showToast } from "@/utils/toast" import { useParams } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" import { getFilename } from "@opencode-ai/core/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const scope = createMemo(() => sdk().directory) const path = createPathHelpers(scope) const tabs = layout.tabs(() => - SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)), + SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)), ) const inflight = new Map>() diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 53bf40e70594..edd58ad7a746 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" +import { requireServerKey } from "@/utils/session-route" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -79,7 +80,7 @@ export type LayoutRoute = | { type: "home" } | { type: "draft"; draftID: string; server?: ServerConnection.Key } | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } - | { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } + | { type: "session"; sessionId: string; server?: ServerConnection.Key } function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { const all = current?.all ?? [] @@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => { return { type: "draft", draftID } } + if (parts[0] === "server" && parts[2] === "session" && parts[3]) { + return { + type: "session", + sessionId: parts[3], + server: requireServerKey(parts[1]), + } + } + const dirBase64 = parts[0] const dir = decode64(dirBase64) if (!dir) return { type: "home" } @@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => { if (parts[1] !== "session") return { type: "home" } const id = parts[2] - if (id) return { type: "session", dir, dirBase64, sessionId: id } + if (id) return { type: "session", sessionId: id } return { type: "dir-new-sesssion", dir, dirBase64 } } @@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const route = createMemo(() => { const value = currentRoute(location.pathname, location.search) if (value.type === "home") return value + if (value.server) return value return { ...value, server: server.key } }) @@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( handoff: { tabs: createMemo(() => store.handoff?.tabs), setTabs(dir: string, id: string) { - setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() }) + setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() }) }, clearTabs() { if (!store.handoff?.tabs) return diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 0814dbce7ca5..ca0ea5f86c53 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,5 +1,5 @@ import { createStore, reconcile } from "solid-js/store" -import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import { useServerSDK } from "./server-sdk" @@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) { export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", gate: false, - init: () => { + init: (props: { directory?: Accessor; sessionID?: Accessor }) => { const params = useParams() const serverSDK = useServerSDK() const serverSync = useServerSync() @@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const empty: Notification[] = [] const currentDirectory = createMemo(() => { - return decode64(params.dir) + return props.directory?.() ?? decode64(params.dir) }) - const currentSession = createMemo(() => params.id) + const currentSession = createMemo(() => props.sessionID?.() ?? params.id) const [store, setStore, _, ready] = persisted( Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index dce3a4049996..ff43638bbc85 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" +import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" @@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) { export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ name: "Permission", gate: false, - init: () => { + init: (props: { directory?: Accessor }) => { const params = useParams() const serverSDK = useServerSDK() const serverSync = useServerSync() const permissionsEnabled = createMemo(() => { - const directory = decode64(params.dir) + const directory = props.directory?.() ?? decode64(params.dir) if (!directory) return false const [store] = serverSync().child(directory) return hasPermissionPromptRules(store.config.permission) @@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple // When config has permission: "allow", auto-enable directory-level auto-accept createEffect(() => { if (!ready()) return - const directory = decode64(params.dir) + const directory = props.directory?.() ?? decode64(params.dir) if (!directory) return const [childStore] = serverSync().child(directory) const perm = childStore.config.permission diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 4a62c2a8d8de..62818550da69 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { checksum } from "@opencode-ai/core/util/encode" +import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useParams, useSearchParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" @@ -7,6 +7,7 @@ import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "./server-sdk" import type { ServerScope } from "@/utils/server-scope" +import { useSDK } from "./sdk" interface PartBase { content: string @@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( gate: false, init: () => { const params = useParams() + const sdk = useSDK() const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK() const cache = new Map() @@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const session = createMemo(() => - load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }), + load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }), ) const pick = (scope?: Scope) => (scope ? load(scope) : session()) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 393875ff0915..1a4a47597879 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -1,6 +1,5 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/core/util/encode" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { ServerConnection, useServer } from "./server" @@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router" import { usePlatform } from "./platform" import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" +import { sessionHref } from "@/utils/session-route" export type SessionTab = { type: "session" server: ServerConnection.Key - dirBase64: string sessionId: string } @@ -34,16 +33,12 @@ type RecentTab = { export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const tabHref = (tab: Tab) => - tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` + tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId) export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { - const dirBase64 = base64Encode(session.directory) - return tabs.some( - (tab) => - tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id, - ) + return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id) } export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ @@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const navigateTab = (tab: Tab) => { const href = tabHref(tab) setRecentKey(tabKey(tab)) - if (tab.server === server.key) { - navigate(href) - return - } - void startTransition(() => { - server.setActive(tab.server) - navigate(href) - }) + navigate(href) } const actions = { @@ -196,10 +184,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const removed = store .filter( (tab) => - tab.type === "session" && - tab.server === server.key && - atob(tab.dirBase64) === input.directory && - input.sessionIDs.includes(tab.sessionId), + tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId), ) .map(tabKey) void startTransition(() => { @@ -211,7 +196,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ ? tabHref({ type: "session", server: server.key, - dirBase64: params.dir, sessionId: params.id, }) : undefined @@ -224,14 +208,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const removedCurrent = currentTab?.type === "session" && currentTab.server === server.key && - atob(currentTab.dirBase64) === input.directory && sessionIDs.has(currentTab.sessionId) for (let i = tabs.length - 1; i >= 0; i--) { const tab = tabs[i] if (!tab || tab.type !== "session") continue if (tab.server !== server.key) continue - if (atob(tab.dirBase64) !== input.directory) continue if (!sessionIDs.has(tab.sessionId)) continue tabs.splice(i, 1) } diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index d1aa61c4ce57..a9c66c483313 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli import { useParams } from "@solidjs/router" import { useSDK, type DirectorySDK } from "./sdk" import type { Platform } from "./platform" -import { useServer } from "./server" +import { useServerSDK } from "./server-sdk" +import { base64Encode } from "@opencode-ai/core/util/encode" import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" @@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont gate: false, init: () => { const sdk = useSDK() - const server = useServer() + const serverSDK = useServerSDK() const params = useParams() const cache = new Map() - const scope = server.scope() + const scope = () => serverSDK().scope + const directory = createMemo(() => base64Encode(sdk().directory)) caches.add(cache) onCleanup(() => caches.delete(cache)) @@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope)) + const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope())) createEffect( on( - () => ({ dir: params.dir, id: params.id, scope }), + () => ({ dir: directory(), id: params.id, scope: scope() }), (next, prev) => { if (!prev?.dir) return if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f937c98facff..d9d5a2edc624 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" +import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" import { Schema } from "effect" +import type { ServerConnection } from "@/context/server" +import { sessionHref } from "@/utils/session-route" -export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) { +export function DirectoryDataProvider( + props: ParentProps<{ + directory: string | Accessor + draftID?: string + server?: Accessor + }>, +) { const location = useLocation() const navigate = useNavigate() const params = useParams() const sync = useSync() - const slug = createMemo(() => base64Encode(props.directory)) + const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory) + const slug = createMemo(() => base64Encode(directory())) + const href = (sessionID: string) => { + const server = props.server?.() + if (server) return sessionHref(server, sessionID) + return `/${slug()}/session/${sessionID}` + } createEffect(() => { // A draft lives at /new-session?draftId=… and has no directory segment to normalize. - if (props.draftID) return + if (props.draftID || props.server?.()) return const next = sync().data.path.directory - if (!next || next === props.directory) return + if (!next || next === directory()) return const path = location.pathname.slice(slug().length + 1) navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) @@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr return ( navigate(`/${slug()}/session/${sessionID}`)} - onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} + directory={directory()} + onNavigateToSession={(sessionID: string) => navigate(href(sessionID))} + onSessionHref={href} > {props.children} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 24c7e4c3abe4..e9f036993ece 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -43,7 +43,6 @@ import { sessionTitle } from "@/utils/session-title" import { pathKey } from "@/utils/path-key" import { useGlobal } from "@/context/global" import { useCommand } from "@/context/command" -import { useSettings } from "@/context/settings" import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerHealthIndicator } from "@/components/server/server-row" import { type ServerHealth } from "@/utils/server-health" @@ -113,16 +112,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) { return `${pathKey(record.session.directory)}:${record.session.id}` } -export default function Home() { - const settings = useSettings() - return ( - }> - - - ) -} - -function HomeDesign() { +export function NewHome() { const sync = useServerSync() const layout = useLayout() const platform = usePlatform() @@ -313,7 +303,7 @@ function HomeDesign() { const ctx = global.createServerCtx(conn) ctx.projects.open(directory) ctx.projects.touch(directory) - navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`) + navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`) } function chooseProject(conn: ServerConnection.Any) { @@ -416,7 +406,7 @@ function HomeDesign() { record={record} server={state.selection.server} activeServer={state.selection.server === server.key} - openSession={openSession} + onClick={() => openSession(record.session)} /> )} @@ -1024,7 +1014,7 @@ function HomeSessionRow(props: { record: HomeSessionRecord server: ServerConnection.Key activeServer: boolean - openSession: (session: Session) => void + onClick: () => void }) { const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) @@ -1033,7 +1023,7 @@ function HomeSessionRow(props: { type="button" data-component="home-session-row" class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} - onClick={() => props.openSession(props.record.session)} + onClick={props.onClick} > group.sessions.length > 0) } -function LegacyHome() { +export function LegacyHome() { const sync = useServerSync() const platform = usePlatform() const pickDirectory = useDirectoryPicker() diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx new file mode 100644 index 000000000000..2f8793d8ddd7 --- /dev/null +++ b/packages/app/src/pages/layout-new.tsx @@ -0,0 +1,38 @@ +import { createEffect, type ParentProps } from "solid-js" +import { useNavigate } from "@solidjs/router" +import { DebugBar } from "@/components/debug-bar" +import { HelpButton } from "@/components/help-button" +import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" +import { usePlatform } from "@/context/platform" +import { setNavigate } from "@/utils/notification-click" +import { setV2Toast, ToastRegion } from "@/utils/toast" + +export default function NewLayout(props: ParentProps) { + const platform = usePlatform() + const navigate = useNavigate() + setNavigate(navigate) + + createEffect(() => setV2Toast(true)) + + const update: TitlebarUpdate = { + version: () => { + const state = platform.updater?.state() + if (state?.status !== "ready") return + return state.version + }, + installing: () => platform.updater?.state().status === "installing", + install: () => void platform.updater?.install(), + } + + return ( +
+ +
+ {props.children} +
+ {import.meta.env.DEV && } + + +
+ ) +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4177d4919c19..cb3a4bf6e35c 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -13,7 +13,7 @@ import { type Accessor, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useServerSync } from "@/context/server-sync" import { Persist, persisted } from "@/utils/persist" @@ -92,7 +92,7 @@ import { import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" -export default function Layout(props: ParentProps) { +export default function LegacyLayout(props: ParentProps) { const serverSDK = useServerSDK() const [store, setStore, , ready] = persisted( Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]), @@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) { const command = useCommand() const theme = useTheme() const language = useLanguage() - const newDesign = createMemo(() => settings.general.newLayoutDesigns()) - createEffect(() => setV2Toast(newDesign())) + createEffect(() => setV2Toast(false)) const initialDirectory = decode64(params.dir) - const location = useLocation() const route = createMemo(() => { const slug = params.dir if (!slug) return { slug, dir: "" } @@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) { const currentDir = createMemo(() => route().dir) const [state, setState] = createStore({ - autoselect: !initialDirectory && !newDesign(), + autoselect: !initialDirectory, busyWorkspaces: {} as Record, hoverProject: undefined as string | undefined, scrollSessionKey: undefined as string | undefined, @@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) { id: "sidebar.toggle", title: language.t("command.sidebar.toggle"), category: language.t("command.category.view"), - keybind: newDesign() ? undefined : "mod+b", + keybind: "mod+b", onSelect: () => layout.sidebar.toggle(), }, { @@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) { }, ] - if (!newDesign()) - Array.from({ length: 9 }, (_, i) => { - const index = i - const number = index + 1 - commands.push({ - id: `project.${number}`, - category: language.t("command.category.project"), - title: `Open Project {number}`, - keybind: `mod+${number}`, - disabled: layout.projects.list().length <= index, - hidden: true, - onSelect: () => navigateToProjectIndex(index), - }) + Array.from({ length: 9 }, (_, i) => { + const index = i + const number = index + 1 + commands.push({ + id: `project.${number}`, + category: language.t("command.category.project"), + title: `Open Project {number}`, + keybind: `mod+${number}`, + disabled: layout.projects.list().length <= index, + hidden: true, + onSelect: () => navigateToProjectIndex(index), }) + }) for (const [id] of availableThemeEntries()) { commands.push({ @@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) { createEffect(() => { document.documentElement.style.setProperty( "--dialog-left-margin", - newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, + `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, ) }) @@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) { ) return ( - - {autoselecting() ?? ""} - -
- }> - {props.children} - -
- {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && } - - -
- } - > -
- {autoselecting() ?? ""} - - - - -
-
-
- - - - - - +
+ {autoselecting() ?? ""} + + + + +
+
+
+ + + ) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 72625a615fcd..011094415b33 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@/utils/toast" -import { checksum } from "@opencode-ai/core/util/encode" +import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useLocation, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" @@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline" import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" -import { useServer } from "@/context/server" import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" @@ -92,7 +91,6 @@ export default function Page() { const prompt = usePrompt() const comments = useComments() const terminal = useTerminal() - const server = useServer() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const location = useLocation() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() @@ -137,11 +135,11 @@ export default function Page() { layout.handoff.clearTabs() return } - if (pending.scope !== server.scope()) return + if (pending.scope !== serverSDK().scope) return if (pending.id !== id) return layout.handoff.clearTabs() - if (pending.dir !== (params.dir ?? "")) return + if (pending.dir !== base64Encode(sdk().directory)) return const from = workspaceTabs().tabs() if (from.all.length === 0 && !from.active) return @@ -247,7 +245,7 @@ export default function Page() { createEffect( on( - () => ({ dir: params.dir, id: params.id }), + () => ({ dir: sdk().directory, id: params.id }), (next, prev) => { if (!prev) return if (next.dir === prev.dir && next.id === prev.id) return @@ -575,7 +573,7 @@ export default function Page() { createEffect( on( - () => params.dir, + () => sdk().directory, (dir) => { if (!dir) return setStore("newSessionWorktree", "main") diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index eab0a1ee1556..3fbde9ef3eab 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -29,6 +29,7 @@ import { useServer } from "@/context/server" import { useTabs } from "@/context/tabs" import { useDirectoryPicker } from "@/components/directory-picker" import { base64Encode } from "@opencode-ai/core/util/encode" +import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" export function SessionComposerRegion(props: { state: SessionComposerState @@ -200,7 +201,11 @@ export function SessionComposerRegion(props: { const openParent = () => { const id = parentID() if (!id) return - navigate(`/${route.params.dir}/session/${id}`) + navigate( + route.params.serverKey + ? sessionHref(requireServerKey(route.params.serverKey), id) + : legacySessionHref(sdk().directory, id), + ) } createEffect(() => { diff --git a/packages/app/src/pages/session/session-layout.ts b/packages/app/src/pages/session/session-layout.ts index 82be2bf5cdb1..1adf6c1a9674 100644 --- a/packages/app/src/pages/session/session-layout.ts +++ b/packages/app/src/pages/session/session-layout.ts @@ -1,15 +1,19 @@ import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" import { useLayout } from "@/context/layout" -import { useServer } from "@/context/server" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" +import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" +import { base64Encode } from "@opencode-ai/core/util/encode" export const useSessionKey = () => { const params = useParams() - const server = useServer() - const scope = createMemo(() => server.scope()) - const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir))) - const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id))) + const sdk = useSDK() + const serverSDK = useServerSDK() + const scope = createMemo(() => serverSDK().scope) + const directory = createMemo(() => base64Encode(sdk().directory)) + const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory()))) + const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id))) return { params, sessionKey, workspaceKey } } diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 6943a9a3ec9c..533d6b3d8274 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSettings } from "@/context/settings" import { useTerminal } from "@/context/terminal" +import { useSDK } from "@/context/sdk" import { terminalTabLabel } from "@/pages/session/terminal-label" import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" @@ -25,10 +26,11 @@ export function TerminalPanel() { const delays = [120, 240] const layout = useLayout() const terminal = useTerminal() + const sdk = useSDK() const language = useLanguage() const command = useCommand() const settings = useSettings() - const { params, workspaceKey, view } = useSessionLayout() + const { workspaceKey, view } = useSessionLayout() const opened = createMemo(() => view().terminal.opened()) const size = createSizing() @@ -122,7 +124,7 @@ export function TerminalPanel() { }) createEffect(() => { - const dir = params.dir + const dir = sdk().directory if (!dir) return if (!terminal.ready()) return language.locale() @@ -140,7 +142,7 @@ export function TerminalPanel() { }) const handoff = createMemo(() => { - const dir = params.dir + const dir = sdk().directory if (!dir) return [] return getTerminalHandoff(workspaceKey()) ?? [] }) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 5a26f1781635..0d705970416a 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout" import { useServerSDK } from "@/context/server-sdk" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" +import { useTabs } from "@/context/tabs" +import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" @@ -260,6 +262,7 @@ export function MessageTimeline(props: { const sdk = useSDK() const sync = useSync() const settings = useSettings() + const tabs = useTabs() const dialog = useDialog() const language = useLanguage() const { params, sessionKey } = useSessionKey() @@ -757,12 +760,18 @@ export function MessageTimeline(props: { const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { if (params.id !== sessionID) return + const href = (id: string) => + params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id) if (parentID) { - navigate(`/${params.dir}/session/${parentID}`) + navigate(href(parentID)) return } if (nextSessionID) { - navigate(`/${params.dir}/session/${nextSessionID}`) + navigate(href(nextSessionID)) + return + } + if (params.serverKey) { + tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory }) return } navigate(`/${params.dir}/session`) @@ -864,7 +873,9 @@ export function MessageTimeline(props: { const navigateParent = () => { const id = parentID() if (!id) return - navigate(`/${params.dir}/session/${id}`) + navigate( + params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id), + ) } function DialogDeleteSession(props: { sessionID: string }) { diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index e3f0c517df1f..f64c5c26aeb4 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" +import { useTabs } from "@/context/tabs" +import { requireServerKey } from "@/utils/session-route" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const settings = useSettings() const sync = useSync() const terminal = useTerminal() + const sessionTabs = useTabs() const layout = useLayout() const navigate = useNavigate() const { params, tabs, view } = useSessionLayout() @@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.new"), keybind: "mod+shift+s", slash: "new", - onSelect: () => navigate(`/${params.dir}/session`), + onSelect: () => { + if (params.serverKey) { + sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory }) + return + } + navigate(`/${params.dir}/session`) + }, }), sessionCommand({ id: "session.undo", diff --git a/packages/app/src/utils/session-route.test.ts b/packages/app/src/utils/session-route.test.ts new file mode 100644 index 000000000000..42ead3b5fd2a --- /dev/null +++ b/packages/app/src/utils/session-route.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { ServerConnection } from "@/context/server" +import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route" + +describe("session routes", () => { + test("builds and decodes a server-keyed session route", () => { + const server = ServerConnection.Key.make("https://example.com:4096") + const href = sessionHref(server, "session-1") + + expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1") + expect(requireServerKey(href.split("/")[2])).toBe(server) + }) + + test("rejects malformed server keys", () => { + expect(() => requireServerKey("not-base64")).toThrow("Invalid server route") + }) + + test("builds the legacy directory-keyed route", () => { + expect(legacySessionHref("/Users/example/project", "session-1")).toBe( + "/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1", + ) + }) + + test("resolves the root session", async () => { + const sessions: Record = { + child: { id: "child", parentID: "parent" }, + parent: { id: "parent", parentID: "root" }, + root: { id: "root" }, + } + + expect( + await rootSession(sessions.child, async (id) => { + const session = sessions[id] + if (!session) throw new Error(`Missing session: ${id}`) + return session + }), + ).toBe(sessions.root) + }) +}) diff --git a/packages/app/src/utils/session-route.ts b/packages/app/src/utils/session-route.ts new file mode 100644 index 000000000000..1bf921954e11 --- /dev/null +++ b/packages/app/src/utils/session-route.ts @@ -0,0 +1,25 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { ServerConnection } from "@/context/server" +import { decode64 } from "@/utils/base64" + +export function sessionHref(server: ServerConnection.Key, sessionID: string) { + return `/server/${base64Encode(server)}/session/${sessionID}` +} + +export function legacySessionHref(directory: string, sessionID: string) { + return `/${base64Encode(directory)}/session/${sessionID}` +} + +export function requireServerKey(segment: string | undefined) { + const key = decode64(segment) + if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route") + return ServerConnection.Key.make(key) +} + +type SessionParent = { id: string; parentID?: string } + +export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise) { + let current = session + while (current.parentID) current = await get(current.parentID) + return current +} From 09757c605a567e19d48dbba1674983ce47deef34 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 23 Jun 2026 06:20:50 +0000 Subject: [PATCH 028/554] chore: generate --- packages/app/src/context/tabs.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 1a4a47597879..774a21d9b799 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -183,8 +183,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ removeSessions: (input: SessionTabsRemovedDetail) => { const removed = store .filter( - (tab) => - tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId), + (tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId), ) .map(tabKey) void startTransition(() => { From 597f47b4864acf83bf4d50314a4847b65878318c Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:00:21 +0800 Subject: [PATCH 029/554] fix(app): improve mobile home layout (#32789) --- packages/app/src/pages/home.tsx | 64 ++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index e9f036993ece..cbb9c71ba475 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -329,7 +329,7 @@ export function NewHome() { return (
-
+
+ platform.openLink("https://opencode.ai/desktop-feedback")} + language={language} + />
) @@ -443,7 +449,7 @@ function HomeProjectColumn(props: { const dialog = useDialog() const controller = useServerManagementController({ navigateOnAdd: false }) return ( -