Compare commits

..

1 Commits

Author SHA1 Message Date
rekram1-node 42608433f1 feat(plugin): expose session history reads 2026-08-20 03:17:28 +00:00
19 changed files with 353 additions and 292 deletions
+1
View File
@@ -6,6 +6,7 @@ export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type MessageApi = Client["message"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type ProviderApi = Client["provider"]
+121 -1
View File
@@ -6,7 +6,7 @@ import type { CredentialOAuth } from "@opencode-ai/sdk/v2/types"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Mcp } from "@opencode-ai/schema/mcp"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { DateTime, Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
import { AISDK } from "../aisdk.js"
import { Catalog } from "../catalog.js"
@@ -27,8 +27,19 @@ import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { WebSearch } from "../websearch.js"
import { PluginHooks } from "./hooks.js"
import { Session } from "../session.js"
import { SessionMessage } from "../session/message.js"
const mutable = <T>(value: T) => value as DeepMutable<T>
type SessionListInput = Exclude<Parameters<Plugin.Context["session"]["list"]>[0], undefined>
type SessionListCursor = Exclude<SessionListInput["cursor"], undefined>
const MessageCursor = Schema.Struct({
id: SessionMessage.ID,
order: Schema.Literals(["asc", "desc"]),
direction: Schema.Literals(["previous", "next"]),
})
export const make = Effect.fn("PluginHost.make")(function* (
plugin: import("../plugin.js").Interface,
pluginID: string = "test",
@@ -67,6 +78,57 @@ export const make = Effect.fn("PluginHost.make")(function* (
ref.directory === location.directory && ref.workspaceID === location.workspaceID
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
const sessionList = (input?: SessionListInput, parentID?: Session.ID) =>
Effect.gen(function* () {
const decoded = input?.cursor === undefined ? sessionListQuery(input) : yield* decodeSessionCursor(input.cursor)
const query = parentID === undefined ? decoded : { ...decoded, parentID }
const page = yield* runtime.session.list({ ...query, limit: input?.limit ?? 50 })
const first = page.data[0]
const last = page.data.at(-1)
return {
data: page.data,
cursor: {
previous:
first === undefined
? undefined
: encodeSessionCursor(query, {
id: first.id,
time: DateTime.toEpochMillis(first.time.updated),
direction: "previous",
}),
next:
last === undefined
? undefined
: encodeSessionCursor(query, {
id: last.id,
time: DateTime.toEpochMillis(last.time.updated),
direction: "next",
}),
},
}
})
const sessionMessages = (input: Parameters<Plugin.Context["session"]["messages"]>[0]) =>
Effect.gen(function* () {
if (input.cursor !== undefined && input.order !== undefined)
return yield* Effect.fail(new Error("Invalid cursor"))
const decoded = input.cursor === undefined ? undefined : yield* decodeMessageCursor(input.cursor)
const order = decoded?.order ?? input.order ?? "desc"
const messages = yield* runtime.session.messages({
sessionID: input.sessionID,
limit: input.limit ?? 50,
order,
cursor: decoded === undefined ? undefined : { id: decoded.id, direction: decoded.direction },
})
const first = messages[0]
const last = messages.at(-1)
return {
data: messages,
cursor: {
previous: first === undefined ? undefined : encodeMessageCursor(first, order, "previous"),
next: last === undefined ? undefined : encodeMessageCursor(last, order, "next"),
},
}
})
return {
app,
@@ -391,6 +453,9 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options),
list: sessionList,
children: (input) => sessionList({ cursor: input.cursor, limit: input.limit }, input.sessionID),
messages: sessionMessages,
create: (input) =>
runtime.session.create({
id: input?.id,
@@ -412,6 +477,61 @@ export const make = Effect.fn("PluginHost.make")(function* (
} satisfies Plugin.Context
})
function sessionListQuery(input?: SessionListInput): Session.ListInput {
const common = {
workspaceID: input?.workspace,
search: input?.search,
order: input?.order,
parentID: input?.parentID,
}
if (input?.directory !== undefined) return { ...common, directory: input.directory }
if (input?.project !== undefined) return { ...common, project: input.project, subpath: input.subpath }
return common
}
function encodeSessionCursor(query: Session.ListInput, anchor: Session.ListAnchor): SessionListCursor {
const value = {
workspace: query.workspaceID,
search: query.search,
order: query.order,
parentID: query.parentID,
anchor,
...("directory" in query ? { directory: query.directory } : {}),
...("project" in query ? { project: query.project, subpath: query.subpath } : {}),
}
return Buffer.from(JSON.stringify(value)).toString("base64url") as SessionListCursor
}
function decodeSessionCursor(input: string) {
return Effect.try({
try: () => JSON.parse(Buffer.from(input, "base64url").toString("utf8")),
catch: () => new Error("Invalid cursor"),
}).pipe(
Effect.flatMap((value) => {
if (typeof value !== "object" || value === null) return Effect.fail(new Error("Invalid cursor"))
return Schema.decodeUnknownEffect(Session.ListInput)({
...value,
workspaceID: "workspace" in value ? value.workspace : undefined,
})
}),
Effect.mapError(() => new Error("Invalid cursor")),
)
}
function encodeMessageCursor(message: SessionMessage.Info, order: "asc" | "desc", direction: "previous" | "next") {
return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url")
}
function decodeMessageCursor(input: string) {
return Effect.try({
try: () => JSON.parse(Buffer.from(input, "base64url").toString("utf8")),
catch: () => new Error("Invalid cursor"),
}).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(MessageCursor)),
Effect.mapError(() => new Error("Invalid cursor")),
)
}
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
const namespace = `plugin:${pluginID
.split("")
+2
View File
@@ -13,6 +13,7 @@ import { Session } from "../session.js"
export interface Interface {
readonly session: Pick<
Session.Interface,
| "list"
| "get"
| "create"
| "messages"
@@ -69,6 +70,7 @@ export const layerWithCell = (cell: Cell) =>
Service,
Service.of({
session: {
list: (input) => require(cell, (runtime) => runtime.session.list(input)),
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
+1 -10
View File
@@ -21,11 +21,7 @@ export interface Interface {
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/**
* Interrupt active work owned by this process. Idle interruption is a no-op. Resolves once
* the interruption is accepted; cleanup settles asynchronously in the execution fiber.
* Compose with `awaitIdle` when settlement matters.
*/
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
@@ -145,11 +141,6 @@ export const layer = Layer.effect(
// Resume steering input and between-turn control work from the interrupted
// intent. Queued next-turn prompts stay parked: a steer-scoped drain never
// promotes them, and a control item behind a queued prompt waits its turn.
// Interruption acknowledges before cleanup settles, so this wake usually lands
// on the stopping execution's doorbell and starts the successor at settle.
// Reading the inbox concurrently with the dying drain is safe: delivery consumes
// rows inside uninterruptible publications, so a steer row is either still
// promotable here or was fully delivered and needs no resumption.
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (next === undefined) return
if (next.delivery === "steer" || next.type === "compaction" || next.type === "move")
+9 -29
View File
@@ -11,11 +11,7 @@ export interface Coordinator<Key, E, Reason = never> {
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key, scope?: Promotable) => Effect.Effect<void>
/**
* Stops the active execution and clears its doorbell. No-op when idle. Resolves once the
* interruption is accepted, not when cleanup settles: the execution fiber finishes its
* finalizers and settled hook on its own time. Compose with `awaitIdle` for settlement.
*/
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
@@ -30,13 +26,7 @@ export interface Coordinator<Key, E, Reason = never> {
* with this execution's exit.
*/
type Execution<E, Reason> = {
/**
* Resolves with the execution's exit as a success value. Success-valued on purpose:
* completing a Deferred with an interrupted exit interrupts suspended waiters as it
* resumes them, and can starve later waiters of their resume entirely
* (Effect-TS/effect#7364). Joiners flatten the exit; idleness waiters just await.
*/
readonly done: Deferred.Deferred<Exit.Exit<void, E>>
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
scope: Promotable
pendingWake?: Promotable
@@ -84,7 +74,7 @@ export const make = <Key, E, Reason = never>(options: {
const start = (key: Key, force: boolean, scope: Promotable) => {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<Exit.Exit<void, E>>(),
done: Deferred.makeUnsafe<void, E>(),
scope,
stopping: false,
}
@@ -114,7 +104,7 @@ export const make = <Key, E, Reason = never>(options: {
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, Exit.succeed(exit))
Deferred.doneUnsafe(execution.done, exit)
}
const run = (key: Key): Effect.Effect<void, E> =>
@@ -123,9 +113,9 @@ export const make = <Key, E, Reason = never>(options: {
if (execution !== undefined) {
// A stopping execution refuses joiners: wait out its cleanup, then run fresh.
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done).pipe(Effect.flatten)
return Deferred.await(execution.done)
}
return Deferred.await(start(key, true, "input").done).pipe(Effect.flatten)
return Deferred.await(start(key, true, "input").done)
})
const wake = (key: Key, scope: Promotable = "input") =>
@@ -142,23 +132,13 @@ export const make = <Key, E, Reason = never>(options: {
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined || execution.stopping) return Effect.void
if (execution.owner === undefined) {
// Settlement window: the owner exited but the settled hook has not finished. The
// terminal outcome is already decided, so no reason attaches — but the interrupt
// still claims the recorded wakes so settle does not start a dead-intent successor.
execution.pendingWake = undefined
return Effect.void
}
if (execution?.owner === undefined || execution.stopping) return Effect.void
execution.stopping = true
// Wakes recorded so far belong to the interrupted intent; the interrupt claims them.
// Wakes arriving during cleanup are new admissions and restart normally at settle.
execution.pendingWake = undefined
execution.interruptionReason = reason
// Fire and forget: nobody benefits from waiting out cleanup here, and callers like
// the interrupt endpoint must acknowledge immediately even when finalizers are slow.
fork(Fiber.interrupt(execution.owner))
return Effect.void
return Fiber.interrupt(execution.owner)
})
// One execution's `done` already spans coalesced continuations; re-check after it
@@ -167,7 +147,7 @@ export const make = <Key, E, Reason = never>(options: {
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined) return Effect.void
return Deferred.await(execution.done).pipe(Effect.andThen(awaitIdle(key)))
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
+53 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { ToolFailure } from "@opencode-ai/ai"
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Context, DateTime, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
@@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import { Money } from "@opencode-ai/schema/money"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
@@ -24,6 +25,57 @@ class Secret extends Context.Service<Secret, string>()("@opencode/test/PluginSec
const versioned = <R>(plugin: EffectPlugin.Plugin<R>, version = "1") => ({ ...plugin, version })
describe("Plugin", () => {
it.effect("exposes paginated session history reads", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const runtime = yield* PluginRuntime.Service
const location = yield* Location.Service
const parentID = Session.ID.make("ses_parent")
const child = (id: string, updated: number) =>
Session.Info.make({
id: Session.ID.make(id),
parentID,
projectID: location.project.id,
cost: Money.USD.make(0),
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(updated), updated: DateTime.makeUnsafe(updated) },
location: Location.Ref.make({ directory: location.directory }),
})
const firstChild = child("ses_first", 1)
const secondChild = child("ses_second", 2)
const seen: unknown[] = []
const host = yield* PluginHost.make(plugins).pipe(
Effect.provideService(
PluginRuntime.Service,
PluginRuntime.Service.of({
...runtime,
session: {
...runtime.session,
list: (input) => {
seen.push(input)
return Effect.succeed({ data: [input?.anchor === undefined ? firstChild : secondChild] })
},
messages: (input) => {
seen.push(input)
return Effect.succeed([])
},
},
}),
),
)
const first = yield* host.session.children({ sessionID: parentID, limit: 1 })
const second = yield* host.session.children({ sessionID: parentID, limit: 1, cursor: first.cursor.next })
const messages = yield* host.session.messages({ sessionID: parentID })
expect(first.data).toHaveLength(1)
expect(second.data).toHaveLength(1)
expect(second.data[0]?.id).not.toBe(first.data[0]?.id)
expect(messages.data).toEqual([])
expect(seen).toHaveLength(3)
}),
)
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+3
View File
@@ -115,6 +115,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
},
session: {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
list: overrides.session?.list ?? (() => Effect.die("unused session.list")),
children: overrides.session?.children ?? (() => Effect.die("unused session.children")),
messages: overrides.session?.messages ?? (() => Effect.die("unused session.messages")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
+35
View File
@@ -100,6 +100,41 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts session history reads through the protocol schema", () =>
Effect.gen(function* () {
const seen: unknown[] = []
const host = testHost({
session: {
list: (input) => {
seen.push(input)
return Effect.succeed({ data: [], cursor: {} })
},
messages: (input) => {
seen.push(input)
return Effect.succeed({ data: [], cursor: {} })
},
},
})
yield* PluginPromise.fromPromise(
define({
id: "promise-session-history",
setup: async (ctx) => {
await ctx.session.list({ parentID: null, limit: 2 })
await ctx.session.children({ sessionID: Session.ID.make("ses_parent"), limit: 3 })
await ctx.session.messages({ sessionID: Session.ID.make("ses_parent"), limit: 4, order: "asc" })
},
}),
).effect(host)
expect(seen).toEqual([
{ parentID: null, limit: 2 },
{ parentID: "ses_parent", limit: 3 },
{ sessionID: "ses_parent", limit: 4, order: "asc" },
])
}),
)
it.effect("forwards transient session generation", () =>
Effect.gen(function* () {
const host = testHost({
@@ -622,7 +622,6 @@ describe("SessionRunCoordinator", () => {
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.interrupt("session")
yield* coordinator.awaitIdle("session")
expect(settled).toHaveLength(1)
expect(settled[0] !== undefined && Exit.isFailure(settled[0]) && Cause.hasInterrupts(settled[0].cause)).toBe(
@@ -633,69 +632,6 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("acknowledges interruption before cleanup settles", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const settled: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
settled: (_key, _exit, reason) => Effect.sync(() => void settled.push(reason)),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
// Interrupt resolves while the cleanup gate is still closed: acceptance, not settlement.
yield* coordinator.interrupt("session", "user")
expect(settled).toHaveLength(0)
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
// Repeating the interrupt during cleanup stays an immediate no-op.
yield* coordinator.interrupt("session", "user")
yield* Deferred.await(cleanupStarted)
yield* Deferred.succeed(cleanupGate, undefined)
yield* coordinator.awaitIdle("session")
expect(settled).toEqual(["user"])
expect(yield* coordinator.active).toEqual(new Set())
}),
),
)
it.effect("an interrupt during terminal settlement claims the recorded wake", () =>
Effect.scoped(
Effect.gen(function* () {
const settling = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
let drains = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Effect.sync(() => void drains++),
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* coordinator.wake("session")
yield* Deferred.await(settling)
// The owner has exited; this wake lands on the settling execution's doorbell.
yield* coordinator.wake("session")
// The interrupt claims it: settle must not start a successor for the dead intent.
yield* coordinator.interrupt("session")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(drains).toBe(1)
expect(yield* coordinator.active).toEqual(new Set())
}),
),
)
it.effect("trampolines synchronous self-waking execution", () =>
Effect.scoped(
Effect.gen(function* () {
+10 -2
View File
@@ -1,4 +1,4 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { MessageApi, SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
@@ -45,9 +45,17 @@ export interface SessionHooks {
readonly "http.response": SessionHttpResponse
}
type SessionListInput = Exclude<Parameters<SessionApi<unknown>["list"]>[0], undefined>
export type SessionChildrenInput = Pick<SessionListInput, "cursor" | "limit"> & {
readonly sessionID: Session.ID
}
export type SessionDomain = Pick<
SessionApi<unknown>,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
"list" | "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly children: (input: SessionChildrenInput) => ReturnType<SessionApi<unknown>["list"]>
readonly messages: MessageApi<unknown>["list"]
readonly hook: ModelHooks<SessionHooks>
}
+13
View File
@@ -76,6 +76,7 @@ export function fromPromise(plugin: Plugin) {
const AgentEndpoints = ClientApi.groups["server.agent"].endpoints
const CommandEndpoints = ClientApi.groups["server.command"].endpoints
const IntegrationEndpoints = ClientApi.groups["server.integration"].endpoints
const MessageEndpoints = ClientApi.groups["server.message"].endpoints
const McpEndpoints = ClientApi.groups["server.mcp"].endpoints
const ModelEndpoints = ClientApi.groups["server.model"].endpoints
const PluginEndpoints = ClientApi.groups["server.plugin"].endpoints
@@ -119,6 +120,10 @@ export function fromPromise(plugin: Plugin) {
callback(draft)
}),
)
const sessionList = adaptApiMethod<Context["session"]["list"]>(
SessionEndpoints["session.list"],
host.session.list,
)
const context2: Context = {
app: host.app,
@@ -307,6 +312,14 @@ export function fromPromise(plugin: Plugin) {
register(
host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))), options),
),
list: sessionList,
children: (input) =>
sessionList({
parentID: input.sessionID,
cursor: input.cursor,
limit: input.limit,
}),
messages: adaptApiMethod(MessageEndpoints["session.messages"], host.session.messages),
create: adaptApiMethod(SessionEndpoints["session.create"], host.session.create),
get: adaptApiMethod(SessionEndpoints["session.get"], host.session.get),
prompt: adaptApiMethod(SessionEndpoints["session.prompt"], host.session.prompt),
+10 -2
View File
@@ -1,4 +1,4 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { MessageApi, SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
@@ -45,9 +45,17 @@ export interface SessionHooks {
readonly "http.response": SessionHttpResponse
}
type SessionListInput = Exclude<Parameters<SessionApi["list"]>[0], undefined>
export type SessionChildrenInput = Pick<SessionListInput, "cursor" | "limit"> & {
readonly sessionID: Session.ID
}
export type SessionDomain = Pick<
SessionApi,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
"list" | "create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly children: (input: SessionChildrenInput) => ReturnType<SessionApi["list"]>
readonly messages: MessageApi["list"]
readonly hook: ModelHooks<SessionHooks>
}
+51 -13
View File
@@ -12,10 +12,8 @@ import { readJson, writeJsonAtomic } from "../util/persistence"
import {
createModelPreferenceRepository,
cycleModelVariant,
favoriteModels,
modelPreferenceKey,
normalizeModelVariant,
recentModels,
type ModelPreference,
type ModelPreferenceModel,
} from "../model-preference"
@@ -34,6 +32,19 @@ export function parseModel(model: string) {
}
}
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
const seen = new Set<string>()
return [model, ...recent]
.filter((item) => {
const key = modelPreferenceKey(item)
if (seen.has(key)) return false
seen.add(key)
return true
})
.slice(0, 10)
.map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
@@ -140,16 +151,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const pendingSelectionCommits = new Map<string, string>()
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
function applyPreferences(value: ModelPreference) {
batch(() => {
const saveState = {
pending: false,
}
function savePreferences() {
if (!preferences.ready) {
saveState.pending = true
return
}
saveState.pending = false
void repository
.patch({
recent: preferences.recent,
favorite: preferences.favorite,
variant: preferences.variant,
})
.catch(() => undefined)
}
repository
.load()
.then((value) => {
setPreferences("recent", value.recent)
setPreferences("favorite", value.favorite)
setPreferences("variant", value.variant)
setPreferences("ready", true)
})
}
onCleanup(repository.subscribe(applyPreferences))
.catch(() => {})
.finally(() => {
setPreferences("ready", true)
if (saveState.pending) savePreferences()
})
const fallbackModel = createMemo(() => {
if (args.model) {
@@ -357,7 +389,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!next) return
if (!selectModel({ ...next })) return
setPreferences("recent", recentModels(next, preferences.recent))
void repository.addRecent(next).catch(() => undefined)
savePreferences()
},
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
batch(() => {
@@ -365,7 +397,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!selectModel(model)) return
if (options?.recent) {
setPreferences("recent", recentModels(model, preferences.recent))
void repository.addRecent(model).catch(() => undefined)
savePreferences()
}
})
},
@@ -375,8 +407,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const exists = preferences.favorite.some(
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
)
setPreferences("favorite", favoriteModels(model, preferences.favorite, !exists))
void repository.setFavorite(model, !exists).catch(() => undefined)
const next = exists
? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...preferences.favorite]
setPreferences(
"favorite",
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
)
savePreferences()
})
},
variant: {
@@ -401,7 +439,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
}
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
void repository.saveVariant(m, value).catch(() => undefined)
savePreferences()
},
cycle() {
const variants = this.list()
+9 -4
View File
@@ -115,6 +115,7 @@ type ResolvedSession = {
type RuntimeState = {
sdk: RunInput["sdk"]
shown: boolean
aborting: boolean
model: RunInput["model"]
defaultModel: RunInput["model"]
providers: RunProvider[]
@@ -212,6 +213,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const state: RuntimeState = {
sdk: ctx.sdk,
shown: !session.first,
aborting: false,
model: ctx.model ?? session.model,
defaultModel: undefined,
providers: [],
@@ -366,17 +368,20 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
},
onInterrupt: () => {
if (!state.sessionID) {
if (!state.sessionID || state.aborting) {
return false
}
// No in-flight guard: interruption acknowledges immediately server-side and repeating
// it is an idempotent no-op, so repeated presses are never swallowed.
state.aborting = true
void (
state.stream
? state.stream.then((item) => item.handle.interruptActiveTurn())
: state.sdk.session.interrupt({ sessionID: state.sessionID, continue: true })
).catch(() => {})
)
.catch(() => {})
.finally(() => {
state.aborting = false
})
return true
},
onBackground: () => {
+5 -25
View File
@@ -143,8 +143,6 @@ type State = {
closed: boolean
initial: boolean
rootActive: boolean
/** Bumped on every root execution lifecycle event; guards paints against stale acks. */
executionEpoch: number
buffered?: ReplayBuffer
errors: Set<string>
pending: Map<string, FooterQueuedPrompt>
@@ -476,7 +474,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
closed: false,
initial: true,
rootActive: false,
executionEpoch: 0,
errors: new Set(),
pending: new Map(),
admitted: new Set(),
@@ -515,13 +512,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
})
controller.signal.addEventListener("abort", () => subagents.close(), { once: true })
// The one "go idle" transition, shared by settlement, terminal events, and the
// interrupt ack so the flag and the paint cannot drift apart.
const paintIdle = (status: string) => {
state.rootActive = false
write([], { phase: "idle", status })
}
const write = (commits: StreamCommit[], patch?: { phase?: "idle" | "running"; status?: string; usage?: string }) => {
if (state.closed || controller.signal.aborted || input.footer.isClosed) return
if (!state.initial && state.buffered === undefined)
@@ -800,7 +790,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const settleSession = async (client: OpenCodeClient) => {
await client.session.wait({ sessionID: input.sessionID }, { signal: controller.signal })
for (const message of await projectedMessages(client, controller.signal)) renderMessage(message, true, true)
paintIdle(blockerStatus(state.view))
state.rootActive = false
write([], { phase: "idle", status: blockerStatus(state.view) })
await input.footer.idle()
}
@@ -1306,7 +1297,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (event.type === "session.execution.started") {
state.executionEpoch++
state.rootActive = true
write([], { phase: "running" })
return
@@ -1316,8 +1306,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.type === "session.execution.failed" ||
event.type === "session.execution.interrupted"
) {
state.executionEpoch++
paintIdle("")
state.rootActive = false
write([], { phase: "idle", status: "" })
const current = state.wait
if (!current) return
if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
@@ -1790,17 +1780,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (state.wait) state.wait.interrupted = true
// Paint idle at the ack, not at settlement: the server accepts interruption immediately
// while cleanup finishes asynchronously, and the terminal execution event re-confirms.
// A failed request paints nothing, so the two-press gesture stays available for retry,
// and a lifecycle event racing the ack wins via the epoch guard.
const epoch = state.executionEpoch
await sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).then(
() => {
if (state.executionEpoch === epoch) paintIdle(blockerStatus(state.view))
},
() => {},
)
await sdk.session.interrupt({ sessionID: input.sessionID, continue: true }).catch(() => {})
},
selectSubagent(sessionID) {
subagents.select(sdk, sessionID)
+11 -77
View File
@@ -1,7 +1,5 @@
import { readJson, writeJsonAtomic } from "./util/persistence"
import { isRecord } from "./util/record"
import { watch } from "node:fs"
import path from "node:path"
export type ModelPreferenceModel = {
providerID: string
@@ -45,24 +43,6 @@ export function modelPreferenceKey(model: ModelPreferenceModel) {
return `${model.providerID}/${model.modelID}`
}
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
const seen = new Set<string>()
return [model, ...recent]
.filter((item) => {
const key = modelPreferenceKey(item)
if (seen.has(key)) return false
seen.add(key)
return true
})
.slice(0, 10)
.map((item) => ({ providerID: item.providerID, modelID: item.modelID }))
}
export function favoriteModels(model: ModelPreferenceModel, favorite: ModelPreferenceModel[], enabled: boolean) {
const current = favorite.filter((item) => modelPreferenceKey(item) !== modelPreferenceKey(model))
return enabled ? [model, ...current] : current
}
export function cycleModelVariant(current: string | undefined, variants: string[]) {
const named = variants.filter((variant) => variant !== "default")
if (named.length === 0) return undefined
@@ -100,78 +80,32 @@ function patch(value: Partial<ModelPreference>) {
}
export function createModelPreferenceRepository(filePath: string) {
let pending = Promise.resolve()
let revision = 0
let watcher: ReturnType<typeof watch> | undefined
let reload: ReturnType<typeof setTimeout> | undefined
const listeners = new Set<(value: ModelPreference) => void>()
const state = {
pending: Promise.resolve(),
}
const read = () =>
readJson<unknown>(filePath)
.then(decodeModelPreference)
.catch(() => decodeModelPreference(undefined))
function update(change: (current: ModelPreference) => Partial<ModelPreference>) {
const result = pending.then(async () => {
const { Flock } = await import("@opencode-ai/util/flock")
return Flock.withLock(
filePath,
async () => {
const current = await read()
const next = { ...current, ...patch(change(preference(current))) }
await writeJsonAtomic(filePath, next)
},
{ dir: path.join(path.dirname(filePath), "locks") },
)
const result = state.pending.then(async () => {
const current = await read()
const next = { ...current, ...patch(change(preference(current))) }
await writeJsonAtomic(filePath, next)
})
pending = result.then(
() => undefined,
() => undefined,
)
state.pending = result.catch(() => undefined)
return result
}
function load() {
return pending.then(read).then(preference)
}
async function refresh() {
const current = ++revision
const value = await load()
if (current !== revision) return
listeners.forEach((listener) => listener(value))
return state.pending.then(read).then(preference)
}
return {
load,
addRecent(model: ModelPreferenceModel) {
return update((current) => ({ recent: recentModels(model, current.recent) }))
},
setFavorite(model: ModelPreferenceModel, enabled: boolean) {
return update((current) => ({ favorite: favoriteModels(model, current.favorite, enabled) }))
},
subscribe(listener: (value: ModelPreference) => void) {
listeners.add(listener)
void refresh()
if (!watcher) {
watcher = watch(path.dirname(filePath), (_event, filename) => {
const changed = filename?.toString()
const name = path.basename(filePath)
if (changed !== undefined && changed !== name && !changed.startsWith(name + ".")) return
clearTimeout(reload)
reload = setTimeout(() => void refresh(), 50)
})
watcher.on("error", () => {
watcher?.close()
watcher = undefined
})
}
return () => {
listeners.delete(listener)
if (listeners.size > 0) return
clearTimeout(reload)
watcher?.close()
watcher = undefined
}
patch(value: Partial<ModelPreference>) {
return update(() => value)
},
async resolveVariant(model: ModelPreferenceModel) {
return (await load()).variant[modelPreferenceKey(model)]
+1 -2
View File
@@ -1,6 +1,5 @@
import { expect, test } from "bun:test"
import { parseModel } from "../../src/context/local"
import { recentModels } from "../../src/model-preference"
import { parseModel, recentModels } from "../../src/context/local"
test("parses model IDs containing slashes", () => {
expect(parseModel("provider/family/model")).toEqual({
+2 -46
View File
@@ -19,7 +19,7 @@ test("repairs known model preferences and preserves unrelated fields", () => {
})
})
test("atomically serializes model preference updates", async () => {
test("atomically serializes patches and variant updates", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "model.json")
await Bun.write(file, JSON.stringify({ unrelated: "keep", favorite: [], variant: {} }))
@@ -28,7 +28,7 @@ test("atomically serializes model preference updates", async () => {
const anthropic = { providerID: "anthropic", modelID: "claude/sonnet" }
await Promise.all([
repository.addRecent(openai),
repository.patch({ recent: [openai] }),
repository.saveVariant(openai, "high"),
repository.saveVariant(anthropic, "low"),
])
@@ -43,47 +43,3 @@ test("atomically serializes model preference updates", async () => {
expect(await repository.resolveVariant(openai)).toBeUndefined()
expect((await Bun.file(file).json()).variant).toEqual({ "anthropic/claude/sonnet": "low" })
})
test("serializes updates across repositories", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "model.json")
await Bun.write(file, JSON.stringify({ recent: [], favorite: [], variant: {} }))
const first = createModelPreferenceRepository(file)
const second = createModelPreferenceRepository(file)
const openai = { providerID: "openai", modelID: "gpt-5" }
const anthropic = { providerID: "anthropic", modelID: "claude-sonnet" }
await Promise.all([first.setFavorite(openai, true), second.addRecent(anthropic), second.saveVariant(openai, "high")])
expect(await first.load()).toEqual({
recent: [anthropic],
favorite: [openai],
variant: { "openai/gpt-5": "high" },
})
})
test("subscribes to updates from another repository", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "model.json")
await Bun.write(file, JSON.stringify({ recent: [], favorite: [], variant: {} }))
const first = createModelPreferenceRepository(file)
const second = createModelPreferenceRepository(file)
const openai = { providerID: "openai", modelID: "gpt-5" }
const changed = Promise.withResolvers<void>()
const unsubscribe = first.subscribe((value) => {
if (value.favorite.some((item) => item.providerID === openai.providerID && item.modelID === openai.modelID))
changed.resolve()
})
try {
await second.setFavorite(openai, true)
await Promise.race([
changed.promise,
Bun.sleep(2_000).then(() => {
throw new Error("timed out waiting for model preference update")
}),
])
} finally {
unsubscribe()
}
})
+16 -16
View File
@@ -192,22 +192,22 @@ Its read and action methods use the same inputs and responses as the client. It
adds plugin-only methods for transforms, runtime hooks, reloads, registrations,
and plugin options.
| Capability | Available operations |
| ---------------------- | -------------------------------------------------------------------------------------------- |
| `ctx.agent` | `list`, `get`, `transform`, `reload` |
| `ctx.catalog.provider` | `list`, `get` |
| `ctx.catalog.model` | `list`, `get`, `default` |
| `ctx.catalog` | `transform`, `reload` |
| `ctx.command` | `list`, `transform`, `reload` |
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
| `ctx.plugin` | `list` currently active plugin IDs |
| `ctx.reference` | `list`, `transform`, `reload` |
| `ctx.session` | `create`, `get`, `prompt`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
| `ctx.skill` | `list`, `transform`, `reload` |
| `ctx.tool` | `transform` and `hook` |
| `ctx.aisdk` | `hook` |
| `ctx.event` | `subscribe` to the current public server event stream |
| `ctx.options` | Readonly options from the matching config object |
| Capability | Available operations |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx.agent` | `list`, `get`, `transform`, `reload` |
| `ctx.catalog.provider` | `list`, `get` |
| `ctx.catalog.model` | `list`, `get`, `default` |
| `ctx.catalog` | `transform`, `reload` |
| `ctx.command` | `list`, `transform`, `reload` |
| `ctx.integration` | `list`, `get`, `connect`, `attempt`, `transform`, `reload`, and connection lookup/resolution |
| `ctx.plugin` | `list` currently active plugin IDs |
| `ctx.reference` | `list`, `transform`, `reload` |
| `ctx.session` | `list`, `children`, `messages`, `create`, `get`, `prompt`, `generate`, `command`, `rename`, `synthetic`, `interrupt`, `wait`, and `hook` |
| `ctx.skill` | `list`, `transform`, `reload` |
| `ctx.tool` | `transform` and `hook` |
| `ctx.aisdk` | `hook` |
| `ctx.event` | `subscribe` to the current public server event stream |
| `ctx.options` | Readonly options from the matching config object |
### Transform hooks