Compare commits

..

1 Commits

Author SHA1 Message Date
Filip 20a953b29c fix(cli): honor agent model across run targets 2026-08-14 13:49:53 +00:00
32 changed files with 1122 additions and 2092 deletions
-3
View File
@@ -188,15 +188,12 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.101",
"solid-js": ">=1.9.0",
},
"optionalPeers": [
"effect",
"solid-js",
],
},
"packages/codemode": {
+1 -5
View File
@@ -11,9 +11,6 @@ import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@/types"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { sessionHref } from "@/utils/session-route"
import { ServerConnection } from "@/context/servers"
interface ForkableMessage {
id: string
@@ -30,7 +27,6 @@ export const DialogFork: Component = () => {
const navigate = useNavigate()
const sync = useSync()
const sdk = useSDK()
const serverSDK = useServerSDK()
const prompt = usePrompt()
const dialog = useDialog()
const language = useLanguage()
@@ -77,7 +73,7 @@ export const DialogFork: Component = () => {
.then((forked) => {
dialog.close()
prompt.set(restored, undefined, { dir, id: forked.id })
navigate(sessionHref(ServerConnection.key(serverSDK.server), forked.id))
navigate(`/${dir}/session/${forked.id}`)
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show, createEffect } from "solid-js"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
@@ -14,7 +14,6 @@ import { SettingsServerScope } from "../settings-server-picker"
import { InlineServerSelect } from "./parts/server-select"
import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css"
import { useData } from "@/context/server"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
@@ -36,7 +35,6 @@ export const SettingsProvidersV2: Component<{
directory: string | undefined
onBack?: () => void
}> = (props) => {
const data = useData()
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
-8
View File
@@ -7,7 +7,6 @@ import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { createServerData } from "@opencode-ai/client/solid"
import type { ServerScope } from "@/utils/server-scope"
import { createServerPermissionState } from "./permission"
import { createServerNotificationState } from "./notification"
@@ -103,12 +102,6 @@ function createServerController(
const sync = createServerSyncContext(sdk)
const permission = createServerPermissionState({ sdk, sync })
const notification = createServerNotificationState({ sdk, sync, key: connKey })
const data = createServerData({
api: () => sdk.api,
event: sdk.event,
connection: sdk.connection,
directory: "",
})
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -141,7 +134,6 @@ function createServerController(
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
return {
data,
sdk,
sync,
isLocal,
+1 -1
View File
@@ -278,7 +278,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
})
}
const unsub = input.sdk.eventByDir.listen((e) => {
const unsub = input.sdk.event.listen((e) => {
const event = e.details
if (
event.type !== "session.execution.succeeded" &&
+2 -2
View File
@@ -52,7 +52,7 @@ function hasPermissionPromptRules(permission: unknown) {
return Object.values(config).some(isNonAllowRule)
}
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
const [store, setStore, _, ready] = persisted(
@@ -197,7 +197,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
void respondPending(event.properties, e.name)
}
const unsubscribe = input.sdk.eventByDir.listen((event) => {
const unsubscribe = input.sdk.event.listen((event) => {
if (ready()) {
handlePermission(event)
return
+67 -1
View File
@@ -1,6 +1,18 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent } from "./server-sdk"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
let starts = 0
const start = () => starts++
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
expect(starts).toBe(1)
})
})
describe("adaptServerEvent", () => {
test("preserves current permission requests", () => {
@@ -31,3 +43,57 @@ describe("adaptServerEvent", () => {
})
})
})
describe("current event buffering", () => {
const delta = (id: string, value: string, ordinal = 0) => ({
directory: "/repo",
payload: adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
} as OpenCodeEvent),
})
test("merges adjacent text deltas for the same message and ordinal", () => {
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
})
test("coalesces current tool input deltas by tool ID", () => {
const current = (eventID: string, id: string, delta: string) =>
adaptServerEvent({
id: eventID,
created: 1,
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
])
expect(result).toHaveLength(2)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
test("preserves current event order when enqueuing", () => {
const events: Parameters<typeof enqueueServerEvent>[0] = []
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
})
})
+283 -32
View File
@@ -1,8 +1,9 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
import type { Event } from "@/types"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, onCleanup } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { type Accessor, batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { createApiForServer, type ServerApi } from "@/utils/server"
import { usePlatform } from "./platform"
import { ServerConnection } from "./servers"
@@ -11,15 +12,85 @@ import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
>
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
queue.push(event)
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
events.forEach((event) => {
const current = currentDelta(event.payload.current)
if (current) {
const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current)
if (
previous &&
prior &&
previous.directory === event.directory &&
currentDeltaKey(prior) === currentDeltaKey(current)
) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
const data =
current.type === "session.compaction.delta"
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment }
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
return
}
output.push(event)
return
}
output.push(event)
})
return output
}
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if (
event?.type === "session.text.delta" ||
event?.type === "session.reasoning.delta" ||
event?.type === "session.tool.input.delta" ||
event?.type === "session.compaction.delta"
)
return event
}
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
function currentDeltaFragment(event: CurrentDelta) {
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
}
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
start()
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
export type ServerConnectionStatus = ClientConnectionStatus
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
@@ -30,51 +101,231 @@ type ServerSDKBase = {
attempt: Accessor<number>
error: Accessor<string | undefined>
}
eventByDir: {
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
}
event: {
on: CurrentEventEmitter["on"]
listen: CurrentEventEmitter["listen"]
}
}
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
const emitter = createGlobalEmitter<CurrentEventMap>()
const abort = new AbortController()
const connection = createClientConnection(api, {
flushInterval: 16,
pageLifecycle: true,
onEvent(event) {
emitter.emit(event.type, event)
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
},
log: {
info(message, data) {
if (message !== "event stream disconnected") return
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
},
},
const eventFetch = (() => {
if (!platform.fetch || !server) return
try {
const url = new URL(server.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const emitter = createGlobalEmitter<{
[key: string]: ServerEvent
}>()
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const CONNECT_TIMEOUT_MS = 2_000
const RECONNECT_DELAY_MS = 1_000
let queue: Queued[] = []
let buffer: Queued[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
function flush() {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
queue = buffer
buffer = events
queue.length = 0
last = Date.now()
const output = coalesceServerEvents(events)
batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload))
})
buffer.length = 0
}
function schedule() {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
function publish(event: OpenCodeEvent) {
const directory = event.location?.directory ?? "global"
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
}
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
const [connection, setConnection] = createStore<{
status: ServerConnectionStatus
attempt: number
error?: string
}>({ status: "connecting", attempt: 0 })
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
let connectedAt: number | undefined
// Bound the initial handshake and tie this request to the stream lifetime.
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
signal.addEventListener("abort", cancel, { once: true })
try {
// Open the event stream and validate its initial handshake.
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done) {
const error =
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
return { error, connectedAt }
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
// Publish the connected state before forwarding live events.
clearTimeout(timeout)
publish(first.value)
connectedAt = Date.now()
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
let yielded = Date.now()
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
publish(event.value)
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0, signal)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
async function runStream(active: number) {
let retries = 0
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
while (!abort.signal.aborted && started && generation === active) {
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
const controller = new AbortController()
attempt = controller
const onAbort = () => controller.abort()
abort.signal.addEventListener("abort", onAbort)
const result = await connect(controller.signal)
abort.signal.removeEventListener("abort", onAbort)
if (abort.signal.aborted || !started || generation !== active) {
if (attempt === controller) attempt = undefined
return
}
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
retries += 1
const message =
result.error === undefined
? undefined
: result.error instanceof Error
? result.error.message
: String(result.error)
console.info("[global-sdk] event stream disconnected", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
attempt: retries,
error: message,
})
setConnection({ status: "reconnecting", attempt: retries, error: message })
await wait(RECONNECT_DELAY_MS, controller.signal)
if (attempt === controller) attempt = undefined
}
}
function start() {
if (started) return run
started = true
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
await runStream(active)
})().finally(() => {
if (run !== current) return
run = undefined
flush()
})
run = current
return run
}
function stop() {
started = false
generation++
attempt?.abort()
}
onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
void start()
})
onCleanup(() => {
dirEmitter.clear()
stop()
abort.abort()
if (timer) clearTimeout(timer)
timer = undefined
queue = []
buffer = []
emitter.clear()
})
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
return {
server,
scope,
url: server.http.url,
api,
connection,
eventByDir: {
on: dirEmitter.on.bind(dirEmitter),
listen: dirEmitter.listen.bind(dirEmitter),
connection: {
status: () => connection.status,
attempt: () => connection.attempt,
error: () => connection.error,
},
event: {
on: emitter.on.bind(emitter),
@@ -114,7 +365,7 @@ export type DirectorySDK = {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.eventByDir.on(directory, (event) => {
const unsub = serverSDK.event.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
+2 -2
View File
@@ -54,7 +54,7 @@ import { createServerSession, type ServerSession } from "./server-session"
import { createCatalogSync } from "./server-sync/catalog"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "./platform"
import { useData, useServer } from "./server"
import { useServer } from "./server"
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
const type = event.current?.type ?? event.type
@@ -568,7 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return event
}
const unsub = serverSDK.eventByDir.listen((e) => {
const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
-5
View File
@@ -22,8 +22,3 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
},
})
export const useData = () => {
const server = useServer()
return server.ctx.data
}
+4 -7
View File
@@ -101,7 +101,7 @@ import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { requireServerKey, sessionHref } from "@/utils/session-route"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionLineage } from "./session/session-lineage"
@@ -2128,12 +2128,9 @@ export default function Page() {
const id = controller.data.parentID()
if (!id) return
navigate(
sessionHref(
controller.identity.params.serverKey
? requireServerKey(controller.identity.params.serverKey)
: ServerConnection.key(serverSDK.server),
id,
),
controller.identity.params.serverKey
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
: legacySessionHref(sdk().directory, id),
)
},
setPromptRef: (el) => {
@@ -15,15 +15,13 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
import type { SessionController } from "@/pages/session/session-controller"
import { requireServerKey, sessionHref } from "@/utils/session-route"
import { useServerSDK } from "@/context/server-sdk"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { sessionTitle } from "@/utils/session-title"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/context/server"
import { ServerConnection } from "@/context/servers"
const emptyMessages: Message[] = []
const emptyParts: Part[] = []
@@ -48,7 +46,6 @@ export function createTimelineController(input: {
}) {
const navigate = useNavigate()
const sdk = useSDK()
const serverSDK = useServerSDK()
const sync = useSync()
const server = useServer()
const settings = useSettings()
@@ -149,22 +146,19 @@ export function createTimelineController(input: {
if (!id || pending.unshare || !shareEnabled()) return
}
const href = (id: string) =>
sessionHref(
input.session.identity.params.serverKey
? requireServerKey(input.session.identity.params.serverKey)
: ServerConnection.key(serverSDK.server),
id,
)
input.session.identity.params.serverKey
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id)
: legacySessionHref(sdk().directory, id)
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
if (input.session.identity.params.id !== id) return
if (parent) return navigate(href(parent))
if (next) return navigate(href(next))
return tabs.newDraft({
server: input.session.identity.params.serverKey
? requireServerKey(input.session.identity.params.serverKey)
: ServerConnection.key(serverSDK.server),
directory: sdk().directory,
})
if (input.session.identity.params.serverKey)
return tabs.newDraft({
server: requireServerKey(input.session.identity.params.serverKey),
directory: sdk().directory,
})
navigate(`/${input.session.identity.params.dir}/session`)
}
const exportSession = async (id: string) => {
try {
+33 -6
View File
@@ -1,5 +1,11 @@
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import {
OpenCode,
type ModelRef,
type OpenCodeClient,
type SessionInfo,
type SessionMessageAssistantTool,
} from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { open } from "node:fs/promises"
import path from "node:path"
@@ -116,8 +122,13 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
return undefined
})
if (!target) return
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
const variant = target.model?.variant
await applyRunSelection({
client,
sessionID: target.session.id,
agent: input.agent,
model: target.model,
explicit: explicit !== undefined || options.variant !== undefined,
})
if (!target.resume && input.title !== undefined) {
await client.session.rename({
sessionID: target.session.id,
@@ -131,9 +142,6 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
location: target.location,
message: prepared.message,
files: prepared.files,
agent: target.agent,
model,
variant,
thinking: input.thinking ?? false,
format: input.format,
auto: input.auto ?? false,
@@ -144,6 +152,25 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
}).catch((error) => reportRunError(input, errorMessage(error), target.session.id))
}
/** @internal Exported for CLI boundary tests. */
export function applyRunSelection(input: {
client: OpenCodeClient
sessionID: SessionInfo["id"]
agent?: string
model?: ModelRef
explicit: boolean
}) {
if (input.agent)
return input.client.session.select({
sessionID: input.sessionID,
agent: input.agent,
model: input.explicit && input.model ? { type: "explicit", model: input.model } : { type: "configured" },
})
if (input.explicit && input.model)
return input.client.session.switchModel({ sessionID: input.sessionID, model: input.model })
return Promise.resolve()
}
export function mergeInput(message: string | undefined, piped: string | undefined) {
if (!message) return piped || undefined
if (!piped) return message
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client/promise"
import { applyRunSelection } from "../../src/run/run"
afterEach(() => mock.restore())
describe("run selection", () => {
test("resolves the configured model when an agent is explicit", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const select = spyOn(client.session, "select").mockResolvedValue(undefined)
await applyRunSelection({ client, sessionID: "ses_test", agent: "modelprobe", explicit: false })
expect(select).toHaveBeenCalledWith({
sessionID: "ses_test",
agent: "modelprobe",
model: { type: "configured" },
})
})
test("selects an explicit agent and model atomically", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const select = spyOn(client.session, "select").mockResolvedValue(undefined)
await applyRunSelection({
client,
sessionID: "ses_test",
agent: "modelprobe",
model: { providerID: "opencode", id: "deepseek-v4-flash-free" },
explicit: true,
})
expect(select).toHaveBeenCalledWith({
sessionID: "ses_test",
agent: "modelprobe",
model: {
type: "explicit",
model: { providerID: "opencode", id: "deepseek-v4-flash-free" },
},
})
})
test("switches only the model when no agent is explicit", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
const switchModel = spyOn(client.session, "switchModel").mockResolvedValue(undefined)
await applyRunSelection({
client,
sessionID: "ses_test",
model: { providerID: "opencode", id: "deepseek-v4-flash-free" },
explicit: true,
})
expect(switchModel).toHaveBeenCalledWith({
sessionID: "ses_test",
model: { providerID: "opencode", id: "deepseek-v4-flash-free" },
})
})
})
+2 -8
View File
@@ -20,7 +20,6 @@
"./promise": "./src/promise/index.ts",
"./promise/api": "./src/promise/api.ts",
"./service": "./src/promise/service.ts",
"./solid": "./src/solid/index.ts",
"./effect": "./src/effect/index.ts",
"./effect/api": "./src/effect/api.ts",
"./effect/service": "./src/effect/service.ts"
@@ -37,15 +36,11 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.101",
"solid-js": ">=1.9.0"
"effect": "4.0.0-beta.101"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
},
"solid-js": {
"optional": true
}
},
"devDependencies": {
@@ -54,7 +49,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:"
"effect": "catalog:"
}
}
+86 -74
View File
@@ -154,28 +154,39 @@ export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly bounda
export type Endpoint5_7Output = Session.Info
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_8Input = {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model:
| { readonly type: "preserve" }
| { readonly type: "configured" }
| { readonly type: "explicit"; readonly model: Model.Ref }
}
export type Endpoint5_8Output = void
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type SessionSelectOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type Endpoint5_9Output = void
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
export type Endpoint5_10Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
export type Endpoint5_11Input = {
export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string }
export type Endpoint5_11Output = void
export type SessionRenameOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_12Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
export type Endpoint5_12Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_12Input = {
export type Endpoint5_13Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -186,10 +197,10 @@ export type Endpoint5_12Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Output = SessionInbox.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_13Input = {
export type Endpoint5_14Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly command: string
@@ -202,19 +213,19 @@ export type Endpoint5_13Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Output = SessionInbox.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_14Input = {
export type Endpoint5_15Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly skill: Skill.ID
readonly resume?: boolean | undefined
}
export type Endpoint5_14Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
export type Endpoint5_15Output = void
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_15Input = {
export type Endpoint5_16Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly text: string
@@ -223,97 +234,97 @@ export type Endpoint5_15Input = {
readonly delivery?: SessionInbox.Delivery | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Output = SessionInbox.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_16Input = {
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly id?: Event.ID | undefined
readonly command: string
}
export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_17Input = {
export type Endpoint5_18Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_17Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Output = SessionInbox.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
export type Endpoint5_18Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
export type Endpoint5_19Output = void
export type SessionWaitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_19Input = {
export type Endpoint5_20Input = {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
readonly files?: boolean | undefined
}
export type Endpoint5_19Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
export type Endpoint5_20Output = void
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_20Output = Session.Revert
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
export type Endpoint5_21Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_22Output = void
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
export type Endpoint5_24Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_27Output = void
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
export type Endpoint5_28Output = ReadonlyArray<InstructionEntry.Info>
export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint5_27Input,
) => Effect.Effect<Endpoint5_27Output, E>
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_28Input = {
export type Endpoint5_29Input = {
readonly sessionID: Session.ID
readonly key: InstructionEntry.Key
readonly value: Schema.Json
}
export type Endpoint5_28Output = void
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_28Input,
) => Effect.Effect<Endpoint5_28Output, E>
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_29Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint5_29Input,
) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_30Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
export type Endpoint5_30Output = void
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint5_30Input,
) => Effect.Effect<Endpoint5_30Output, E>
export type Endpoint5_31Input = {
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string }
export type Endpoint5_31Output = { readonly text: string }
export type SessionGenerateOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
export type Endpoint5_32Input = {
readonly sessionID: Session.ID
readonly after?: Event.Seq | undefined
readonly follow?: boolean | undefined
}
export type Endpoint5_31Output =
export type Endpoint5_32Output =
| (
| {
readonly id: Event.ID
@@ -902,19 +913,19 @@ export type Endpoint5_31Output =
}
)
| EventLog.Synced
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_32Output = void
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
export type Endpoint5_33Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_34Input = { readonly sessionID: Session.ID }
export type Endpoint5_34Output = void
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
export type Endpoint5_35Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
@@ -925,6 +936,7 @@ export interface SessionApi<E = never> {
readonly get: SessionGetOperation<E>
readonly remove: SessionRemoveOperation<E>
readonly fork: SessionForkOperation<E>
readonly select: SessionSelectOperation<E>
readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E>
readonly rename: SessionRenameOperation<E>
+80 -69
View File
@@ -86,6 +86,8 @@ import type {
Endpoint5_33Output,
Endpoint5_34Input,
Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -376,35 +378,43 @@ const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Inp
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
preserveEffect<Endpoint5_8Output>()(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
),
raw["session.select"]({
params: { sessionID: input["sessionID"] },
payload: { agent: input["agent"], model: input["model"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
preserveEffect<Endpoint5_9Output>()(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
preserveEffect<Endpoint5_10Output>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
preserveEffect<Endpoint5_11Output>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
preserveEffect<Endpoint5_12Output>()(
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -423,8 +433,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
),
)
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
preserveEffect<Endpoint5_13Output>()(
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
raw["session.command"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -445,16 +455,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
),
)
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
preserveEffect<Endpoint5_14Output>()(
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
preserveEffect<Endpoint5_15Output>()(
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
raw["session.synthetic"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -471,16 +481,16 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
),
)
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
preserveEffect<Endpoint5_16Output>()(
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.shell"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
raw["session.compact"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], delivery: input["delivery"] },
@@ -490,13 +500,13 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
),
)
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
preserveEffect<Endpoint5_18Output>()(
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
preserveEffect<Endpoint5_19Output>()(
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -506,27 +516,19 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
),
)
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
preserveEffect<Endpoint5_20Output>()(
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
preserveEffect<Endpoint5_21Output>()(
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
preserveEffect<Endpoint5_22Output>()(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -534,58 +536,66 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
preserveEffect<Endpoint5_27Output>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
preserveEffect<Endpoint5_28Output>()(
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
preserveEffect<Endpoint5_29Output>()(
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveEffect<Endpoint5_31Output>()(
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
preserveStream<Endpoint5_31Output>()(
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveStream<Endpoint5_32Output>()(
Stream.unwrap(
raw["session.log"]({
params: { sessionID: input["sessionID"] },
@@ -597,21 +607,21 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
),
)
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
preserveEffect<Endpoint5_32Output>()(
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
raw["session.interrupt"]({
params: { sessionID: input["sessionID"] },
query: { continue: input["continue"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
preserveEffect<Endpoint5_33Output>()(
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
preserveEffect<Endpoint5_34Output>()(
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -627,26 +637,27 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
get: Endpoint5_5(raw),
remove: Endpoint5_6(raw),
fork: Endpoint5_7(raw),
switchAgent: Endpoint5_8(raw),
switchModel: Endpoint5_9(raw),
rename: Endpoint5_10(raw),
move: Endpoint5_11(raw),
prompt: Endpoint5_12(raw),
command: Endpoint5_13(raw),
skill: Endpoint5_14(raw),
synthetic: Endpoint5_15(raw),
shell: Endpoint5_16(raw),
compact: Endpoint5_17(raw),
wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw),
inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
generate: Endpoint5_30(raw),
log: Endpoint5_31(raw),
interrupt: Endpoint5_32(raw),
background: Endpoint5_33(raw),
message: Endpoint5_34(raw),
select: Endpoint5_8(raw),
switchAgent: Endpoint5_9(raw),
switchModel: Endpoint5_10(raw),
rename: Endpoint5_11(raw),
move: Endpoint5_12(raw),
prompt: Endpoint5_13(raw),
command: Endpoint5_14(raw),
skill: Endpoint5_15(raw),
synthetic: Endpoint5_16(raw),
shell: Endpoint5_17(raw),
compact: Endpoint5_18(raw),
wait: Endpoint5_19(raw),
revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) },
context: Endpoint5_23(raw),
inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) },
instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } },
generate: Endpoint5_31(raw),
log: Endpoint5_32(raw),
interrupt: Endpoint5_33(raw),
background: Endpoint5_34(raw),
message: Endpoint5_35(raw),
})
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -26,6 +26,8 @@ import type {
SessionRemoveOutput,
SessionForkInput,
SessionForkOutput,
SessionSelectInput,
SessionSelectOutput,
SessionSwitchAgentInput,
SessionSwitchAgentOutput,
SessionSwitchModelInput,
@@ -557,6 +559,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
select: (input: SessionSelectInput, requestOptions?: RequestOptions) =>
request<SessionSelectOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/selection`,
body: { agent: input["agent"], model: input["model"] },
successStatus: 204,
declaredStatuses: [404, 503, 400, 401],
empty: true,
},
requestOptions,
),
switchAgent: (input: SessionSwitchAgentInput, requestOptions?: RequestOptions) =>
request<SessionSwitchAgentOutput>(
{
+34 -8
View File
@@ -2194,6 +2194,14 @@ export type MessageNotFoundError = {
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type CommandNotFoundError = {
readonly _tag: "CommandNotFoundError"
readonly command: string
@@ -2218,14 +2226,6 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
@@ -3343,6 +3343,32 @@ export type SessionForkInput = {
export type SessionForkOutput = { data: SessionInfo }["data"]
export type SessionSelectInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly agent: {
readonly agent: string
readonly model:
| { readonly type: "preserve" }
| { readonly type: "configured" }
| {
readonly type: "explicit"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
}["agent"]
readonly model: {
readonly agent: string
readonly model:
| { readonly type: "preserve" }
| { readonly type: "configured" }
| {
readonly type: "explicit"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
}["model"]
}
export type SessionSelectOutput = void
export type SessionSwitchAgentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly agent: { readonly agent: string }["agent"]
-269
View File
@@ -1,269 +0,0 @@
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
export type ClientConnectionEvent = {
readonly type: "client.connection"
readonly created: number
readonly data: {
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
readonly attempt: number
readonly error?: string
}
}
export type ClientConnectionOptions = {
readonly reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
readonly onEvent: (event: OpenCodeEvent) => void
readonly flushInterval?: number
readonly pageLifecycle?: boolean
readonly log?: {
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
}
}
const connectTimeout = 2_000
const reconnectDelay = 1_000
const connectionHistoryLimit = 50
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
>
export function coalesceClientEvents(events: OpenCodeEvent[]) {
return events.reduce<OpenCodeEvent[]>((output, event) => {
const current = currentDelta(event)
const previous = output[output.length - 1]
const prior = currentDelta(previous)
if (
!current ||
!prior ||
previous?.location?.directory !== event.location?.directory ||
currentDeltaKey(prior) !== currentDeltaKey(current)
) {
output.push(event)
return output
}
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
output[output.length - 1] = {
...current,
data:
current.type === "session.compaction.delta"
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment },
} as CurrentDelta
return output
}, [])
}
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if (
event?.type === "session.text.delta" ||
event?.type === "session.reasoning.delta" ||
event?.type === "session.tool.input.delta" ||
event?.type === "session.compaction.delta"
)
return event
}
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
function currentDeltaFragment(event: CurrentDelta) {
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
}
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
const abort = new AbortController()
const history: ClientConnectionEvent[] = []
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
error?: string
}>({ status: "connecting", attempt: 0 })
let api = initialApi
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
let stream: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
if (history.length > connectionHistoryLimit) history.shift()
}
function publish(event: OpenCodeEvent) {
pending.push(event)
if (flushTimer) return
flushTimer = setTimeout(() => {
flushTimer = undefined
const events = pending
pending = []
batch(() => coalesceClientEvents(events).forEach(options.onEvent))
}, options.flushInterval ?? 10)
}
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
try {
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
options.log?.info?.("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done)
return {
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
connectedAt,
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
options.log?.info?.("event stream connected")
publish(first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value)
options.log?.debug?.("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
publish(event.value)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
async function runStream(active: number) {
let attempt = 0
while (!abort.signal.aborted && started && generation === active) {
setConnection({ status: attempt === 0 ? "connecting" : "reconnecting", attempt, error: undefined })
const controller = new AbortController()
stream = controller
const cancel = () => controller.abort(abort.signal.reason)
abort.signal.addEventListener("abort", cancel)
const result = await connect(controller.signal, attempt)
abort.signal.removeEventListener("abort", cancel)
if (abort.signal.aborted || !started || generation !== active) return
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= reconnectDelay) attempt = 0
attempt += 1
const message = errorMessage(result.error)
record("disconnected", attempt, message)
options.log?.info?.("event stream disconnected", { attempt, error: message })
setConnection({ status: "reconnecting", attempt, error: message })
if (options.reconnect) {
const next = await options.reconnect(controller.signal).catch((error) => {
if (!controller.signal.aborted)
options.log?.info?.("server resolution failed", { attempt, error: errorMessage(error) })
})
if (abort.signal.aborted || controller.signal.aborted || !started || generation !== active) return
if (next) {
api = next
if (attempt === 1) continue
}
}
await wait(reconnectDelay, controller.signal)
}
}
function start() {
if (started) return run
started = true
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
await runStream(active)
})().finally(() => {
if (run !== current) return
run = undefined
})
run = current
return run
}
function stop() {
started = false
generation += 1
stream?.abort()
}
onMount(() => {
if (options.pageLifecycle) {
const pagehide = () => stop()
const pageshow = (event: PageTransitionEvent) => {
if (event.persisted) void start()
}
window.addEventListener("pagehide", pagehide)
window.addEventListener("pageshow", pageshow)
onCleanup(() => {
window.removeEventListener("pagehide", pagehide)
window.removeEventListener("pageshow", pageshow)
})
}
void start()
})
onCleanup(() => {
stop()
abort.abort()
if (flushTimer) clearTimeout(flushTimer)
pending = []
})
return {
status: () => connection.status,
attempt: () => connection.attempt,
error: () => connection.error,
internal: {
history: () => history.slice(),
},
}
}
function errorMessage(error: unknown) {
if (error === undefined) return undefined
if (error instanceof Error) return error.message
return String(error)
}
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
export * from "./data"
export * from "./connection"
@@ -1,44 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "../src/promise"
import { coalesceClientEvents } from "../src/solid/connection"
describe("coalesceClientEvents", () => {
const delta = (id: string, value: string, ordinal = 0) =>
({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
}) as OpenCodeEvent
test("merges adjacent deltas for the same stream", () => {
const result = coalesceClientEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("coalesces tool input deltas by tool ID", () => {
const current = (eventID: string, id: string, value: string) =>
({
id: eventID,
created: 1,
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta: value },
}) as OpenCodeEvent
const result = coalesceClientEvents([
current("evt_1", "call_1", "{"),
current("evt_2", "call_1", "}"),
current("evt_3", "call_2", "[]"),
])
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceClientEvents(events).map((event) => event.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
})
+48 -12
View File
@@ -204,6 +204,11 @@ export interface Interface {
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
readonly select: (input: {
sessionID: SessionSchema.ID
agent: Agent.ID
model?: Model.Ref
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly move: (input: {
sessionID: SessionSchema.ID
@@ -701,22 +706,45 @@ const layer = Layer.effect(
.resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
select: Effect.fn("Session.select")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
const agent =
session.agent === input.agent
? undefined
: {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
}
const model = !input.model || sameModel(session.model, input.model) ? undefined : input.model
if (agent && model) {
yield* bus.publishAll([
[SessionEvent.AgentSelected, agent],
[
SessionEvent.ModelSelected,
{
sessionID: input.sessionID,
model,
previous: session.model,
},
],
])
return
}
if (agent) yield* bus.publish(SessionEvent.AgentSelected, agent)
if (model)
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model,
previous: session.model,
})
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
yield* result.select({ sessionID: input.sessionID, agent: input.agent })
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
if (sameModel(session.model, input.model)) return
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model: input.model,
@@ -1084,6 +1112,14 @@ function positiveInt(value: string | null) {
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
function sameModel(left: Model.Ref | undefined, right: Model.Ref) {
return (
left?.providerID === right.providerID &&
left.id === right.id &&
(left.variant ?? "default") === (right.variant ?? "default")
)
}
// Mirrors the shell tool's in-memory preview safety limit.
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
+28
View File
@@ -728,6 +728,34 @@ describe("Session.create", () => {
}),
)
it.effect("atomically selects a different agent and model", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const previous = Model.Ref.make({ id: Model.ID.make("haiku"), providerID: Provider.ID.anthropic })
const created = yield* session.create({ location, agent: Agent.ID.make("build"), model: previous })
const model = Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.anthropic })
yield* session.select({ sessionID: created.id, agent: Agent.ID.make("plan"), model })
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan", model })
expect(
Array.from(yield* logEvents(session, created.id).pipe(Stream.runCollect)).map((event) => event.type),
).toEqual(["session.created", "session.agent.selected", "session.model.selected"])
}),
)
it.effect("does not emit redundant selection events", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const model = Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.anthropic })
const created = yield* session.create({ location, agent: Agent.ID.make("build"), model })
yield* session.select({ sessionID: created.id, agent: Agent.ID.make("build"), model })
expect(Array.from(yield* logEvents(session, created.id).pipe(Stream.runCollect))).toHaveLength(1)
}),
)
it.effect("rejects an agent switch for a missing Session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
+1 -1
View File
@@ -41,7 +41,7 @@ export async function startBackgroundCli(logger: Logger) {
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version: cli.version,
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
command: [...cli.command, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
+22
View File
@@ -54,6 +54,12 @@ const SessionsQueryFields = {
parentID: ParentIDFilter.pipe(Schema.optional),
}
const SelectionModelPolicy = Schema.Union([
Schema.Struct({ type: Schema.Literal("preserve") }),
Schema.Struct({ type: Schema.Literal("configured") }),
Schema.Struct({ type: Schema.Literal("explicit"), model: Model.Ref }),
])
const SessionsDirectoryQuery = Schema.Struct({
...SessionsQueryFields,
directory: AbsolutePath,
@@ -250,6 +256,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.select", "/api/session/:sessionID/selection", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ agent: Agent.ID, model: SelectionModelPolicy }),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, ServiceUnavailableError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.select",
summary: "Select session agent and model",
description: "Atomically select the agent and model policy used by subsequent provider turns.",
}),
),
)
.add(
HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", {
params: { sessionID: Session.ID },
+37
View File
@@ -1,4 +1,6 @@
import { Session } from "@opencode-ai/core/session"
import { Agent } from "@opencode-ai/core/agent"
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { DateTime, Effect, Stream } from "effect"
@@ -226,6 +228,41 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.select",
Effect.fn(function* (ctx) {
const model = yield* Effect.gen(function* () {
if (ctx.payload.model.type === "preserve") return undefined
if (ctx.payload.model.type === "explicit") return ctx.payload.model.model
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush.pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () =>
Effect.fail(
new ServiceUnavailableError({
message: "Agent initialization timed out",
service: "agent.catalog",
}),
),
}),
)
const agents = yield* Agent.Service
return (yield* agents.get(ctx.payload.agent))?.model
})
yield* session.select({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent, model }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.switchAgent",
Effect.fn(function* (ctx) {
+119
View File
@@ -0,0 +1,119 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer } from "effect/unstable/http"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerProcess } from "../src/process"
it.live("resolves configured agent models after plugin initialization", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir("opencode-session-endpoint-")),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
agents: {
modelprobe: {
description: "Model resolution probe",
mode: "primary",
model: "opencode/nemotron-3.5-lightning-free",
},
plain: { description: "No configured model", mode: "primary" },
},
}),
),
)
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
config: { directory: tmp.path },
fs: { filewatcher: false },
})
const base = HttpServer.formatAddress(server.address)
const created = yield* request(base, "/api/session", {
agent: "modelprobe",
location: { directory: tmp.path },
})
if (!isRecord(created) || !isRecord(created["data"])) throw new Error("Expected a created session")
expect(created["data"]["model"]).toBeUndefined()
const sessionID = created["data"]["id"]
if (typeof sessionID !== "string") throw new Error("Expected a Session ID")
expect(
yield* request(base, `/api/session/${sessionID}/selection`, {
agent: "modelprobe",
model: { type: "configured" },
}),
).toBeUndefined()
const configured = yield* get(base, `/api/session/${sessionID}`)
if (!isRecord(configured) || !isRecord(configured["data"])) throw new Error("Expected a Session")
expect(configured["data"]).toMatchObject({
agent: "modelprobe",
model: {
providerID: "opencode",
id: "nemotron-3.5-lightning-free",
variant: "default",
},
})
expect(
yield* request(base, `/api/session/${sessionID}/selection`, {
agent: "plain",
model: { type: "configured" },
}),
).toBeUndefined()
const preserved = yield* get(base, `/api/session/${sessionID}`)
if (!isRecord(preserved) || !isRecord(preserved["data"])) throw new Error("Expected a Session")
expect(preserved["data"]).toMatchObject({ agent: "plain", model: configured["data"]["model"] })
expect(
yield* request(base, `/api/session/${sessionID}/selection`, {
agent: "missing",
model: { type: "configured" },
}),
).toBeUndefined()
const missing = yield* get(base, `/api/session/${sessionID}`)
if (!isRecord(missing) || !isRecord(missing["data"])) throw new Error("Expected a Session")
expect(missing["data"]).toMatchObject({ agent: "missing", model: configured["data"]["model"] })
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function request(base: string, pathname: string, body: unknown) {
return Effect.promise(() =>
fetch(new URL(pathname, base), {
method: "POST",
headers: {
authorization: `Basic ${btoa("opencode:secret")}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
}).then(async (response) => {
expect(response.status).toBe(pathname === "/api/session" ? 200 : 204)
return response.status === 204 ? undefined : response.json()
}),
)
}
function get(base: string, pathname: string) {
return Effect.promise(() =>
fetch(new URL(pathname, base), {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}).then((response) => {
expect(response.status).toBe(200)
return response.json()
}),
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+183 -18
View File
@@ -1,39 +1,177 @@
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
import { createClientConnection } from "@opencode-ai/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { batch, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "../util/error"
import { createSimpleContext } from "./helper"
import { useLog } from "./log"
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
export type ClientConnectionEvent = {
readonly type: "client.connection"
readonly created: number
readonly data: {
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
readonly attempt: number
readonly error?: string
}
}
type ManagedService = {
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
restart: () => Promise<void>
}
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
const connectTimeout = 2_000
const connectionHistoryLimit = 50
const eventFlushInterval = 10
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
name: "Client",
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
const log = useLog({ component: "client" })
const service = props.service
const events = createGlobalEmitter<ClientEventMap>()
const abort = new AbortController()
const history: ClientConnectionEvent[] = []
let api = props.api
const connection = createClientConnection(api, {
reconnect: service
? async (signal) => {
api = (await service.reconnect(signal)).api
return api
}
: undefined,
onEvent(event) {
events.emit(event.type, event)
},
log,
const events = createGlobalEmitter<ClientEventMap>()
let pending: OpenCodeEvent[] = []
let flushTimer: ReturnType<typeof setTimeout> | undefined
const [connection, setConnection] = createStore<{
status: ClientConnectionStatus
attempt: number
error?: string
}>({
status: "connecting",
attempt: 0,
})
let stream: AbortController | undefined
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
if (history.length > connectionHistoryLimit) history.shift()
}
function flushEvents() {
flushTimer = undefined
const queued = pending
pending = []
batch(() => queued.forEach((event) => events.emit(event.type, event)))
}
function emit(event: OpenCodeEvent) {
pending.push(event)
if (flushTimer) return
flushTimer = setTimeout(flushEvents, eventFlushInterval)
}
async function connect(signal: AbortSignal, attempt: number) {
let connectedAt: number | undefined
// Bound the initial handshake and tie this request to the stream lifetime.
const request = new AbortController()
const cancel = () => request.abort(signal.reason)
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
signal.addEventListener("abort", cancel, { once: true })
try {
// Open the event stream and validate its initial handshake.
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
log.info("event stream connecting", { attempt })
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
const first = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (first.done) {
const error =
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
return { error, connectedAt }
}
if (first.value.type !== "server.connected")
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
// Publish the connected state before forwarding live events.
clearTimeout(timeout)
record("connected", attempt)
connectedAt = Date.now()
log.info("event stream connected")
emit(first.value)
setConnection({ status: "connected", attempt: 0, error: undefined })
// Forward events until the stream closes or this connection is cancelled.
while (!signal.aborted) {
const event = await iterator.next()
if (signal.aborted) return { error: undefined, connectedAt }
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
if ("durable" in event.value)
log.debug("event", {
type: event.value.type,
aggregateID: event.value.durable.aggregateID,
seq: event.value.durable.seq,
})
emit(event.value)
}
return { error: undefined, connectedAt }
} catch (error) {
return { error, connectedAt }
} finally {
request.abort()
clearTimeout(timeout)
signal.removeEventListener("abort", cancel)
}
}
function start() {
stream?.abort()
const controller = new AbortController()
stream = controller
void (async () => {
let attempt = 0
while (!abort.signal.aborted && !controller.signal.aborted) {
const result = await connect(controller.signal, attempt)
if (abort.signal.aborted || controller.signal.aborted) return
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0
attempt += 1
const message = errorMessage(result.error)
record("disconnected", attempt, message)
log.info("event stream disconnected", {
attempt,
error: message,
})
setConnection({ status: "reconnecting", attempt, error: message })
// Re-resolve the transport before retrying: the server may have
// moved (service restarted on a new port) or need starting. Static
// transports (--server, standalone) resolve to the same address.
if (props.service) {
const next = await props.service.reconnect(controller.signal).catch((error) => {
if (!controller.signal.aborted)
log.info("server resolution failed", {
attempt,
error: errorMessage(error),
})
})
if (abort.signal.aborted || controller.signal.aborted) return
if (next) {
api = next.api
if (attempt === 1) continue
}
}
await wait(1_000, controller.signal)
}
})()
}
onMount(start)
onCleanup(() => {
abort.abort()
stream?.abort()
if (flushTimer) clearTimeout(flushTimer)
pending = []
events.clear()
})
@@ -45,8 +183,35 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
on: events.on,
listen: events.listen,
},
connection,
restart: service?.restart,
connection: {
status() {
return connection.status
},
attempt() {
return connection.attempt
},
error() {
return connection.error
},
internal: {
history() {
return history.slice()
},
},
},
restart: props.service?.restart,
}
},
})
function wait(delay: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const timer = setTimeout(done, delay)
signal.addEventListener("abort", done, { once: true })
function done() {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
})
}
+1
View File
@@ -37,6 +37,7 @@ import type { Plugin } from "@opencode-ai/plugin/tui"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
import { nonEmptyToolContent } from "../util/tool-display"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { Worktree } from "@opencode-ai/schema/worktree"
import { createEffect, createSignal, onCleanup } from "solid-js"
+4 -8
View File
@@ -1873,14 +1873,15 @@ test("refreshes effective catalog data after catalog updates", async () => {
test("refreshes agents after agent updates", async () => {
const events = createEventStream()
let agentID = "build"
let requests = 0
const calls = createFetch((url) => {
if (url.pathname !== "/api/agent") return
requests++
return json({
location: { directory, project: { id: "proj_test", directory } },
data: [
{
id: agentID,
id: requests === 1 ? "build" : "reviewer",
request: { headers: {}, body: {} },
mode: "primary",
hidden: false,
@@ -1910,11 +1911,6 @@ test("refreshes agents after agent updates", async () => {
try {
await wait(() => data.location.agent.list()?.[0]?.id === "build")
await Bun.sleep(20)
events.emit({ id: "evt_agent_unlocated", created: 0, type: "agent.updated", data: {} })
await Bun.sleep(20)
expect(data.location.agent.list()?.[0]?.id).toBe("build")
agentID = "reviewer"
emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} })
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
} finally {
@@ -2805,7 +2801,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
await mounted
const received: string[] = []
const unsubscribe = sync.listen((event) => received.push(event.name))
events.emit({
emitEvent(events, {
id: "evt_admitted_1",
created: 0,
type: "session.inbox.enqueued",