Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline cd5f49f6d9 feat(session): persist previous agent on switch
Project agent-switched messages with the prior selection, matching model-switched.
2026-08-10 17:52:19 -05:00
Kit Langton 9d34029cd9 fix(core): runtime-neutral legacy credential import (#41607) 2026-08-10 18:35:52 -04:00
15 changed files with 104 additions and 147 deletions
@@ -1,19 +0,0 @@
import type { ProviderPackage } from "../provider-package"
import type { OpenAIProviderOptionsInput } from "./openai-options"
import { CloudflareWorkersAI } from "./cloudflare"
export interface Settings extends ProviderPackage.Settings {
readonly accountId?: string
readonly apiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
CloudflareWorkersAI.configure({
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : { accountId: settings.accountId ?? "" }),
apiKey: settings.apiKey,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
}).model(modelID)
@@ -1,16 +0,0 @@
import { describe, expect, test } from "bun:test"
import { model } from "../../src/providers/cloudflare-workers-ai"
describe("Cloudflare Workers AI provider package", () => {
test("derives the endpoint from accountId", () => {
const resolved = model("@cf/model", { accountId: "account", apiKey: "secret" })
expect(resolved.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/account/ai/v1")
})
test("preserves an explicit endpoint", () => {
const resolved = model("@cf/model", { baseURL: "https://proxy.example/v1", apiKey: "secret" })
expect(resolved.route.endpoint.baseURL).toBe("https://proxy.example/v1")
})
})
@@ -41,6 +41,7 @@ export type SessionMessageAgentSelected = {
time: { created: number }
type: "agent-switched"
agent: string
previous?: string
}
export type PromptBase64 = string
@@ -2535,6 +2536,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -2786,6 +2788,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -3037,6 +3040,7 @@ export type SessionImportInput = {
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
readonly previous?: string
}
| {
readonly id: string
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises"
import path from "node:path"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
@@ -41,9 +42,9 @@ export default migration
export function importLegacyCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], filepath: string) {
return Effect.gen(function* () {
const file = Bun.file(filepath)
if (!(yield* Effect.promise(() => file.exists()))) return
const input = Option.getOrUndefined(decodeJson(yield* Effect.promise(() => file.text())))
const content = yield* Effect.promise(() => readFile(filepath, "utf8").catch(() => undefined))
if (content === undefined) return
const input = Option.getOrUndefined(decodeJson(content))
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return yield* Effect.fail(new Error("Legacy credential file must contain an object"))
}
@@ -8,14 +8,13 @@ import { iife } from "../../util/iife"
import { configuredSettings } from "./configured"
const providerID = Provider.ID.make("cloudflare-workers-ai")
const nativePackage = "@opencode-ai/ai/providers/cloudflare-workers-ai"
export const CloudflareWorkersAIPlugin = define({
id: "opencode.provider.cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(providerID)
const form = iife(() => {
if (hasExplicitEndpoint(configured?.baseURL) || resolveAccountId(configured ?? {})) return
if (typeof configured?.baseURL === "string" || resolveAccountId(configured ?? {})) return
return Form.Fields.make([
{
type: "string",
@@ -39,24 +38,12 @@ export const CloudflareWorkersAIPlugin = define({
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
const compatible =
Provider.isAISDK(item.provider.package) &&
Provider.packageName(item.provider.package) === "@ai-sdk/openai-compatible"
evt.provider.update(item.provider.id, (provider) => {
if (!compatible) return
provider.package = nativePackage
provider.settings = nativeSettings(provider.settings)
if (!Provider.isAISDK(provider.package)) return
if (typeof provider.settings?.baseURL === "string") return
const accountId = resolveAccountId(provider.settings ?? {})
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
})
for (const model of item.models.values()) {
evt.model.update(item.provider.id, model.id, (draft) => {
if (!draft.package && !compatible) return
if (draft.package === nativePackage) return
if (draft.package && !Provider.isAISDK(draft.package)) return
if (draft.package && Provider.packageName(draft.package) !== "@ai-sdk/openai-compatible") return
if (draft.package) draft.package = nativePackage
draft.settings = nativeSettings(draft.settings)
})
}
})
yield* ctx.aisdk.hook(
"sdk",
@@ -96,17 +83,6 @@ function workersEndpoint(accountId: string) {
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`
}
function hasExplicitEndpoint(baseURL: unknown) {
return typeof baseURL === "string" && !baseURL.includes("${CLOUDFLARE_ACCOUNT_ID}")
}
function nativeSettings(settings: Record<string, unknown> | undefined) {
const result = { ...settings }
if (process.env.CLOUDFLARE_ACCOUNT_ID) result.baseURL = workersEndpoint(process.env.CLOUDFLARE_ACCOUNT_ID)
else if (!hasExplicitEndpoint(result.baseURL)) delete result.baseURL
return result
}
function hasWorkersEndpoint(model: {
readonly package?: string
readonly settings?: Readonly<Record<string, unknown>>
@@ -117,7 +93,7 @@ function hasWorkersEndpoint(model: {
function sdkOptions(options: Record<string, any>, app: App.Info) {
return {
...options,
baseURL: expandAccountId(options.baseURL, resolveAccountId(options)),
baseURL: expandAccountId(options.baseURL),
apiKey: process.env.CLOUDFLARE_API_KEY ?? options.apiKey,
headers: {
"User-Agent": `${App.useragent(app)} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
@@ -127,9 +103,9 @@ function sdkOptions(options: Record<string, any>, app: App.Info) {
}
}
function expandAccountId(baseURL: unknown, accountId: string | undefined) {
function expandAccountId(baseURL: unknown) {
if (typeof baseURL !== "string") return baseURL
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", accountId ?? "${CLOUDFLARE_ACCOUNT_ID}")
return baseURL.replaceAll("${CLOUDFLARE_ACCOUNT_ID}", process.env.CLOUDFLARE_ACCOUNT_ID ?? "${CLOUDFLARE_ACCOUNT_ID}")
}
function stringOption(options: Record<string, unknown>, key: string) {
+14 -9
View File
@@ -4,6 +4,7 @@ import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export interface Adapter {
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
@@ -59,15 +60,19 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.created": () => Effect.void,
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
time: { created: event.created },
}),
)
return Effect.gen(function* () {
const previous = yield* adapter.getAgent()
yield* adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous,
time: { created: event.created },
}),
)
})
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
+21 -6
View File
@@ -5,6 +5,7 @@ import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database"
import { Bus } from "../bus"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "../agent"
import { Model } from "../model"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
@@ -230,6 +231,17 @@ function run(db: DatabaseService, event: MessageEvent) {
}
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = {
getAgent() {
return db
.select({ agent: SessionTable.agent })
.from(SessionTable)
.where(eq(SessionTable.id, event.data.sessionID))
.get()
.pipe(
Effect.orDie,
Effect.map((row) => (row?.agent ? Agent.ID.make(row.agent) : undefined)),
)
},
getModel() {
return db
.select({ model: SessionTable.model })
@@ -398,12 +410,15 @@ const layer = Layer.effectDiscard(
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.AgentSelected, (event) =>
db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.andThen(run(db, event))),
Effect.gen(function* () {
yield* run(db, event)
yield* db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.ModelSelected, (event) =>
Effect.gen(function* () {
@@ -164,6 +164,20 @@ describe("DatabaseMigration", () => {
expect(await Bun.file(source).text()).toBe(content)
})
test("skips legacy credential import when the source file is absent", async () => {
await using tmp = await tmpdir()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
yield* db.transaction((tx) => importLegacyCredentials(tx, path.join(tmp.path, "missing-auth.json")))
expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
}),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
@@ -2,8 +2,6 @@ import { AISDK } from "@opencode-ai/core/aisdk"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Model } from "@opencode-ai/core/model"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -18,6 +16,7 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const host = yield* PluginHost.make(plugin)
yield* CloudflareWorkersAIPlugin.effect(host)
})
@@ -104,13 +103,15 @@ describe("CloudflareWorkersAIPlugin", () => {
),
)
it.effect("maps the environment account ID to the native endpoint", () =>
it.effect("maps account ID to endpoint URL and creates an OpenAI-compatible SDK", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.package = Provider.aisdk("test-provider")
}),
)
yield* addPlugin()
@@ -118,10 +119,21 @@ describe("CloudflareWorkersAIPlugin", () => {
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-workers-ai")))?.methods,
).toContainEqual({ type: "key", label: "API key" })
const provider = required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))
const sdk = yield* aisdk.runSDK({
model: Model.Info.make({
...Model.Info.default(Provider.ID.make("cloudflare-workers-ai"), Model.ID.make("@cf/model")),
modelID: Model.ID.make("@cf/model"),
package: provider.package,
settings: provider.settings,
}),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
})
expect(provider).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1" },
})
expect(sdk.sdk).toBeDefined()
}),
),
)
@@ -181,72 +193,19 @@ describe("CloudflareWorkersAIPlugin", () => {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) =>
catalog.provider.update(Provider.ID.make("cloudflare-workers-ai"), (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.package = Provider.aisdk("test-provider")
provider.settings = { ...provider.settings, accountId: "configured-acct" }
}),
)
yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.make("cloudflare-workers-ai")))).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
settings: {
accountId: "configured-acct",
baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
},
package: "aisdk:test-provider",
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
})
}),
),
)
it.effect("passes the connected account ID to the native provider at runtime", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined }, () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = Provider.ID.make("cloudflare-workers-ai")
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = {
accountId: "configured-acct",
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
}
})
draft.model.update(providerID, Model.ID.make("@cf/model"), (model) => {
model.settings = {
accountId: "model-acct",
baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
}
})
})
yield* addPlugin()
const selected = required(yield* catalog.model.get(providerID, Model.ID.make("@cf/model")))
const { model } = yield* Effect.promise(() => import("@opencode-ai/ai/providers/cloudflare-workers-ai"))
const resolved = yield* ModelResolver.fromCatalogModel(
selected,
Credential.Key.make({
type: "key",
key: "secret",
configuration: { accountId: "connected-acct" },
}),
{ loadPackage: () => Effect.succeed({ model }) },
)
expect(required(yield* catalog.provider.get(providerID))).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
settings: { accountId: "configured-acct" },
})
expect(selected).toMatchObject({
package: "@opencode-ai/ai/providers/cloudflare-workers-ai",
settings: { accountId: "model-acct" },
})
expect(selected.settings).not.toHaveProperty("baseURL")
expect(resolved.route.endpoint.baseURL).toBe(
"https://api.cloudflare.com/client/v4/accounts/connected-acct/ai/v1",
)
}),
),
)
it.effect("uses env API key over auth or configured API key and keeps the Cloudflare User-Agent", () =>
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "env-key" }, () =>
Effect.gen(function* () {
+4 -1
View File
@@ -648,7 +648,7 @@ describe("Session.create", () => {
it.effect("switches the selected agent through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const created = yield* session.create({ location })
const created = yield* session.create({ location, agent: Agent.ID.make("build") })
yield* session.switchAgent({ sessionID: created.id, agent: Agent.ID.make("plan") })
@@ -656,6 +656,9 @@ describe("Session.create", () => {
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "agent-switched", agent: "plan", previous: "build" },
])
}),
)
@@ -358,6 +358,7 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
agent: "plan",
model: previousModel,
})
.run()
@@ -459,6 +460,10 @@ describe("SessionProjector", () => {
text: "synthetic context",
metadata: { source: "projector-test" },
})
expect(messages.find((message) => message.type === "agent-switched")).toMatchObject({
agent: build,
previous: "plan",
})
expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel })
expect(messages.find((message) => message.type === "shell")).toMatchObject({
command: "pwd",
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
+1
View File
@@ -42,6 +42,7 @@ export const AgentSelected = Schema.Struct({
...Base,
type: Schema.tag("agent-switched"),
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
}).annotate({ identifier: "Session.Message.AgentSelected" })
export interface ModelSelected extends Schema.Schema.Type<typeof ModelSelected> {}
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],
+3
View File
@@ -12733,6 +12733,9 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["id", "time", "type", "agent"],