From b6a2912bb1105e0853e5855b20b3ef410a07df2e Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 7 Jul 2026 09:21:19 -0400 Subject: [PATCH 01/34] refactor(simulation): type RPC request payloads (#35734) --- packages/simulation/src/backend/control.ts | 20 ++++++------ packages/simulation/src/frontend/actions.ts | 2 +- packages/simulation/src/frontend/renderer.ts | 2 +- packages/simulation/src/frontend/server.ts | 24 +++++--------- .../simulation/src/frontend/simulation.ts | 9 +++--- packages/simulation/src/protocol/index.ts | 31 ++++++++++++++++--- 6 files changed, 48 insertions(+), 40 deletions(-) diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts index e52c8296f6..a27a0cd2af 100644 --- a/packages/simulation/src/backend/control.ts +++ b/packages/simulation/src/backend/control.ts @@ -25,10 +25,10 @@ import { SimulationNetwork } from "./network" type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> function parseRequest(input: string | Buffer) { - return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) + return SimulationProtocol.Backend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc.Request): Promise { +async function handle(socket: ControlSocket, request: SimulationProtocol.Backend.Request): Promise { switch (request.method) { case "llm.attach": { socket.data.unsubscribe?.() @@ -38,23 +38,22 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc return { attached: true } } case "llm.chunk": { - const params = await SimulationProtocol.Backend.decodeChunkParams(request.params) await Effect.runPromise( SimulationLLMExchange.push( - params.id, - params.items.map((item) => ({ type: "item", item }) as const), + request.params.id, + request.params.items.map((item) => ({ type: "item", item }) as const), ), ) return { ok: true } } case "llm.finish": { - const params = await SimulationProtocol.Backend.decodeFinishParams(request.params) - await Effect.runPromise(SimulationLLMExchange.push(params.id, [{ type: "finish", reason: params.reason }])) + await Effect.runPromise( + SimulationLLMExchange.push(request.params.id, [{ type: "finish", reason: request.params.reason }]), + ) return { ok: true } } case "llm.disconnect": { - const params = await SimulationProtocol.Backend.decodeDisconnectParams(request.params) - await Effect.runPromise(SimulationLLMExchange.disconnect(params.id)) + await Effect.runPromise(SimulationLLMExchange.disconnect(request.params.id)) return { ok: true } } case "llm.pending": @@ -62,7 +61,6 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.JsonRpc case "network.log": return { entries: SimulationNetwork.log() } } - throw new Error(`Unknown simulation control method: ${request.method}`) } export function start(endpoint: string) { @@ -79,7 +77,7 @@ export function start(endpoint: string) { socket.data.unsubscribe?.() }, async message(socket, message) { - let request: SimulationProtocol.JsonRpc.Request | undefined + let request: SimulationProtocol.Backend.Request | undefined try { request = parseRequest(message) const result = await handle(socket, request) diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index d03e4a5f75..9e5b7bdead 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -47,7 +47,7 @@ function hit(renderer: CliRenderer, renderable: Renderable) { /** * Builds the harness the simulation server drives. * - * When the renderer is the fake simulation renderer, its TestRendererSetup + * When the renderer is the headless simulation renderer, its TestRendererSetup * provides the supported testing APIs. For the visible terminal renderer the * harness falls back to `requestRender` + `idle` and reading the private * `currentRenderBuffer`. diff --git a/packages/simulation/src/frontend/renderer.ts b/packages/simulation/src/frontend/renderer.ts index 168a8999b6..e973d57a98 100644 --- a/packages/simulation/src/frontend/renderer.ts +++ b/packages/simulation/src/frontend/renderer.ts @@ -4,7 +4,7 @@ import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testin const setups = new WeakMap() /** - * Creates the fake simulation renderer: a real CliRenderer backed by an + * Creates the headless simulation renderer: a real CliRenderer backed by an * in-memory screen buffer instead of a terminal. The TestRendererSetup is * kept module-side (keyed by renderer) so the harness can use the supported * testing APIs without app code carrying it around. diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index 854b5e9846..c717dbd4c6 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -7,29 +7,20 @@ export interface Server { readonly stop: () => void } -function actionParam(params: unknown) { - return SimulationProtocol.Frontend.decodeActionParams(params).action -} - function parseRequest(input: string | Buffer) { - return SimulationProtocol.JsonRpc.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) + return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Request) { +async function handle(harness: Harness, request: SimulationProtocol.Frontend.Request, headless: boolean) { switch (request.method) { case "ui.state": { + if (headless) await harness.renderOnce() const result = SimulationActions.state(harness) SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length }) return result } case "ui.action": - return SimulationActions.execute(harness, actionParam(request.params)) - case "ui.render": { - await harness.renderOnce() - const result = SimulationActions.state(harness) - SimulationTrace.add("ui.render", { elements: result.elements.length, actions: result.actions.length }) - return result - } + return SimulationActions.execute(harness, request.params.action) case "trace.list": return { records: SimulationTrace.list() } case "trace.clear": @@ -38,10 +29,9 @@ async function handle(harness: Harness, request: SimulationProtocol.JsonRpc.Requ case "trace.export": return SimulationTrace.exportTrace() } - throw new Error(`Unknown simulation method: ${request.method}`) } -export function start(harness: Harness, endpoint: string): Server { +export function start(harness: Harness, endpoint: string, headless: boolean): Server { const url = new URL(endpoint) const server = Bun.serve<{ readonly drive: true }>({ hostname: url.hostname, @@ -58,10 +48,10 @@ export function start(harness: Harness, endpoint: string): Server { SimulationTrace.add("control.disconnect") }, async message(socket, message) { - let request: SimulationProtocol.JsonRpc.Request | undefined + let request: SimulationProtocol.Frontend.Request | undefined try { request = parseRequest(message) - const result = await handle(harness, request) + const result = await handle(harness, request, headless) const next = SimulationProtocol.JsonRpc.success(request.id, result) if (next) socket.send(JSON.stringify(next)) } catch (error) { diff --git a/packages/simulation/src/frontend/simulation.ts b/packages/simulation/src/frontend/simulation.ts index 96e68e8dca..c2a56ad8c1 100644 --- a/packages/simulation/src/frontend/simulation.ts +++ b/packages/simulation/src/frontend/simulation.ts @@ -7,19 +7,18 @@ import { SimulationServer } from "./server" /** * Drive-mode renderer entry point. * - * Creates the renderer (fake when OPENCODE_DRIVE_RENDERER=fake, the normal + * Creates the renderer (headless when OPENCODE_DRIVE_RENDERER=headless, the normal * visible renderer otherwise) and starts the UI control * server against it. The server stops when the renderer is destroyed, so the * caller only manages the renderer lifecycle. */ export async function create(options: CliRendererConfig): Promise { - const renderer = - process.env.OPENCODE_DRIVE_RENDERER === "fake" - ? await SimulationRenderer.create(options) - : await createCliRenderer(options) + const headless = process.env.OPENCODE_DRIVE_RENDERER === "headless" + const renderer = headless ? await SimulationRenderer.create(options) : await createCliRenderer(options) const server = SimulationServer.start( SimulationActions.createHarness(renderer), DriveManifest.resolve().endpoints.ui, + headless, ) process.stderr.write(`opencode drive ui websocket: ${server.url}\n`) renderer.once("destroy", () => server.stop()) diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index b05f24d501..2534db8367 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -4,9 +4,12 @@ const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null]) type Json = Schema.Schema.Type export namespace JsonRpc { - export const Request = Schema.Struct({ + export const RequestFields = { jsonrpc: Schema.Literal("2.0"), id: Schema.optional(JsonRpcID), + } + export const Request = Schema.Struct({ + ...RequestFields, method: Schema.String, params: Schema.optional(Schema.Json), }) @@ -92,7 +95,16 @@ export namespace Frontend { export const ActionParams = Schema.Struct({ action: Action }) export interface ActionParams extends Schema.Schema.Type {} - export const decodeActionParams = Schema.decodeUnknownSync(ActionParams) + + export const Request = Schema.Union([ + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.action"), params: ActionParams }), + Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literals(["ui.state", "trace.list", "trace.clear", "trace.export"]), + }), + ]) + export type Request = Schema.Schema.Type + export const decodeRequest = Schema.decodeUnknownSync(Request) export const TraceRecord = Schema.Struct({ id: Schema.Number, @@ -130,6 +142,18 @@ export namespace Backend { export const DisconnectParams = Schema.Struct({ id: Schema.String }) export interface DisconnectParams extends Schema.Schema.Type {} + export const Request = Schema.Union([ + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }), + Schema.Struct({ + ...JsonRpc.RequestFields, + method: Schema.Literals(["llm.attach", "llm.pending", "network.log"]), + }), + ]) + export type Request = Schema.Schema.Type + export const decodeRequest = Schema.decodeUnknownSync(Request) + export const OpenedExchange = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json }) export interface OpenedExchange extends Schema.Schema.Type {} @@ -141,9 +165,6 @@ export namespace Backend { }) export interface NetworkLogEntry extends Schema.Schema.Type {} - export const decodeChunkParams = Schema.decodeUnknownPromise(ChunkParams) - export const decodeFinishParams = Schema.decodeUnknownPromise(FinishParams) - export const decodeDisconnectParams = Schema.decodeUnknownPromise(DisconnectParams) } export * as SimulationProtocol from "./index" From e3a39b214f366c1e4957809699d3438c2158f28e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 09:49:17 -0400 Subject: [PATCH 02/34] fix(core): lower failed reasoning to text (#35735) --- packages/core/src/session/runner/to-llm-message.ts | 4 ++-- packages/core/test/session-runner-message.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index f4e1d7ca01..23de369102 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -120,12 +120,12 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") - return sameModel + return reuseProviderMetadata ? [ { type: "reasoning", text: item.text, - providerMetadata: reuseProviderMetadata ? providerMetadata(model.providerID, item.state) : undefined, + providerMetadata: providerMetadata(model.providerID, item.state), }, ] : item.text.length > 0 diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index b34d0e9918..fb79b35d11 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -461,7 +461,7 @@ Recent work ]) }) - test("drops provider-native continuation metadata from failed assistant turns", () => { + test("lowers failed assistant reasoning to text", () => { const messages = toLLMMessages( [ SessionMessage.Assistant.make({ @@ -501,7 +501,7 @@ Recent work ) expect(messages[0]?.content).toEqual([ - { type: "reasoning", text: "Partial thought", providerMetadata: undefined }, + { type: "text", text: "Partial thought" }, { type: "tool-call", id: "hosted-failed", From f488089179f4584b4f1b97eca19da585b557fefe Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:54:31 -0500 Subject: [PATCH 03/34] fix: preserve compaction files and classify Z.AI overflow (#35747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> --- packages/core/src/session/compaction.ts | 15 +++++++++++---- packages/core/test/session-compaction.test.ts | 16 +++++++++++++--- packages/llm/src/provider-error.ts | 1 + packages/llm/test/provider-error.test.ts | 8 ++++++++ .../opencode/test/session/message-v2.test.ts | 1 + 5 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 packages/llm/test/provider-error.test.ts diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index e90b6455dc..8b975e3298 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -25,20 +25,27 @@ const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside Rules: - Keep every section, even when empty. - Use terse bullets, not prose paragraphs. - Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known. -- Put relevant files and symbols inside the section where they matter; do not add extra sections. - Do not mention the summary process or that context was compacted.` type Settings = { diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 1cfd353f54..d4d96abbc4 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -52,6 +52,15 @@ const it = testEffect( ), ) +test("compaction prompt preserves detailed work state and relevant files", () => { + const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] }) + + expect(prompt).toContain("## Work State\n### Completed") + expect(prompt).toContain("### Active") + expect(prompt).toContain("### Blocked") + expect(prompt).toContain("## Relevant Files") +}) + test("compaction describes tool media without embedding base64", () => { const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" const serialized = SessionCompaction.serializeToolContent([ @@ -74,13 +83,14 @@ test("compaction prompt requires the checkpoint headings in order", () => { "## Objective", "## Important Details", "## Work State", + "### Completed", + "### Active", + "### Blocked", "## Next Move", + "## Relevant Files", ]) expect(prompt).toContain("one or two brief sentences") expect(prompt).toContain("constraints/preferences, decisions and why") - expect(prompt).toContain("Completed:") - expect(prompt).toContain("Active:") - expect(prompt).toContain("Blocked:") expect(prompt).toContain("immediate concrete action") expect(prompt).toContain("next action if known") expect(prompt).toContain("Keep every section, even when empty.") diff --git a/packages/llm/src/provider-error.ts b/packages/llm/src/provider-error.ts index 8d3f2a8f63..321bd7927e 100644 --- a/packages/llm/src/provider-error.ts +++ b/packages/llm/src/provider-error.ts @@ -6,6 +6,7 @@ const patterns = [ /input is too long for requested model/i, /exceeds the context window/i, /input token count.*exceeds the maximum/i, + /tokens in request more than max tokens allowed/i, /maximum prompt length is \d+/i, /reduce the length of the messages/i, /maximum context length is \d+ tokens/i, diff --git a/packages/llm/test/provider-error.test.ts b/packages/llm/test/provider-error.test.ts new file mode 100644 index 0000000000..3622c89454 --- /dev/null +++ b/packages/llm/test/provider-error.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from "bun:test" +import { isContextOverflow } from "../src" + +describe("provider error classification", () => { + test("classifies Z.AI GLM token limit messages as context overflow", () => { + expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true) + }) +}) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1de84c9dd9..9bb688aedd 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1448,6 +1448,7 @@ describe("session.message-v2.fromError", () => { "prompt is too long: 213462 tokens > 200000 maximum", "Your input exceeds the context window of this model", "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)", + "tokens in request more than max tokens allowed", "Please reduce the length of the messages or completion", "400 status code (no body)", "413 status code (no body)", From 8deb0e5780dc83160a9ec12e6476e36267f82948 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 11:43:40 -0400 Subject: [PATCH 04/34] fix(core): preserve SDK plugins across location eviction (#35725) --- packages/client/src/effect/api/api.ts | 6 +++ .../client/src/effect/generated/client.ts | 10 +++- .../client/src/promise/generated/client.ts | 14 ++++++ .../client/src/promise/generated/types.ts | 8 ++++ packages/core/src/plugin/sdk.ts | 48 +++++++------------ packages/core/test/location-layer.test.ts | 36 ++++++++++++++ packages/protocol/src/client.ts | 1 + packages/protocol/src/groups/debug.ts | 17 ++++++- packages/sdk-next/src/opencode.ts | 44 ++++------------- packages/sdk-next/test/embedded.test.ts | 44 +++++++++++++++++ packages/server/src/handlers/debug.ts | 25 +++++++--- packages/server/src/process.ts | 13 ++--- packages/server/src/routes.ts | 48 ++++++++++--------- 13 files changed, 209 insertions(+), 105 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 588d67cad6..59202fe0ef 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -890,8 +890,14 @@ export interface VcsApi { export type Endpoint25_0Output = EffectValue> export type DebugLocationOperation = () => Effect.Effect +type Endpoint25_1Request = Parameters[0] +export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } +export type Endpoint25_1Output = EffectValue> +export type DebugEvictLocationOperation = (input?: Endpoint25_1Input) => Effect.Effect + export interface DebugApi { readonly location: DebugLocationOperation + readonly evictLocation: DebugEvictLocationOperation } export interface AppApi { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 945bd0ffc0..94de0970f3 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -1069,7 +1069,15 @@ const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint24_0(r const Endpoint25_0 = (raw: RawClient["server.debug"]) => () => raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)) -const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ location: Endpoint25_0(raw) }) +type Endpoint25_1Request = Parameters[0] +type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } +const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) => + raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ + location: Endpoint25_0(raw), + evictLocation: Endpoint25_1(raw), +}) const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 697d54d732..4c57621d48 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -178,6 +178,8 @@ import type { VcsDiffInput, VcsDiffOutput, DebugLocationOutput, + DebugEvictLocationInput, + DebugEvictLocationOutput, } from "./types" import { ClientError } from "./client-error" @@ -1494,6 +1496,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + evictLocation: (input?: DebugEvictLocationInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/debug/location`, + query: { location: input?.["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 7482d23725..aaee9778b3 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -6130,3 +6130,11 @@ export type VcsDiffOutput = { } export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> + +export type DebugEvictLocationInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type DebugEvictLocationOutput = void diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index f6ceb06a8f..da14b4a61f 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -7,14 +7,6 @@ import { EventV2 } from "../event" export const Updated = EventV2.ephemeral({ type: "sdk.plugin.updated", schema: {} }) -export interface Store { - readonly plugins: Map -} - -export const makeStore = (): Store => ({ plugins: new Map() }) - -const defaultStore = makeStore() - /** * Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes, * so `PluginSupervisor` can add them on every Location boot through the ordinary @@ -22,10 +14,9 @@ const defaultStore = makeStore() * config. Registration publishes an unlocated update so every booted Location * reloads its plugin generation from the shared store. * - * The store is shared explicitly between the SDK construction graph and the - * embedded route graph because `LocationServiceMap` builds Location layers lazily - * in a nested graph. Each embedded SDK creates its own store, so instances do not - * see each other's contributions. + * Each host-global layer owns one private store. Location graphs reuse that + * layer through Effect's memoization, so separate hosts remain isolated while + * every Location in one host sees the same registrations. */ export interface Interface { readonly register: (plugin: Plugin) => Effect.Effect @@ -34,26 +25,19 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SdkPlugins") {} -export const layerWithStore = (store: Store) => - Layer.effect( - Service, - Effect.gen(function* () { - const events = yield* EventV2.Service - yield* Effect.addFinalizer(() => +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const plugins = new Map() + return Service.of({ + register: (plugin) => Effect.sync(() => { - store.plugins.clear() - }), - ) - return Service.of({ - register: (plugin) => - Effect.sync(() => { - store.plugins.set(plugin.id, plugin) - }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), - all: () => [...store.plugins.values()], - }) - }), - ) - -export const layer = layerWithStore(defaultStore) + plugins.set(plugin.id, plugin) + }).pipe(Effect.andThen(events.publish(Updated, {})), Effect.asVoid), + all: () => [...plugins.values()], + }) + }), +) export const node = makeGlobalNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index f945cb2265..e9eb2be4d9 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -12,6 +12,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" +import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { ModelV2 } from "@opencode-ai/core/model" import { ProjectV2 } from "@opencode-ai/core/project" @@ -28,8 +29,43 @@ import { Reference } from "../src/reference" import { ToolRegistry } from "../src/tool/registry" const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node]))) +const itWithSdk = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SdkPlugins.node, LocationServiceMap.node])), +) describe("LocationServiceMap", () => { + itWithSdk.live("preserves embedded SDK plugins after Location eviction", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.gen(function* () { + const sdk = yield* SdkPlugins.Service + const locations = yield* LocationServiceMap.Service + const id = AgentV2.ID.make("persistent-sdk-agent") + const plugin = define({ + id: "persistent-sdk-plugin", + effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})), + }) + yield* sdk.register(plugin) + + const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const read = Effect.gen(function* () { + const supervisor = yield* PluginSupervisor.Service + yield* supervisor.ready + const agents = yield* AgentV2.Service + return yield* agents.get(id) + }) + + expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined() + yield* locations.invalidate(ref) + expect(yield* read.pipe(Effect.scoped, Effect.provide(locations.get(ref)))).toBeDefined() + }), + ), + ), + ) + it.live("applies ordered plugin config operations during boot", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 1abf666796..652beb6595 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -61,6 +61,7 @@ export const groupNames = { } as const export const endpointNames = { + "debug.location.evict": "evictLocation", "session.messages": "list", "integration.connect.key": "connectKey", "integration.connect.oauth": "connectOauth", diff --git a/packages/protocol/src/groups/debug.ts b/packages/protocol/src/groups/debug.ts index b43e5bc639..a405044105 100644 --- a/packages/protocol/src/groups/debug.ts +++ b/packages/protocol/src/groups/debug.ts @@ -1,6 +1,7 @@ import { Location } from "@opencode-ai/schema/location" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location.js" export const DebugGroup = HttpApiGroup.make("server.debug") .add( @@ -14,4 +15,18 @@ export const DebugGroup = HttpApiGroup.make("server.debug") }), ), ) + .add( + HttpApiEndpoint.delete("debug.location.evict", "/api/debug/location", { + query: LocationQuery, + success: HttpApiSchema.NoContent, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.debug.location.evict", + summary: "Evict a loaded location", + description: "Dispose the requested location's cached services so its next use boots them fresh.", + }), + ), + ) .annotateMerge(OpenApi.annotations({ title: "debug" })) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index bcb06f5319..8ebe230791 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -1,47 +1,23 @@ import { OpenCode } from "@opencode-ai/client/effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { EventV2 } from "@opencode-ai/core/event" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" -import { Project } from "@opencode-ai/core/project" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" -import { Context, Effect, Layer, Scope } from "effect" -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" +import { Context, Effect, Layer, ManagedRuntime } from "effect" +import { FetchHttpClient, HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" export const create = Effect.fn("OpenCode.create")(function* () { - const scope = yield* Scope.Scope - const memoMap = yield* Layer.makeMemoMap - const sdkPlugins = SdkPlugins.makeStore() - const context = yield* Layer.buildWithMemoMap( - AppNodeBuilder.build(LayerNode.group([EventV2.node, PermissionSaved.node, Project.node, SdkPlugins.node]), [ - [SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)], - ]), - memoMap, - scope, + const runtime = yield* Effect.acquireRelease( + Effect.sync(() => ManagedRuntime.make(createEmbeddedRoutes().pipe(Layer.provide(HttpServer.layerServices)))), + (runtime) => runtime.disposeEffect, ) + const context = yield* runtime.contextEffect const plugins = Context.get(context, SdkPlugins.Service) - const permissions = Context.get(context, PermissionSaved.Service) - const project = Context.get(context, Project.Service) - const web = yield* Effect.acquireRelease( - Effect.sync(() => - HttpRouter.toWebHandler( - createEmbeddedRoutes(sdkPlugins).pipe( - HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), - HttpRouter.provideRequest(Layer.succeed(Project.Service, project)), - Layer.provide(HttpServer.layerServices), - ), - { disableLogger: true, memoMap }, - ), - ), - (web) => Effect.promise(web.dispose), - ) - const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => web.handler(new Request(input, init)), { + const router = Context.get(context, HttpRouter.HttpRouter) + const handler = HttpEffect.toWebHandler(router.asHttpEffect()) + const fetch = Object.assign((input: RequestInfo | URL, init?: RequestInit) => handler(new Request(input, init)), { preconnect: () => undefined, }) satisfies typeof globalThis.fetch const client = yield* OpenCode.make({ baseUrl: "http://opencode.local" }).pipe( - Effect.provide(FetchHttpClient.layer), - Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.provide(FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch)), Layer.fresh)), ) return { ...client, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index b0efa62909..0b3b639087 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -105,6 +105,50 @@ it.live( 25_000, ) +it.live( + "preserves SDK plugins across Location eviction", + () => + withEmbedded("opencode-embedded-plugin-eviction-", (fixture) => + Effect.gen(function* () { + const opencode = yield* fixture.sdk.OpenCode.create() + const ref = location(fixture) + const connected = yield* Latch.make(false) + const booted = yield* Deferred.make() + // The rebooted Location commits its second plugin generation. + const recommitted = yield* Deferred.make() + const generations = yield* Ref.make(0) + const id = `evicted-sdk-${crypto.randomUUID()}` + + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => { + if (event.type === "server.connected") return connected.open + if (event.type !== "plugin.updated" || event.location?.directory !== fixture.directory) return Effect.void + return Ref.updateAndGet(generations, (total) => total + 1).pipe( + Effect.flatMap((total) => { + if (total === 1) return Deferred.succeed(booted, undefined) + if (total === 2) return Deferred.succeed(recommitted, undefined) + return Effect.void + }), + Effect.asVoid, + ) + }), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.plugin({ id, effect: () => Effect.void }) + + yield* opencode.plugin.list({ location: ref }) + yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds")) + yield* opencode.debug.evictLocation({ location: ref }) + yield* opencode.plugin.list({ location: ref }) + yield* Deferred.await(recommitted).pipe(Effect.timeout("5 seconds")) + + expect((yield* opencode.plugin.list({ location: ref })).data.map((plugin) => String(plugin.id))).toContain(id) + }), + ), + 15_000, +) + it.live( "keeps SDK plugin registration isolated between embedded hosts", () => diff --git a/packages/server/src/handlers/debug.ts b/packages/server/src/handlers/debug.ts index f5ffd536e4..ec797c0032 100644 --- a/packages/server/src/handlers/debug.ts +++ b/packages/server/src/handlers/debug.ts @@ -2,13 +2,24 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { Effect, Option, RcMap } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Api } from "../api" +import { requestRef } from "../location" export const DebugHandler = HttpApiBuilder.group(Api, "server.debug", (handlers) => - handlers.handle( - "debug.location", - Effect.fn(function* () { - const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) - return Array.from(yield* RcMap.keys(locations.rcMap)) - }), - ), + handlers + .handle( + "debug.location", + Effect.fn(function* () { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + return Array.from(yield* RcMap.keys(locations.rcMap)) + }), + ) + .handle( + "debug.location.evict", + Effect.fn(function* (ctx) { + const locations = Option.getOrThrow(yield* Effect.serviceOption(LocationServiceMap.Service)) + // Resolve through requestRef so the key matches the shape the location + // middleware cached the services under. + yield* locations.invalidate(requestRef(ctx.request)) + }), + ), ) diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index de6f4b328f..971d77734b 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,14 +1,9 @@ export * as ServerProcess from "./process" import { NodeHttpClient, NodeHttpServer } from "@effect/platform-node" -import { Credential } from "@opencode-ai/core/credential" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { Project } from "@opencode-ai/core/project" import { HealthGroup } from "@opencode-ai/protocol/groups/health" import { Context, Effect, Layer, Option } from "effect" -import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" import { createServer } from "node:http" import { ServerAuth } from "./auth" @@ -53,9 +48,11 @@ function listen(options: Options) { function bind(hostname: string, port: number, password: string) { const server = createServer() return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( + createRoutes(password).pipe( + Layer.flatMap((context) => + HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger), + ), Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), - Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), ), ).pipe( Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 1c5a57f62f..e4d1ae4930 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -19,7 +19,7 @@ import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" -import { Effect, Layer, Option } from "effect" +import { Context, Effect, Layer, Option } from "effect" import { Api } from "./api" import { ServerAuth } from "./auth" import { handlers } from "./handlers" @@ -40,6 +40,7 @@ const applicationServices = LayerNode.group([ Project.node, SessionV2.node, PluginRuntime.providerNode, + SdkPlugins.node, PermissionSaved.node, PtyTicket.node, Credential.node, @@ -55,26 +56,22 @@ export function createRoutes(password?: string) { ) } -export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) { - return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins) +export function createEmbeddedRoutes() { + return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() })) } -function makeRoutes( - auth: Layer.Layer, - sdkPlugins?: SdkPlugins.Store, -) { +function makeRoutes(auth: Layer.Layer) { const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ [SessionExecution.node, SessionExecutionLocal.node], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], - ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), ] const serviceLayer = simulateEnabled() ? Layer.unwrap( Effect.gen(function* () { - const { simulationReplacements, startDriveServer } = yield* Effect.promise(() => - import("@opencode-ai/simulation/backend"), + const { simulationReplacements, startDriveServer } = yield* Effect.promise( + () => import("@opencode-ai/simulation/backend"), ) if (driveEnabled()) startDriveServer() return AppNodeBuilder.build(applicationServices, [ @@ -85,16 +82,24 @@ function makeRoutes( ) : AppNodeBuilder.build(applicationServices, replacements) - return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( - Layer.provide(handlers.pipe(Layer.provide(serviceLayer))), - Layer.provide(formLocationLayer), - Layer.provide(sessionLocationLayer), - Layer.provide(layer), - Layer.provide(authorizationLayer), - Layer.provide(schemaErrorLayer), - Layer.provide(auth), - Layer.provide(serviceLayer), - Layer.provide(Observability.layer), + return serviceLayer.pipe( + Layer.flatMap((context) => { + const services = Layer.succeedContext(context) + const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)) + return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( + Layer.provide(handlers.pipe(Layer.provide(services))), + Layer.provide(formLocationLayer), + Layer.provide(sessionLocationLayer), + Layer.provide(layer), + Layer.provide(authorizationLayer), + Layer.provide(schemaErrorLayer), + Layer.provide(auth), + Layer.provide(Observability.layer), + HttpRouter.provideRequest(requestServices), + Layer.provideMerge(services), + Layer.provideMerge(HttpRouter.layer), + ) + }), ) } @@ -108,5 +113,4 @@ function driveEnabled() { export const routes = createRoutes() -export const webHandler = () => - HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) +export const webHandler = () => HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))) From dd1c007877e30a777b8939fd2f54665a0c1857aa Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 7 Jul 2026 00:13:29 -0400 Subject: [PATCH 05/34] docs --- bun.lock | 96 ++++++++++++++++++++++++++++-- packages/cli/bin/opencode2.cjs | 0 packages/cli/src/server-process.ts | 9 --- packages/docs/docs.json | 20 ++----- 4 files changed, 97 insertions(+), 28 deletions(-) mode change 100644 => 100755 packages/cli/bin/opencode2.cjs diff --git a/bun.lock b/bun.lock index 4e7e1f72e0..0102b13a6b 100644 --- a/bun.lock +++ b/bun.lock @@ -1611,11 +1611,11 @@ "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], - "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@emotion/is-prop-valid": ["@emotion/is-prop-valid@0.8.8", "", { "dependencies": { "@emotion/memoize": "0.7.4" } }, "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA=="], @@ -2277,7 +2277,7 @@ "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.127.0", "", { "os": "win32", "cpu": "x64" }, "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w=="], - "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "@oxc-project/types": ["@oxc-project/types@0.138.0", "", {}, "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.20.0", "", { "os": "android", "cpu": "arm" }, "sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg=="], @@ -2569,7 +2569,37 @@ "@remix-run/router": ["@remix-run/router@1.9.0", "", {}, "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.4", "", { "os": "linux", "cpu": "arm" }, "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.4", "", { "os": "none", "cpu": "arm64" }, "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA=="], + + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.4", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], @@ -3111,6 +3141,8 @@ "@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], @@ -3183,6 +3215,12 @@ "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], + "@vercel/cli-config": ["@vercel/cli-config@0.2.0", "", { "dependencies": { "xdg-app-paths": "5", "zod": "4.1.11" } }, "sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ=="], + + "@vercel/cli-exec": ["@vercel/cli-exec@1.0.0", "", { "dependencies": { "execa": "5.1.1" } }, "sha512-kQF8LGie/Hbdq9/psJxLE7owRTcqMQMhgybU04gCeR7cbQAr5t8OrjefDNColJv1QSSucFt4pLwRiARVmlOnug=="], + + "@vercel/functions": ["@vercel/functions@3.7.5", "", { "dependencies": { "@vercel/oidc": "3.8.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*", "ws": ">=8" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity", "ws"] }, "sha512-ESf8BbeDebqRUyMi09JwRbQqpLn4g6fjcVVHPsHB56j2dSqRrSHO4h3X4aaxJf6iQQjzhAtDGI2xCWQ27JE8PA=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], @@ -4987,6 +5025,8 @@ "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], + "os-paths": ["os-paths@4.4.0", "", {}, "sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg=="], + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "oxc-minify": ["oxc-minify@0.96.0", "", { "optionalDependencies": { "@oxc-minify/binding-android-arm64": "0.96.0", "@oxc-minify/binding-darwin-arm64": "0.96.0", "@oxc-minify/binding-darwin-x64": "0.96.0", "@oxc-minify/binding-freebsd-x64": "0.96.0", "@oxc-minify/binding-linux-arm-gnueabihf": "0.96.0", "@oxc-minify/binding-linux-arm-musleabihf": "0.96.0", "@oxc-minify/binding-linux-arm64-gnu": "0.96.0", "@oxc-minify/binding-linux-arm64-musl": "0.96.0", "@oxc-minify/binding-linux-riscv64-gnu": "0.96.0", "@oxc-minify/binding-linux-s390x-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-gnu": "0.96.0", "@oxc-minify/binding-linux-x64-musl": "0.96.0", "@oxc-minify/binding-wasm32-wasi": "0.96.0", "@oxc-minify/binding-win32-arm64-msvc": "0.96.0", "@oxc-minify/binding-win32-x64-msvc": "0.96.0" } }, "sha512-dXeeGrfPJJ4rMdw+NrqiCRtbzVX2ogq//R0Xns08zql2HjV3Zi2SBJ65saqfDaJzd2bcHqvGWH+M44EQCHPAcA=="], @@ -5401,6 +5441,8 @@ "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + "rolldown": ["rolldown@1.1.4", "", { "dependencies": { "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.4", "@rolldown/binding-darwin-arm64": "1.1.4", "@rolldown/binding-darwin-x64": "1.1.4", "@rolldown/binding-freebsd-x64": "1.1.4", "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", "@rolldown/binding-linux-arm64-gnu": "1.1.4", "@rolldown/binding-linux-arm64-musl": "1.1.4", "@rolldown/binding-linux-ppc64-gnu": "1.1.4", "@rolldown/binding-linux-s390x-gnu": "1.1.4", "@rolldown/binding-linux-x64-gnu": "1.1.4", "@rolldown/binding-linux-x64-musl": "1.1.4", "@rolldown/binding-openharmony-arm64": "1.1.4", "@rolldown/binding-wasm32-wasi": "1.1.4", "@rolldown/binding-win32-arm64-msvc": "1.1.4", "@rolldown/binding-win32-x64-msvc": "1.1.4" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA=="], + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], @@ -6021,8 +6063,12 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xdg-app-paths": ["xdg-app-paths@5.5.1", "", { "dependencies": { "os-paths": "^4.0.1", "xdg-portable": "^7.2.0" } }, "sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ=="], + "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="], + "xdg-portable": ["xdg-portable@7.3.0", "", { "dependencies": { "os-paths": "^4.0.1" } }, "sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw=="], + "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], @@ -6619,6 +6665,10 @@ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@oxc-parser/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -6639,6 +6689,8 @@ "@puppeteer/browsers/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], @@ -6747,6 +6799,14 @@ "@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], + "@vercel/cli-config/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], + + "@vercel/cli-exec/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "@vercel/functions/@vercel/oidc": ["@vercel/oidc@3.8.0", "", { "dependencies": { "@vercel/cli-config": "0.2.0", "@vercel/cli-exec": "1.0.0", "jose": "^5.9.6" } }, "sha512-r00laGW6Pv778RoR6M2NxX91ycSj+PBwVo+fOb9Bif+F0IyUKt25zrvBzfEzQpeAzbqOgPZyQibEWDdDFApd+A=="], + + "@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], "@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], @@ -7043,6 +7103,8 @@ "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="], + "p-any/p-cancelable": ["p-cancelable@3.0.0", "", {}, "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw=="], "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -7707,6 +7769,10 @@ "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@oxc-parser/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -7715,6 +7781,8 @@ "@puppeteer/browsers/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@sentry/bundler-plugin-core/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], "@sentry/bundler-plugin-core/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], @@ -7785,6 +7853,22 @@ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@vercel/cli-exec/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@vercel/cli-exec/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "@vercel/cli-exec/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "@vercel/cli-exec/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "@vercel/cli-exec/execa/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "@vercel/cli-exec/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@vercel/cli-exec/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "@vercel/functions/@vercel/oidc/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], @@ -8351,6 +8435,8 @@ "@stoplight/spectral-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@vercel/cli-exec/execa/onetime/mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/packages/cli/bin/opencode2.cjs b/packages/cli/bin/opencode2.cjs old mode 100644 new mode 100755 diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 48471a573a..2d5c2f8bfa 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -7,7 +7,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { AppProcess } from "@opencode-ai/core/process" -import { Flock } from "@opencode-ai/core/util/flock" import { start } from "@opencode-ai/server/process" import { randomBytes, randomUUID } from "node:crypto" import path from "node:path" @@ -37,14 +36,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { if (options.mode === "service") yield* Effect.sync(() => process.chdir(Global.Path.home)) return yield* Effect.scoped( Effect.gen(function* () { - if (options.mode === "service") { - const service = yield* ServiceConfig.options() - yield* Flock.effect(path.basename(service.file, ".json") + "-process", { - dir: path.dirname(service.file), - staleMs: 3_000, - timeoutMs: 15_000, - }) - } const environmentPassword = yield* Env.password // Keep the lease credential out of the environment inherited by tools. if (options.mode === "stdio") { diff --git a/packages/docs/docs.json b/packages/docs/docs.json index 68a975cf85..e8559d72f6 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -17,7 +17,12 @@ "tabs": [ { "tab": "Docs", - "pages": ["index", "config", "plugins", "troubleshooting"] + "groups": [ + { + "group": "Get started", + "pages": ["index", "config", "plugins", "troubleshooting"] + } + ] }, { "tab": "SDK", @@ -39,19 +44,6 @@ ], "global": {} }, - "navbar": { - "links": [ - { - "label": "Discord", - "href": "https://opencode.ai/discord" - } - ], - "primary": { - "type": "button", - "label": "GitHub", - "href": "https://github.com/anomalyco/opencode" - } - }, "contextual": { "options": ["copy", "view", "chatgpt", "claude", "mcp", "cursor", "vscode"] }, From 6c065ca6a590835be3a02cc25b33e3060212a099 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 7 Jul 2026 12:15:01 -0400 Subject: [PATCH 06/34] feat(simulation): render driven UI screenshots and videos (#35739) --- bun.lock | 25 +++++ packages/simulation/package.json | 1 + packages/simulation/src/backend/control.ts | 4 - packages/simulation/src/frontend/actions.ts | 103 ++++++++++++++------ packages/simulation/src/frontend/png.ts | 84 ++++++++++++++++ packages/simulation/src/frontend/server.ts | 94 +++++++++++++----- packages/simulation/src/frontend/trace.ts | 37 ------- packages/simulation/src/protocol/index.ts | 65 +++++++----- 8 files changed, 292 insertions(+), 121 deletions(-) create mode 100644 packages/simulation/src/frontend/png.ts delete mode 100644 packages/simulation/src/frontend/trace.ts diff --git a/bun.lock b/bun.lock index 0102b13a6b..491dbd1679 100644 --- a/bun.lock +++ b/bun.lock @@ -873,6 +873,7 @@ "name": "@opencode-ai/simulation", "version": "1.17.13", "dependencies": { + "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opentui/core": "catalog:", @@ -1983,6 +1984,30 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.2", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.2", "@napi-rs/canvas-darwin-arm64": "1.0.2", "@napi-rs/canvas-darwin-x64": "1.0.2", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", "@napi-rs/canvas-linux-arm64-musl": "1.0.2", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-gnu": "1.0.2", "@napi-rs/canvas-linux-x64-musl": "1.0.2", "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", "@napi-rs/canvas-win32-x64-msvc": "1.0.2" } }, "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 29613ff28a..a98b6f3289 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -16,6 +16,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { + "@napi-rs/canvas": "1.0.2", "@opencode-ai/core": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opentui/core": "catalog:", diff --git a/packages/simulation/src/backend/control.ts b/packages/simulation/src/backend/control.ts index a27a0cd2af..eaacc162c1 100644 --- a/packages/simulation/src/backend/control.ts +++ b/packages/simulation/src/backend/control.ts @@ -1,7 +1,6 @@ import { Effect } from "effect" import { SimulationProtocol } from "../protocol" import { SimulationLLMExchange } from "./llm-exchange" -import { SimulationNetwork } from "./network" /** * Backend-hosted simulation control WebSocket. @@ -19,7 +18,6 @@ import { SimulationNetwork } from "./network" * - `llm.finish` { id, reason? } finish an exchange * - `llm.disconnect` { id } abruptly terminate an exchange without a finish * - `llm.pending` list open exchanges - * - `network.log` simulated network request log */ type ControlSocket = Bun.ServerWebSocket<{ unsubscribe?: () => void }> @@ -58,8 +56,6 @@ async function handle(socket: ControlSocket, request: SimulationProtocol.Backend } case "llm.pending": return { exchanges: SimulationLLMExchange.pending() } - case "network.log": - return { entries: SimulationNetwork.log() } } } diff --git a/packages/simulation/src/frontend/actions.ts b/packages/simulation/src/frontend/actions.ts index 9e5b7bdead..4b03cb6939 100644 --- a/packages/simulation/src/frontend/actions.ts +++ b/packages/simulation/src/frontend/actions.ts @@ -1,8 +1,11 @@ -import type { CliRenderer, Renderable } from "@opentui/core" +import { mkdtemp } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import type { CapturedFrame, CliRenderer, Renderable } from "@opentui/core" import { createMockKeys, createMockMouse, type MockInput, type MockMouse } from "@opentui/core/testing" import type { SimulationProtocol } from "../protocol" import { SimulationRenderer } from "./renderer" -import { SimulationTrace } from "./trace" +import { SimulationPng } from "./png" export type Action = SimulationProtocol.Frontend.Action export type Element = SimulationProtocol.Frontend.Element @@ -91,57 +94,95 @@ export function elements(renderer: CliRenderer): Element[] { .filter((element) => element.focusable || element.clickable || element.editor) } -export function actions(renderer: CliRenderer, options: { text?: string } = {}): Action[] { - const items = elements(renderer) - return [ - ...(renderer.currentFocusedEditor - ? ([{ type: "typeText", text: options.text ?? "hello" }, { type: "pressEnter" }] satisfies Action[]) - : []), - ...items.filter((item) => item.focusable && !item.focused).map((item) => ({ type: "focus" as const, target: item.num })), - ...items - .filter((item) => item.clickable) - .map((item) => ({ - type: "click" as const, - target: item.num, - x: Math.floor(item.x + item.width / 2), - y: Math.floor(item.y + item.height / 2), - })), - { type: "pressArrow", direction: "down" }, - { type: "pressArrow", direction: "up" }, - ] -} - export function state(harness: Harness) { return { - screen: harness.screen(), focused: { renderable: harness.renderer.currentFocusedRenderable?.num, editor: Boolean(harness.renderer.currentFocusedEditor), }, elements: elements(harness.renderer), - actions: actions(harness.renderer), } } +export async function screenshot(harness: Harness) { + await harness.renderOnce() + const image = SimulationPng.screenshot(harness.renderer) + const path = join(await mkdtemp(join(tmpdir(), "opencode-drive-")), "screenshot.png") + await Bun.write(path, image.data) + return path +} + +export function frame(harness: Harness): CapturedFrame { + const buffer = harness.renderer.currentRenderBuffer + return { + cols: buffer.width, + rows: buffer.height, + cursor: [0, 0], + lines: buffer.getSpanLines().map((line) => ({ + spans: line.spans.map((span) => ({ + text: span.text, + fg: span.fg, + bg: span.bg, + attributes: span.attributes, + width: span.width, + })), + })), + } +} + +export async function video(frames: CapturedFrame[]) { + const directory = await mkdtemp(join(tmpdir(), "opencode-drive-recording-")) + await Promise.all( + frames.map((frame, index) => + Bun.write( + join(directory, `frame-${index.toString().padStart(6, "0")}.png`), + SimulationPng.screenshotFrame(frame).data, + ), + ), + ) + const path = join(directory, "recording.mp4") + const process = Bun.spawn( + [ + "ffmpeg", + "-loglevel", + "error", + "-framerate", + "10", + "-i", + join(directory, "frame-%06d.png"), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + "-y", + path, + ], + { stderr: "pipe" }, + ) + if ((await process.exited) !== 0) throw new Error(`ffmpeg failed: ${await new Response(process.stderr).text()}`) + return path +} + export async function execute(harness: Harness, action: Action) { - SimulationTrace.add("ui.action", { action }) switch (action.type) { - case "typeText": + case "ui.type": await harness.mockInput.typeText(action.text) break - case "pressKey": + case "ui.press": harness.mockInput.pressKey(action.key, action.modifiers) break - case "pressEnter": + case "ui.enter": harness.mockInput.pressEnter() break - case "pressArrow": + case "ui.arrow": harness.mockInput.pressArrow(action.direction) break - case "focus": + case "ui.focus": all(harness.renderer.root).find((item) => item.num === action.target)?.focus() break - case "click": + case "ui.click": await harness.mockMouse.click(action.x, action.y) break } diff --git a/packages/simulation/src/frontend/png.ts b/packages/simulation/src/frontend/png.ts new file mode 100644 index 0000000000..137a09e1be --- /dev/null +++ b/packages/simulation/src/frontend/png.ts @@ -0,0 +1,84 @@ +import { fileURLToPath } from "node:url" +import { GlobalFonts, createCanvas } from "@napi-rs/canvas" +import { TextAttributes, type CapturedFrame, type CliRenderer, type RGBA } from "@opentui/core" + +const CellWidth = 10 +const CellHeight = 20 +const FontSize = 16 +const FontFamily = "OpenCode Screenshot" + +GlobalFonts.registerFromPath( + fileURLToPath(new URL("../../../ui/src/assets/fonts/JetBrainsMonoNerdFontMono-Regular.woff2", import.meta.url)), + FontFamily, +) + +export function screenshot(renderer: CliRenderer) { + return screenshotFrame({ + cols: renderer.currentRenderBuffer.width, + rows: renderer.currentRenderBuffer.height, + cursor: [0, 0], + lines: renderer.currentRenderBuffer.getSpanLines(), + }) +} + +export function screenshotFrame(frame: CapturedFrame) { + const canvas = createCanvas(frame.cols * CellWidth, frame.rows * CellHeight) + const context = canvas.getContext("2d") + context.fillStyle = "#080808" + context.fillRect(0, 0, canvas.width, canvas.height) + context.textBaseline = "top" + + frame.lines.forEach((line, row) => { + let column = 0 + line.spans.forEach((span) => { + const attributes = span.attributes & 0xff + const inverse = Boolean(attributes & TextAttributes.INVERSE) + const hidden = Boolean(attributes & TextAttributes.HIDDEN) + const foreground = inverse ? span.bg : span.fg + const background = inverse ? span.fg : span.bg + const chars = [...span.text] + let remaining = span.width + + chars.forEach((char, index) => { + const cells = Math.max(1, remaining - (chars.length - index - 1)) + if (background.a) { + context.fillStyle = color(background) + context.fillRect(column * CellWidth, row * CellHeight, cells * CellWidth, CellHeight) + } + if (!hidden && char.codePointAt(0) !== 0x0a00) { + context.fillStyle = color(foreground, attributes & TextAttributes.DIM ? 0.55 : 1) + context.font = `${attributes & TextAttributes.ITALIC ? "italic " : ""}${attributes & TextAttributes.BOLD ? "bold " : ""}${FontSize}px "${FontFamily}"` + context.fillText(char, column * CellWidth, row * CellHeight + 1) + if (attributes & TextAttributes.UNDERLINE) { + context.fillRect(column * CellWidth, row * CellHeight + 17, cells * CellWidth, 1) + } + if (attributes & TextAttributes.STRIKETHROUGH) { + context.fillRect(column * CellWidth, row * CellHeight + 10, cells * CellWidth, 1) + } + } + column += cells + remaining -= cells + }) + while (remaining-- > 0) { + if (background.a) { + context.fillStyle = color(background) + context.fillRect(column * CellWidth, row * CellHeight, CellWidth, CellHeight) + } + column++ + } + }) + }) + + return { + width: canvas.width, + height: canvas.height, + data: canvas.toBuffer("image/png"), + } +} + +function color(value: RGBA, opacity = 1) { + const [red, green, blue, alpha] = value.toInts() + return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255) * opacity})` +} + +export * as SimulationPng from "./png" diff --git a/packages/simulation/src/frontend/server.ts b/packages/simulation/src/frontend/server.ts index c717dbd4c6..4edd687887 100644 --- a/packages/simulation/src/frontend/server.ts +++ b/packages/simulation/src/frontend/server.ts @@ -1,6 +1,6 @@ +import type { CapturedFrame } from "@opentui/core" import { SimulationProtocol } from "../protocol" import { SimulationActions, type Harness } from "./actions" -import { SimulationTrace } from "./trace" export interface Server { readonly url: string @@ -11,47 +11,90 @@ function parseRequest(input: string | Buffer) { return SimulationProtocol.Frontend.decodeRequest(JSON.parse(typeof input === "string" ? input : input.toString())) } -async function handle(harness: Harness, request: SimulationProtocol.Frontend.Request, headless: boolean) { +interface Recording { + readonly frames: CapturedFrame[] + readonly timer: ReturnType + pending: Promise +} + +async function handle( + harness: Harness, + request: SimulationProtocol.Frontend.Request, + recording: { current?: Recording }, + headless: boolean, +) { switch (request.method) { + case "ui.screenshot": + return SimulationActions.screenshot(harness) case "ui.state": { - if (headless) await harness.renderOnce() - const result = SimulationActions.state(harness) - SimulationTrace.add("ui.state", { elements: result.elements.length, actions: result.actions.length }) - return result + return SimulationActions.state(harness) } - case "ui.action": - return SimulationActions.execute(harness, request.params.action) - case "trace.list": - return { records: SimulationTrace.list() } - case "trace.clear": - SimulationTrace.clear() - return { cleared: true } - case "trace.export": - return SimulationTrace.exportTrace() + case "ui.start-record": { + if (recording.current) throw new Error("UI recording is already active") + const frames = [SimulationActions.frame(harness)] + const current: Recording = { + frames, + timer: setInterval(() => { + current.pending = current.pending.then(async () => { + if (headless) await harness.renderOnce() + frames.push(SimulationActions.frame(harness)) + }) + }, 100), + pending: Promise.resolve(), + } + recording.current = current + return { recording: true } + } + case "ui.end-record": { + if (!recording.current) throw new Error("UI recording is not active") + const current = recording.current + clearInterval(current.timer) + await current.pending + if (headless) await harness.renderOnce() + current.frames.push(SimulationActions.frame(harness)) + recording.current = undefined + return SimulationActions.video(current.frames) + } + case "ui.type": + return SimulationActions.execute(harness, { type: "ui.type", text: request.params.text }) + case "ui.enter": + return SimulationActions.execute(harness, { type: "ui.enter" }) + case "ui.press": + return SimulationActions.execute(harness, { + type: "ui.press", + key: request.params.key, + modifiers: request.params.modifiers, + }) + case "ui.arrow": + return SimulationActions.execute(harness, { type: "ui.arrow", direction: request.params.direction }) + case "ui.focus": + return SimulationActions.execute(harness, { type: "ui.focus", target: request.params.target }) + case "ui.click": + return SimulationActions.execute(harness, { + type: "ui.click", + target: request.params.target, + x: request.params.x, + y: request.params.y, + }) } } export function start(harness: Harness, endpoint: string, headless: boolean): Server { const url = new URL(endpoint) - const server = Bun.serve<{ readonly drive: true }>({ + const recording: { current?: Recording } = {} + const server = Bun.serve<{ readonly drive: true; readonly headless: boolean }>({ hostname: url.hostname, port: Number(url.port), fetch(request, server) { - if (server.upgrade(request, { data: { drive: true } })) return undefined + if (server.upgrade(request, { data: { drive: true, headless } })) return undefined return new Response("opencode drive ui websocket", { status: 426 }) }, websocket: { - open() { - SimulationTrace.add("control.connect") - }, - close() { - SimulationTrace.add("control.disconnect") - }, async message(socket, message) { let request: SimulationProtocol.Frontend.Request | undefined try { request = parseRequest(message) - const result = await handle(harness, request, headless) + const result = await handle(harness, request, recording, headless) const next = SimulationProtocol.JsonRpc.success(request.id, result) if (next) socket.send(JSON.stringify(next)) } catch (error) { @@ -60,11 +103,10 @@ export function start(harness: Harness, endpoint: string, headless: boolean): Se }, }, }) - SimulationTrace.add("control.start", { url: endpoint }) return { url: endpoint, stop: () => { - SimulationTrace.add("control.stop", { url: endpoint }) + if (recording.current) clearInterval(recording.current.timer) server.stop(true) }, } diff --git a/packages/simulation/src/frontend/trace.ts b/packages/simulation/src/frontend/trace.ts deleted file mode 100644 index b0914faace..0000000000 --- a/packages/simulation/src/frontend/trace.ts +++ /dev/null @@ -1,37 +0,0 @@ -export type TraceRecord = { - readonly id: number - readonly time: string - readonly type: string - readonly data?: unknown -} - -const records: TraceRecord[] = [] -let nextID = 0 - -export function add(type: string, data?: unknown) { - const record = { - id: ++nextID, - time: new Date().toISOString(), - type, - ...(data === undefined ? {} : { data }), - } satisfies TraceRecord - records.push(record) - return record -} - -export function list() { - return [...records] -} - -export function clear() { - records.length = 0 - nextID = 0 -} - -export function exportTrace() { - return { - records: list(), - } -} - -export * as SimulationTrace from "./trace" diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index 2534db8367..c4acb77188 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -59,12 +59,12 @@ export namespace Frontend { export interface KeyModifiers extends Schema.Schema.Type {} export const Action = Schema.Union([ - Schema.Struct({ type: Schema.Literal("typeText"), text: Schema.String }), - Schema.Struct({ type: Schema.Literal("pressKey"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), - Schema.Struct({ type: Schema.Literal("pressEnter") }), - Schema.Struct({ type: Schema.Literal("pressArrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), - Schema.Struct({ type: Schema.Literal("focus"), target: Schema.Number }), - Schema.Struct({ type: Schema.Literal("click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), + Schema.Struct({ type: Schema.Literal("ui.type"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("ui.press"), key: Schema.String, modifiers: Schema.optional(KeyModifiers) }), + Schema.Struct({ type: Schema.Literal("ui.enter") }), + Schema.Struct({ type: Schema.Literal("ui.arrow"), direction: Schema.Literals(["up", "down", "left", "right"]) }), + Schema.Struct({ type: Schema.Literal("ui.focus"), target: Schema.Number }), + Schema.Struct({ type: Schema.Literal("ui.click"), target: Schema.Number, x: Schema.Number, y: Schema.Number }), ]) export type Action = Schema.Schema.Type @@ -83,39 +83,58 @@ export namespace Frontend { export interface Element extends Schema.Schema.Type {} export const State = Schema.Struct({ - screen: Schema.String, focused: Schema.Struct({ renderable: Schema.optional(Schema.Number), editor: Schema.Boolean, }), elements: Schema.Array(Element), - actions: Schema.Array(Action), }) export interface State extends Schema.Schema.Type {} - export const ActionParams = Schema.Struct({ action: Action }) - export interface ActionParams extends Schema.Schema.Type {} + export const Screenshot = Schema.String + export type Screenshot = Schema.Schema.Type + + export const StartRecord = Schema.Struct({ recording: Schema.Literal(true) }) + export interface StartRecord extends Schema.Schema.Type {} + + export const EndRecord = Schema.String + export type EndRecord = Schema.Schema.Type + + export const TypeParams = Schema.Struct({ text: Schema.String }) + export interface TypeParams extends Schema.Schema.Type {} + + export const PressParams = Schema.Struct({ key: Schema.String, modifiers: Schema.optional(KeyModifiers) }) + export interface PressParams extends Schema.Schema.Type {} + + export const ArrowParams = Schema.Struct({ direction: Schema.Literals(["up", "down", "left", "right"]) }) + export interface ArrowParams extends Schema.Schema.Type {} + + export const FocusParams = Schema.Struct({ target: Schema.Number }) + export interface FocusParams extends Schema.Schema.Type {} + + export const ClickParams = Schema.Struct({ target: Schema.Number, x: Schema.Number, y: Schema.Number }) + export interface ClickParams extends Schema.Schema.Type {} export const Request = Schema.Union([ - Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.action"), params: ActionParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.focus"), params: FocusParams }), + Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }), Schema.Struct({ ...JsonRpc.RequestFields, - method: Schema.Literals(["ui.state", "trace.list", "trace.clear", "trace.export"]), + method: Schema.Literals([ + "ui.enter", + "ui.screenshot", + "ui.state", + "ui.start-record", + "ui.end-record", + ]), }), ]) export type Request = Schema.Schema.Type export const decodeRequest = Schema.decodeUnknownSync(Request) - export const TraceRecord = Schema.Struct({ - id: Schema.Number, - time: Schema.String, - type: Schema.String, - data: Schema.optional(Schema.Json), - }) - export interface TraceRecord extends Schema.Schema.Type {} - - export const TraceList = Schema.Struct({ records: Schema.Array(TraceRecord) }) - export interface TraceList extends Schema.Schema.Type {} } export namespace Backend { @@ -148,7 +167,7 @@ export namespace Backend { Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }), Schema.Struct({ ...JsonRpc.RequestFields, - method: Schema.Literals(["llm.attach", "llm.pending", "network.log"]), + method: Schema.Literals(["llm.attach", "llm.pending"]), }), ]) export type Request = Schema.Schema.Type From b2ce195bdac023845342053d666f5cd2f5d66aad Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 12:25:29 -0400 Subject: [PATCH 07/34] fix(core): normalize Location.Ref keys in LocationServiceMap (#35757) --- packages/core/src/location-services.ts | 57 ++++++++++++++--------- packages/core/test/location-layer.test.ts | 32 ++++++++++++- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index fe2301651f..661e9795eb 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -150,31 +150,44 @@ export type LocationError = LayerNode.Error export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], ): Layer.Layer { + // Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded + // payloads omit optional keys) and `{ directory, workspaceID: undefined }` are + // different RcMap keys. The RcMap caches by the raw key before the build + // callback runs, so canonicalize at the map boundary to the key-present shape. + const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID }) return Layer.effect( LocationServiceMap.Service, - LayerMap.make( - (ref: Location.Ref) => { - const startedAt = performance.now() - const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) - // Apply replacements during hoist, not afterward: replacements can - // introduce new tagged dependencies (Location.boundNode depends on - // Project), and the hoist walk is the only pass that can still slice - // those back out. - const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) + Effect.map( + LayerMap.make( + (ref: Location.Ref) => { + const startedAt = performance.now() + const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]]) + // Apply replacements during hoist, not afterward: replacements can + // introduce new tagged dependencies (Location.boundNode depends on + // Project), and the hoist walk is the only pass that can still slice + // those back out. + const location = LayerNode.hoist(locationServices, Node.tags.values.global, allReplacements) - return LayerNode.compile(location.node).pipe( - Layer.fresh, - Layer.tap(() => - Effect.logInfo("location services booted", { - directory: ref.directory, - workspaceID: ref.workspaceID, - durationMs: Math.round(performance.now() - startedAt), - }), - ), - Layer.provide(LayerNode.compile(location.hoisted)), - ) - }, - { idleTimeToLive: "60 minutes" }, + return LayerNode.compile(location.node).pipe( + Layer.fresh, + Layer.tap(() => + Effect.logInfo("location services booted", { + directory: ref.directory, + workspaceID: ref.workspaceID, + durationMs: Math.round(performance.now() - startedAt), + }), + ), + Layer.provide(LayerNode.compile(location.hoisted)), + ) + }, + { idleTimeToLive: "60 minutes" }, + ), + (inner) => ({ + ...inner, + get: (ref: Location.Ref) => inner.get(canonical(ref)), + contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)), + invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)), + }), ), ) } diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index e9eb2be4d9..f2b8d004a1 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -3,7 +3,7 @@ import path from "path" import { describe, expect } from "bun:test" import { Config } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" -import { Context, DateTime, Effect, Equal, Hash, Schema, Stream } from "effect" +import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" @@ -208,6 +208,36 @@ describe("LocationServiceMap", () => { ), ) + it.live("normalizes ref key shapes to one cached location graph", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((dir) => + Effect.scoped( + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + const directory = AbsolutePath.make(dir.path) + const absent = Location.Ref.make({ directory }) + const present = Location.Ref.make({ directory, workspaceID: undefined }) + // The two shapes are not structurally Equal: own-key sets differ. + expect(Object.keys(absent)).toEqual(["directory"]) + expect(Object.keys(present)).toEqual(["directory", "workspaceID"]) + expect(Equal.equals(absent, present)).toBe(false) + + const first = yield* locations.contextEffect(absent) + expect(yield* locations.contextEffect(present)).toBe(first) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1) + + // Invalidating with the shape opposite to the one that booted must evict. + yield* locations.invalidate(present) + expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(0) + }), + ), + ), + ), + ) + it.live("isolates catalog state by location", () => Effect.acquireRelease( Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), From d0733f83bf9128de5d0bc0c464b185d8eaa22286 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:44:50 -0500 Subject: [PATCH 08/34] fix(codemode): return final top-level expression (#35759) Co-authored-by: Kit Langton --- packages/codemode/README.md | 2 +- packages/codemode/src/interpreter/model.ts | 1 - packages/codemode/src/interpreter/runtime.ts | 42 ++++---------------- packages/codemode/test/codemode.test.ts | 18 +++++++++ 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 2cc67fe36a..771d672943 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -62,7 +62,7 @@ const result = `result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption. -Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well. +Successful result values are JSON-safe data. An explicit `return` produces the program result; when it is omitted, the final executable top-level expression is returned as a model-friendly REPL convenience. Otherwise reaching the end produces `null`. Returned `undefined` and nested `undefined` values are normalized to `null` as well. ## API diff --git a/packages/codemode/src/interpreter/model.ts b/packages/codemode/src/interpreter/model.ts index 08d47cd37e..e26538550c 100644 --- a/packages/codemode/src/interpreter/model.ts +++ b/packages/codemode/src/interpreter/model.ts @@ -30,7 +30,6 @@ export type Binding = { export type StatementResult = | { kind: "none" } - | { kind: "value"; value: unknown } | { kind: "return"; value: unknown } | { kind: "break" } | { kind: "continue" } diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 2825e744b5..bdee13b2a8 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -606,7 +606,6 @@ class Interpreter { // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself. private readonly toolKeys: (path: ReadonlyArray) => ReadonlyArray private readonly logs: Array - private lastValue: unknown // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap). private readonly callPermits: Semaphore.Semaphore // Fiber-backed promises whose settlement no program construct has observed yet. Successful @@ -625,7 +624,6 @@ class Interpreter { this.invokeTool = invokeTool this.toolKeys = toolKeys this.logs = logs - this.lastValue = undefined this.callPermits = shared?.callPermits ?? Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY) this.pendingSettlements = shared?.pendingSettlements ?? new Set() globalScope.set("tools", { mutable: false, value: new ToolReference([]) }) @@ -671,13 +669,15 @@ class Interpreter { return Effect.gen(function* () { self.hoistFunctions(program.body) let value: unknown = undefined - let returned = false - for (const statement of program.body) { + for (const [index, statement] of program.body.entries()) { + if (index === program.body.length - 1 && statement.type === "ExpressionStatement") { + value = yield* self.evaluateExpression(getNode(statement, "expression")) + break + } const result = yield* self.evaluateStatement(statement) if (result.kind === "return") { value = result.value - returned = true break } @@ -685,11 +685,7 @@ class Interpreter { throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement) } - if (result.kind === "value") { - self.lastValue = result.value - } } - if (!returned) value = self.lastValue // The program body runs inside an implicit async function, so a returned promise // resolves before crossing the data boundary - `return tools.ns.tool(...)` works @@ -780,7 +776,7 @@ class Interpreter { private evaluateStatement(node: AstNode): Effect.Effect { switch (node.type) { case "ExpressionStatement": - return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value })) + return Effect.as(this.evaluateExpression(getNode(node, "expression")), { kind: "none" }) case "VariableDeclaration": return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" })) case "ReturnStatement": { @@ -833,11 +829,6 @@ class Interpreter { const statement = asNode(statementValue, "body") const result = yield* self.evaluateStatement(statement) - if (result.kind === "value") { - self.lastValue = result.value - continue - } - if (result.kind !== "none") { return result } @@ -929,7 +920,6 @@ class Interpreter { const result = yield* self.evaluateStatement(asNode(statementValue, "consequent")) if (result.kind === "break") return { kind: "none" } satisfies StatementResult if (result.kind === "return" || result.kind === "continue") return result - if (result.kind === "value") self.lastValue = result.value } } return { kind: "none" } satisfies StatementResult @@ -957,9 +947,6 @@ class Interpreter { return result } - if (result.kind === "value") { - self.lastValue = result.value - } } return { kind: "none" } satisfies StatementResult @@ -987,9 +974,6 @@ class Interpreter { return result } - if (result.kind === "value") { - self.lastValue = result.value - } } while (yield* self.evaluateExpression(testNode)) return { kind: "none" } satisfies StatementResult @@ -1045,10 +1029,6 @@ class Interpreter { return { kind: "none" } satisfies StatementResult } - if (result.kind === "value") { - self.lastValue = result.value - } - if (iterationScope) { const loopScope = self.currentScope() for (const name of perIterationBindings) { @@ -1133,10 +1113,6 @@ class Interpreter { return { kind: "none" } } - if (result.kind === "value") { - self.lastValue = result.value - } - if (result.kind === "continue") { continue } @@ -1226,10 +1202,6 @@ class Interpreter { return { kind: "none" } } - if (result.kind === "value") { - self.lastValue = result.value - } - if (result.kind === "continue") { continue } @@ -2372,7 +2344,7 @@ class Interpreter { if (fn.body.type === "BlockStatement") { const result = yield* invocation.evaluateStatement(fn.body) - return result.kind === "return" || result.kind === "value" ? result.value : undefined + return result.kind === "return" ? result.value : undefined } return yield* invocation.evaluateExpression(fn.body) diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 221b5e07df..1113b44683 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -1081,6 +1081,24 @@ describe("CodeMode public contract", () => { expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result) }) + test("returns the final top-level expression when return is omitted", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `1; 2` })) + + expect(result).toStrictEqual({ ok: true, value: 2, toolCalls: [] }) + }) + + test("does not implicitly return expressions nested in control flow", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `if (true) { 2 }` })) + + expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] }) + }) + + test("returns null when the final top-level statement is not an expression", async () => { + const result = await Effect.runPromise(CodeMode.execute({ code: `1; const value = 2` })) + + expect(result).toStrictEqual({ ok: true, value: null, toolCalls: [] }) + }) + test("rejects invalid configuration and discovery limits", async () => { expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: 0 } })).toThrow(RangeError) expect(() => CodeMode.execute({ code: "return 1", limits: { timeoutMs: Number.POSITIVE_INFINITY } })).toThrow( From 66a8d830aab5a7f4f30a8c19a30e6332398b86d4 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:55:06 -0500 Subject: [PATCH 09/34] feat(core): support MCP resources (#35656) --- packages/core/src/mcp/client.ts | 134 +++++++- packages/core/src/mcp/index.ts | 130 +++++--- packages/core/test/fixture/mcp-timeout.ts | 17 +- packages/core/test/fixture/mcp.ts | 7 +- packages/core/test/mcp.test.ts | 300 +++++++++++++++++- packages/schema/src/index.ts | 1 + packages/schema/src/mcp-event.ts | 9 +- packages/schema/src/mcp.ts | 58 +++- packages/schema/test/contract-hygiene.test.ts | 6 + packages/schema/test/event-manifest.test.ts | 3 +- packages/schema/test/mcp.test.ts | 37 +++ 11 files changed, 618 insertions(+), 84 deletions(-) create mode 100644 packages/schema/test/mcp.test.ts diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 0b10ecd7e4..5f48cba649 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -3,7 +3,7 @@ export * as MCPClient from "./client" import path from "node:path" import { execFile } from "node:child_process" import { pathToFileURL } from "node:url" -import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" @@ -21,6 +21,7 @@ import { ListToolsResultSchema, PromptListChangedNotificationSchema, PromptSchema, + ResourceListChangedNotificationSchema, type LoggingMessageNotification, LoggingMessageNotificationSchema, ToolListChangedNotificationSchema, @@ -68,11 +69,13 @@ export interface ToolDefinition { export interface PromptDefinition { readonly name: string readonly description: string | undefined - readonly arguments: ReadonlyArray<{ - readonly name: string - readonly description: string | undefined - readonly required: boolean | undefined - }> | undefined + readonly arguments: + | ReadonlyArray<{ + readonly name: string + readonly description: string | undefined + readonly required: boolean | undefined + }> + | undefined } export interface PromptMessage { @@ -84,6 +87,28 @@ export interface PromptResult { readonly messages: ReadonlyArray } +export interface ResourceDefinition { + readonly name: string + readonly uri: string + readonly description: string | undefined + readonly mimeType: string | undefined +} + +export interface ResourceTemplateDefinition { + readonly name: string + readonly uriTemplate: string + readonly description: string | undefined + readonly mimeType: string | undefined +} + +export type ResourceContentPart = + | { readonly type: "text"; readonly uri: string; readonly text: string; readonly mimeType: string | undefined } + | { readonly type: "blob"; readonly uri: string; readonly blob: string; readonly mimeType: string | undefined } + +export interface ReadResourceResult { + readonly contents: ReadonlyArray +} + export type CallToolContent = | { readonly type: "text"; readonly text: string } | { readonly type: "media"; readonly data: string; readonly mimeType: string } @@ -124,6 +149,12 @@ export interface Connection { readonly tools: () => Effect.Effect /** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */ readonly prompts: () => Effect.Effect + /** Lists the server's resources; returns [] when the server doesn't advertise resource support. */ + readonly resources: () => Effect.Effect + /** Lists the server's resource templates; returns [] when the server doesn't advertise resource support. */ + readonly resourceTemplates: () => Effect.Effect + /** Reads one resource; returns undefined when the server doesn't advertise resource support. */ + readonly readResource: (input: { readonly uri: string }) => Effect.Effect /** Invokes a prompt on the server. Interruption aborts the in-flight request. */ readonly prompt: (input: { readonly name: string @@ -141,6 +172,8 @@ export interface Connection { readonly onToolsChanged: (callback: () => void) => void /** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */ readonly onPromptsChanged: (callback: () => void) => void + /** Registers a callback fired when the server announces its resource catalog changed. */ + readonly onResourcesChanged: (callback: () => void) => void } /** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */ @@ -168,7 +201,8 @@ export const connect = Effect.fnUntraced(function* ( }, }) } - if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) + if (!URL.canParse(config.url)) + return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) return new StreamableHTTPClientTransport(new URL(config.url), { requestInit: config.headers ? { headers: config.headers } : undefined, authProvider, @@ -202,10 +236,7 @@ export const connect = Effect.fnUntraced(function* ( }).pipe(Effect.exit) if (Exit.isSuccess(exit)) { yield* Effect.addFinalizer(() => - cleanupStdioDescendants(transport).pipe( - Effect.andThen(Effect.promise(() => client.close())), - Effect.ignore, - ), + cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => client.close())), Effect.ignore), ) const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT @@ -257,7 +288,9 @@ export const connect = Effect.fnUntraced(function* ( ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( - Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })), + Effect.tapError((error) => + Effect.logWarning("failed to list MCP prompts", { server, error: error.message }), + ), ) return prompts.map((prompt) => ({ name: prompt.name, @@ -269,6 +302,74 @@ export const connect = Effect.fnUntraced(function* ( })), })) }), + resources: () => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.resources) return [] + const resources = yield* Effect.tryPromise({ + try: () => + paginate( + (cursor) => + client.listResources(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }), + (result) => result.resources, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to list MCP resources", { server, error: error.message }), + ), + ) + return resources.map((resource) => ({ + name: resource.name, + uri: resource.uri, + description: resource.description, + mimeType: resource.mimeType, + })) + }), + resourceTemplates: () => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.resources) return [] + const templates = yield* Effect.tryPromise({ + try: () => + paginate( + (cursor) => + client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { + timeout: catalogTimeout, + }), + (result) => result.resourceTemplates, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to list MCP resource templates", { server, error: error.message }), + ), + ) + return templates.map((template) => ({ + name: template.name, + uriTemplate: template.uriTemplate, + description: template.description, + mimeType: template.mimeType, + })) + }), + readResource: (input) => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.resources) return undefined + const result = yield* Effect.tryPromise({ + try: (signal) => client.readResource({ uri: input.uri }, { signal, timeout: executionTimeout }), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("failed to read MCP resource", { server, uri: input.uri, error: error.message }), + ), + ) + return { + contents: result.contents.map( + (part): ResourceContentPart => + "text" in part + ? { type: "text", uri: part.uri, text: part.text, mimeType: part.mimeType } + : { type: "blob", uri: part.uri, blob: part.blob, mimeType: part.mimeType }, + ), + } + }), prompt: (input) => Effect.tryPromise({ try: (signal) => @@ -328,13 +429,14 @@ export const connect = Effect.fnUntraced(function* ( if (!client.getServerCapabilities()?.prompts?.listChanged) return client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback()) }, + onResourcesChanged: (callback) => { + if (!client.getServerCapabilities()?.resources?.listChanged) return + client.setNotificationHandler(ResourceListChangedNotificationSchema, async () => callback()) + }, } satisfies Connection } - yield* cleanupStdioDescendants(transport).pipe( - Effect.andThen(Effect.promise(() => transport.close())), - Effect.ignore, - ) + yield* cleanupStdioDescendants(transport).pipe(Effect.andThen(Effect.promise(() => transport.close())), Effect.ignore) const error = Cause.squash(exit.cause) if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server }) return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) }) diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 1ce8c3d3ec..7a99c15982 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -83,48 +83,16 @@ export class PromptResult extends Schema.Class("MCP.PromptResult") messages: Schema.Array(PromptMessage), }) {} -export class Resource extends Schema.Class("MCP.Resource")({ - server: ServerName, - name: Schema.String, - uri: Schema.String, - description: Schema.String.pipe(Schema.optional), - mimeType: Schema.String.pipe(Schema.optional), -}) {} - -export class ResourceTemplate extends Schema.Class("MCP.ResourceTemplate")({ - server: ServerName, - name: Schema.String, - uriTemplate: Schema.String, - description: Schema.String.pipe(Schema.optional), - mimeType: Schema.String.pipe(Schema.optional), -}) {} - -export class ResourceCatalog extends Schema.Class("MCP.ResourceCatalog")({ - resources: Schema.Array(Resource), - templates: Schema.Array(ResourceTemplate), -}) {} - -export const ResourceContentPart = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("text"), - uri: Schema.String, - text: Schema.String, - mimeType: Schema.String.pipe(Schema.optional), - }), - Schema.Struct({ - type: Schema.Literal("blob"), - uri: Schema.String, - blob: Schema.String, - mimeType: Schema.String.pipe(Schema.optional), - }), -]).pipe(Schema.toTaggedUnion("type")) -export type ResourceContentPart = typeof ResourceContentPart.Type - -export class ResourceContent extends Schema.Class("MCP.ResourceContent")({ - server: ServerName, - uri: Schema.String, - contents: Schema.Array(ResourceContentPart), -}) {} +export const Resource = Mcp.Resource +export type Resource = Mcp.Resource +export const ResourceTemplate = Mcp.ResourceTemplate +export type ResourceTemplate = Mcp.ResourceTemplate +export const ResourceCatalog = Mcp.ResourceCatalog +export type ResourceCatalog = Mcp.ResourceCatalog +export const ResourceContentPart = Mcp.ResourceContentPart +export type ResourceContentPart = Mcp.ResourceContentPart +export const ResourceContent = Mcp.ResourceContent +export type ResourceContent = Mcp.ResourceContent export class NotFoundError extends Schema.TaggedErrorClass()("MCP.NotFoundError", { server: ServerName, @@ -415,6 +383,24 @@ export const layer = Layer.effect( ), }) + const toResource = (server: ServerName, def: MCPClient.ResourceDefinition) => + Resource.make({ + server, + name: def.name, + uri: def.uri, + description: def.description, + mimeType: def.mimeType, + }) + + const toResourceTemplate = (server: ServerName, def: MCPClient.ResourceTemplateDefinition) => + ResourceTemplate.make({ + server, + name: def.name, + uriTemplate: def.uriTemplate, + description: def.description, + mimeType: def.mimeType, + }) + const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => connection.tools().pipe( Effect.map((defs) => { @@ -443,6 +429,7 @@ export const layer = Layer.effect( entry.prompts = undefined entry.status = { status: "failed", error: "Connection closed" } fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) + fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)) fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)) fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) }) @@ -458,6 +445,10 @@ export const layer = Layer.effect( connection.onPromptsChanged(() => { fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore)) }) + connection.onResourcesChanged(() => { + if (entry.client !== connection) return + fork(events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)) + }) } const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { @@ -501,6 +492,7 @@ export const layer = Layer.effect( // after the initial registration sweep and emits no list-changed notification would otherwise // stay invisible to the model. yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) + yield* events.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore)) return @@ -557,11 +549,6 @@ export const layer = Layer.effect( concurrency: "unbounded", discard: true, }) - const gate = Effect.fnUntraced(function* (server: ServerName | string) { - const target = yield* requireServer(server) - yield* Deferred.await(target.entry.startup) - }) - return Service.of({ servers: Effect.fn("MCP.servers")(function* () { const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b)) @@ -637,11 +624,54 @@ export const layer = Layer.effect( }), resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { yield* whenAllReady - return new ResourceCatalog({ resources: [], templates: [] }) + const catalogs = yield* Effect.forEach( + Array.from(runtime), + ([name, entry]) => { + if (!entry.client) return Effect.succeed({ resources: [], templates: [] }) + return Effect.all( + { + resources: entry.client.resources().pipe(Effect.catch(() => Effect.succeed([]))), + templates: entry.client.resourceTemplates().pipe(Effect.catch(() => Effect.succeed([]))), + }, + { concurrency: "unbounded" }, + ).pipe( + Effect.map((catalog) => ({ + resources: catalog.resources.map((def) => toResource(name, def)), + templates: catalog.templates.map((def) => toResourceTemplate(name, def)), + })), + ) + }, + { concurrency: "unbounded" }, + ) + return ResourceCatalog.make({ + resources: catalogs + .flatMap((catalog) => catalog.resources) + .toSorted( + (a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri), + ), + templates: catalogs + .flatMap((catalog) => catalog.templates) + .toSorted( + (a, b) => + a.server.localeCompare(b.server) || + a.name.localeCompare(b.name) || + a.uriTemplate.localeCompare(b.uriTemplate), + ), + }) }), readResource: Effect.fn("MCP.readResource")(function* (input) { - yield* gate(input.server) - return undefined + const target = yield* requireServer(input.server) + yield* Deferred.await(target.entry.startup) + if (!target.entry.client) return undefined + const result = yield* target.entry.client + .readResource({ uri: input.uri }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!result) return undefined + return ResourceContent.make({ + server: target.name, + uri: input.uri, + contents: result.contents, + }) }), }) }), diff --git a/packages/core/test/fixture/mcp-timeout.ts b/packages/core/test/fixture/mcp-timeout.ts index 15c851236c..4855b0ec26 100644 --- a/packages/core/test/fixture/mcp-timeout.ts +++ b/packages/core/test/fixture/mcp-timeout.ts @@ -4,16 +4,27 @@ import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, ListToolsRequestSchema, + ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js" -const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } }) +const server = new Server( + { name: "timeout", version: "1.0.0" }, + { capabilities: { prompts: {}, resources: {}, tools: {} } }, +) server.setRequestHandler(ListToolsRequestSchema, async () => { if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100) return { tools: [{ name: "slow", inputSchema: { type: "object" } }] } }) server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] })) +server.setRequestHandler(ListResourcesRequestSchema, async () => { + if (process.env.MCP_TIMEOUT_TARGET === "resource-catalog") await Bun.sleep(100) + return { resources: [{ name: "slow", uri: "test://slow" }] } +}) +server.setRequestHandler(ListResourceTemplatesRequestSchema, () => Promise.resolve({ resourceTemplates: [] })) server.setRequestHandler(CallToolRequestSchema, async () => { await Bun.sleep(100) return { content: [] } @@ -22,5 +33,9 @@ server.setRequestHandler(GetPromptRequestSchema, async () => { await Bun.sleep(100) return { messages: [] } }) +server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + await Bun.sleep(100) + return { contents: [{ uri: request.params.uri, text: "slow" }] } +}) await server.connect(new StdioServerTransport()) diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts index 2c4d1f86a6..b247a4d8b7 100644 --- a/packages/core/test/fixture/mcp.ts +++ b/packages/core/test/fixture/mcp.ts @@ -14,15 +14,12 @@ export const emptyMcpLayer = Layer.succeed( instructions: () => Effect.succeed([]), prompts: () => Effect.succeed([]), prompt: () => Effect.succeed(undefined), - resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })), + resourceCatalog: () => Effect.succeed(MCP.ResourceCatalog.make({ resources: [], templates: [] })), readResource: () => Effect.succeed(undefined), }), ) -export const emptyConfigLayer = Layer.succeed( - Config.Service, - Config.Service.of({ entries: () => Effect.succeed([]) }), -) +export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) export const testLocationLayer = Layer.succeed( Location.Service, diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 26977a8729..32a0f94d88 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -3,26 +3,165 @@ import { describe, expect, test } from "bun:test" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { + CallToolRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, +} from "@modelcontextprotocol/sdk/types.js" import { ConfigMCP } from "@opencode-ai/core/config/mcp" +import { Config } from "@opencode-ai/core/config" +import { Credential } from "@opencode-ai/core/credential" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { Form } from "@opencode-ai/core/form" +import { Integration } from "@opencode-ai/core/integration" +import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" import { MCPClient } from "@opencode-ai/core/mcp/client" import { PermissionV2 } from "@opencode-ai/core/permission" +import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { McpTool } from "@opencode-ai/core/tool/mcp" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { Deferred, Effect, Fiber, Layer, Stream } from "effect" +import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" +import { location } from "./fixture/location" import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool" let assertion: Deferred.Deferred | undefined let decision: Effect.Effect = Effect.void let calls = 0 +type ResourcePage = { + items: Array<{ name: string; uri: string; description?: string; mimeType?: string }> + nextCursor?: string +} + +type ResourceTemplatePage = { + items: Array<{ name: string; uriTemplate: string; description?: string; mimeType?: string }> + nextCursor?: string +} + +function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) { + return Effect.acquireRelease( + Effect.promise(async () => { + const state = { + resources: [] as ResourcePage["items"], + templates: [] as ResourceTemplatePage["items"], + resourcePages: undefined as Record | undefined, + templatePages: undefined as Record | undefined, + contents: [ + { uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>, + resourceLists: 0, + templateLists: 0, + } + const protocol = new Server( + { name: "mcp-resources", version: "1.0.0" }, + { + capabilities: { + tools: {}, + ...(input.resources === false ? {} : { resources: { listChanged: input.listChanged } }), + }, + }, + ) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + if (input.resources !== false) { + protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { + state.resourceLists += 1 + const page = state.resourcePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => { + state.templateLists += 1 + const page = state.templatePages?.[request.params?.cursor ?? "initial"] + return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor }) + }) + protocol.setRequestHandler(ReadResourceRequestSchema, () => Promise.resolve({ contents: state.contents })) + } + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + enableJsonResponse: true, + }) + await protocol.connect(transport) + const http = Bun.serve({ + port: 0, + fetch: (request) => transport.handleRequest(request), + }) + return { + state, + url: http.url.toString(), + sendResourceListChanged: () => protocol.sendResourceListChanged(), + close: async () => { + await protocol.close().catch(() => {}) + await http.stop(true) + }, + } + }), + (server) => Effect.promise(server.close), + ) +} + +function resourceMcpLayer(url: string) { + const directory = AbsolutePath.make(import.meta.dir) + const unusedIntegration = () => Effect.die("unused integration service") + return MCP.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed( + Config.Service, + Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: new Config.Info({ + mcp: new ConfigMCP.Info({ + servers: { resources: new ConfigMCP.Remote({ type: "remote", url, oauth: false }) }, + }), + }), + }), + ]), + }), + ), + Layer.succeed(Location.Service, Location.Service.of(location({ directory }))), + Layer.mock(EventV2.Service, { + subscribe: () => Stream.never, + publish: (definition, data) => + Effect.succeed({ + id: EventV2.ID.create(), + type: definition.type, + data, + } as EventV2.Payload), + }), + Layer.mock(Form.Service, {}), + Layer.mock(Integration.Service, { + connection: { + active: unusedIntegration, + resolve: unusedIntegration, + key: unusedIntegration, + oauth: unusedIntegration, + update: unusedIntegration, + remove: unusedIntegration, + }, + attempt: { + status: unusedIntegration, + complete: unusedIntegration, + cancel: unusedIntegration, + }, + }), + Layer.mock(Credential.Service, {}), + ), + ), + ) +} + const mcp = Layer.mock(MCP.Service, { tools: () => Effect.succeed([ @@ -241,6 +380,163 @@ test("applies the configured MCP execution timeout to prompts", async () => { await expect(result).rejects.toThrow("Request timed out") }) +test("applies configured MCP timeouts to resource operations", async () => { + const catalog = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "resource-catalog-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + environment: { MCP_TIMEOUT_TARGET: "resource-catalog" }, + timeout: new ConfigMCP.Timeout({ catalog: 10 }), + }), + import.meta.dir, + ) + return yield* connection.resources() + }), + ), + ) + await expect(catalog).rejects.toThrow("Request timed out") + + const read = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "resource-read-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + timeout: new ConfigMCP.Timeout({ execution: 10 }), + }), + import.meta.dir, + ) + return yield* connection.readResource({ uri: "test://slow" }) + }), + ), + ) + await expect(read).rejects.toThrow("Request timed out") +}) + +test("lists, reads, and reports MCP resource changes", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer({ listChanged: true }) + server.state.resourcePages = { + initial: { + items: [{ name: "Readme", uri: "docs://readme", description: "Project docs" }], + nextCursor: "resources-2", + }, + "resources-2": { items: [{ name: "Logo", uri: "docs://logo", mimeType: "image/png" }] }, + } + server.state.templatePages = { + initial: { + items: [{ name: "File", uriTemplate: "docs://{path}" }], + nextCursor: "templates-2", + }, + "templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}", description: "Issue" }] }, + } + const connection = yield* MCPClient.connect( + "resources", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }), + import.meta.dir, + ) + + expect(yield* connection.resources()).toEqual([ + { name: "Readme", uri: "docs://readme", description: "Project docs", mimeType: undefined }, + { name: "Logo", uri: "docs://logo", description: undefined, mimeType: "image/png" }, + ]) + expect(yield* connection.resourceTemplates()).toEqual([ + { name: "File", uriTemplate: "docs://{path}", description: undefined, mimeType: undefined }, + { name: "Issue", uriTemplate: "issue://{id}", description: "Issue", mimeType: undefined }, + ]) + expect(yield* connection.readResource({ uri: "docs://readme" })).toEqual({ + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }) + + const changed = yield* Deferred.make() + connection.onResourcesChanged(() => Deferred.doneUnsafe(changed, Exit.void)) + yield* Effect.promise(server.sendResourceListChanged) + yield* Deferred.await(changed) + }), + ), + ) +}) + +test("skips MCP resource requests when the capability is absent", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer({ resources: false }) + const connection = yield* MCPClient.connect( + "resources", + new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }), + import.meta.dir, + ) + expect(yield* connection.resources()).toEqual([]) + expect(yield* connection.resourceTemplates()).toEqual([]) + expect(yield* connection.readResource({ uri: "docs://readme" })).toBeUndefined() + expect({ resources: server.state.resourceLists, templates: server.state.templateLists }).toEqual({ + resources: 0, + templates: 0, + }) + }), + ), + ) +}) + +test("loads and reads MCP resources", async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const server = yield* resourceServer() + server.state.resources = [{ name: "Readme", uri: "docs://readme" }] + server.state.templates = [{ name: "File", uriTemplate: "docs://{path}" }] + + yield* Effect.gen(function* () { + const service = yield* MCP.Service + expect(yield* service.resourceCatalog()).toEqual({ + resources: [ + { + server: "resources", + name: "Readme", + uri: "docs://readme", + description: undefined, + mimeType: undefined, + }, + ], + templates: [ + { + server: "resources", + name: "File", + uriTemplate: "docs://{path}", + description: undefined, + mimeType: undefined, + }, + ], + }) + + server.state.resources = [{ name: "Guide", uri: "docs://guide" }] + expect((yield* service.resourceCatalog()).resources.map((resource) => resource.uri)).toEqual(["docs://guide"]) + expect(yield* service.readResource({ server: "resources", uri: "docs://readme" })).toEqual({ + server: "resources", + uri: "docs://readme", + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }) + }).pipe(Effect.provide(resourceMcpLayer(server.url))) + }), + ), + ) +}) + it.effect("advertises MCP output schemas to Code Mode", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 3f2efee68d..4e77cc4151 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -9,6 +9,7 @@ export { Form } from "./form.js" export { Integration } from "./integration.js" export { LLM } from "./llm.js" export { Location } from "./location.js" +export { Mcp } from "./mcp.js" export { Model } from "./model.js" export { Permission } from "./permission.js" export { PermissionSaved } from "./permission-saved.js" diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index ae1e82656d..d41f5f0c4a 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -10,6 +10,13 @@ export const ToolsChanged = Event.ephemeral({ }, }) +export const ResourcesChanged = Event.ephemeral({ + type: "mcp.resources.changed", + schema: { + server: Schema.String, + }, +}) + export const BrowserOpenFailed = Event.ephemeral({ type: "mcp.browser.open.failed", schema: { @@ -27,4 +34,4 @@ export const StatusChanged = Event.ephemeral({ }, }) -export const Definitions = Event.inventory(ToolsChanged, StatusChanged) +export const Definitions = Event.inventory(ToolsChanged, ResourcesChanged, StatusChanged) diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index 4a7ce15618..a86ffe6d67 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -25,14 +25,9 @@ const NeedsClientRegistration = Schema.Struct({ }).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" }) export type Status = typeof Status.Type -export const Status = Schema.Union([ - Connected, - Pending, - Disabled, - Failed, - NeedsAuth, - NeedsClientRegistration, -]).pipe(Schema.toTaggedUnion("status")) +export const Status = Schema.Union([Connected, Pending, Disabled, Failed, NeedsAuth, NeedsClientRegistration]).pipe( + Schema.toTaggedUnion("status"), +) export interface Server extends Schema.Schema.Type {} export const Server = Schema.Struct({ @@ -42,3 +37,50 @@ export const Server = Schema.Struct({ // without matching by name, which could collide with provider or plugin integrations. integrationID: optional(IntegrationID), }).annotate({ identifier: "Mcp.Server" }) + +export interface Resource extends Schema.Schema.Type {} +export const Resource = Schema.Struct({ + server: Schema.String, + name: Schema.String, + uri: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), +}).annotate({ identifier: "Mcp.Resource" }) + +export interface ResourceTemplate extends Schema.Schema.Type {} +export const ResourceTemplate = Schema.Struct({ + server: Schema.String, + name: Schema.String, + uriTemplate: Schema.String, + description: optional(Schema.String), + mimeType: optional(Schema.String), +}).annotate({ identifier: "Mcp.ResourceTemplate" }) + +export interface ResourceCatalog extends Schema.Schema.Type {} +export const ResourceCatalog = Schema.Struct({ + resources: Schema.Array(Resource), + templates: Schema.Array(ResourceTemplate), +}).annotate({ identifier: "Mcp.ResourceCatalog" }) + +export const ResourceContentPart = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("text"), + uri: Schema.String, + text: Schema.String, + mimeType: optional(Schema.String), + }), + Schema.Struct({ + type: Schema.Literal("blob"), + uri: Schema.String, + blob: Schema.String, + mimeType: optional(Schema.String), + }), +]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Mcp.ResourceContentPart" })) +export type ResourceContentPart = typeof ResourceContentPart.Type + +export interface ResourceContent extends Schema.Schema.Type {} +export const ResourceContent = Schema.Struct({ + server: Schema.String, + uri: Schema.String, + contents: Schema.Array(ResourceContentPart), +}).annotate({ identifier: "Mcp.ResourceContent" }) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index 236e15b91f..0cc1c71974 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { DateTime, Schema } from "effect" import { Agent } from "../src/agent.js" import { FileSystem } from "../src/filesystem.js" +import { Mcp } from "../src/mcp.js" import { Model } from "../src/model.js" import { Project } from "../src/project.js" import { Provider } from "../src/provider.js" @@ -51,6 +52,11 @@ describe("contract hygiene", () => { const identifiers = [ Agent.Color, FileSystem.Submatch, + Mcp.Resource, + Mcp.ResourceTemplate, + Mcp.ResourceCatalog, + Mcp.ResourceContentPart, + Mcp.ResourceContent, Model.Ref, Model.Capabilities, Model.Cost, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 1a5cbe7fa7..f93fb89de7 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -53,6 +53,7 @@ describe("public event manifest", () => { expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted) expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false) + expect(EventManifest.Server.has("mcp.resources.changed")).toBe(false) expect(Agent.Event.Updated.durable).toBeUndefined() expect(EventManifest.Durable.has("agent.updated")).toBe(false) }) @@ -76,7 +77,7 @@ describe("public event manifest", () => { expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled]) expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(Plugin.Event.Definitions).toEqual([Plugin.Event.Added, Plugin.Event.Updated]) - expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.StatusChanged]) + expect(McpEvent.Definitions).toEqual([McpEvent.ToolsChanged, McpEvent.ResourcesChanged, McpEvent.StatusChanged]) expect(EventManifest.Latest.has("mcp.browser.open.failed")).toBe(false) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) diff --git a/packages/schema/test/mcp.test.ts b/packages/schema/test/mcp.test.ts new file mode 100644 index 0000000000..75daa7fe1c --- /dev/null +++ b/packages/schema/test/mcp.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Mcp } from "../src/mcp.js" + +describe("Mcp resources", () => { + test("decodes resource catalogs and omits absent metadata", () => { + const value = Schema.decodeUnknownSync(Mcp.ResourceCatalog)({ + resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], + templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }], + }) + + expect(Schema.encodeSync(Mcp.ResourceCatalog)(value)).toEqual({ + resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], + templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }], + }) + }) + + test("preserves text and base64 blob contents", () => { + expect( + Schema.decodeUnknownSync(Mcp.ResourceContent)({ + server: "docs", + uri: "docs://readme", + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }), + ).toEqual({ + server: "docs", + uri: "docs://readme", + contents: [ + { type: "text", uri: "docs://readme", text: "hello", mimeType: "text/plain" }, + { type: "blob", uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" }, + ], + }) + }) +}) From 1fb2837fd383a025a1dba909a5c9e4ae38ac0b03 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:11:57 -0500 Subject: [PATCH 10/34] feat(codemode): expand numeric standard library (#35749) --- packages/codemode/README.md | 2 +- packages/codemode/codemode.md | 2 +- packages/codemode/src/stdlib/math.ts | 2 ++ packages/codemode/src/stdlib/number.ts | 16 ++++++++++++++-- packages/codemode/test/stdlib.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 771d672943..e298ccfc57 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -241,7 +241,7 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports: - `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets, including assignment-form destructuring such as `for ([key, value] of entries)`), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. - Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. -- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. +- Common array, string, number, `Object`, `Math`, and `JSON` operations, including primitive-number `valueOf`, the standard non-finite `Number` constants, and host-backed `Math.random`. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. - `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). - Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index f7f798f2e1..c388cca1bd 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -158,7 +158,7 @@ current omissions to implement, not intentional product boundaries. - [ ] Add `Object.is` after runtime method and tool references have stable identity semantics. - [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set composition methods, and `Array.prototype.toSpliced`. -- [ ] Decide whether nondeterministic `Math.random` and iterable `Math.sumPrecise` belong in the runtime. +- [ ] Decide whether iterable `Math.sumPrecise` belongs in the runtime. - [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter defects are distinguishable without leaking private causes. diff --git a/packages/codemode/src/stdlib/math.ts b/packages/codemode/src/stdlib/math.ts index edb7e3d94b..7720f69775 100644 --- a/packages/codemode/src/stdlib/math.ts +++ b/packages/codemode/src/stdlib/math.ts @@ -1,6 +1,7 @@ export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"]) export const mathMethods = new Set([ + "random", "max", "min", "abs", @@ -40,6 +41,7 @@ export const mathMethods = new Set([ export const invokeMathMethod = (name: string, args: Array, node: AstNode): number => { if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node) + if (name === "random") return Math.random() const nums = args.map((arg) => { if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node) return arg diff --git a/packages/codemode/src/stdlib/number.ts b/packages/codemode/src/stdlib/number.ts index 79710e1ad2..02dfa953b4 100644 --- a/packages/codemode/src/stdlib/number.ts +++ b/packages/codemode/src/stdlib/number.ts @@ -1,6 +1,15 @@ -export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"]) +export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString", "valueOf"]) -export const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"]) +export const numberConstants = new Set([ + "MAX_SAFE_INTEGER", + "MIN_SAFE_INTEGER", + "MAX_VALUE", + "MIN_VALUE", + "EPSILON", + "NaN", + "POSITIVE_INFINITY", + "NEGATIVE_INFINITY", +]) export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"]) @@ -32,6 +41,9 @@ export const invokeNumberMethod = (value: number, name: string, args: Array { return result.error } +describe("Number and Math", () => { + test("Math.random returns a number in [0, 1)", async () => { + expect(await value(`const n = Math.random(); return typeof n === "number" && n >= 0 && n < 1`)).toBe(true) + }) + + test("Number exposes native non-finite constants", async () => { + expect( + await value( + `return [Number.isNaN(Number.NaN), Number.POSITIVE_INFINITY === Infinity, Number.NEGATIVE_INFINITY === -Infinity]`, + ), + ).toEqual([true, true, true]) + }) + + test("Number valueOf returns its primitive receiver", async () => { + expect(await value(`return (42).valueOf()`)).toBe(42) + }) + + test("Number valueOf does not enable boxed numbers", async () => { + expect((await error(`return new Number(42)`)).kind).toBe("UnsupportedSyntax") + }) +}) + describe("Date", () => { test("Date.now() returns a number", async () => { expect(await value(`return typeof Date.now()`)).toBe("number") From cb18a42a473afe40e00774717a14413fbe13b28d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:48:05 -0500 Subject: [PATCH 11/34] docs(codemode): clarify implicit return guidance (#35763) Co-authored-by: Kit Langton --- packages/codemode/src/tool-runtime.ts | 1 + packages/codemode/test/codemode.test.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 7d67ba6c79..80ce828414 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -617,6 +617,7 @@ export const prepare = (tools: HostTools, catalogBudget = defaultCatalogBu "", "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.", "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.", + "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ] diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index 1113b44683..16c17ca0c2 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -658,6 +658,9 @@ describe("CodeMode public contract", () => { expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers") expect(instructions).not.toContain("host globals") expect(instructions).toContain("Use Code Mode tools for external operations") + expect(instructions).toContain( + "Prefer explicit `return`; otherwise only the final top-level expression becomes the result.", + ) expect(instructions).toContain( "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.", ) From 3e57b43a4a59e2f91d13f5fc08a6db5d4e3fb164 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 7 Jul 2026 14:17:20 -0400 Subject: [PATCH 12/34] feat(session): support admit-only synthetic messages (#35774) --- packages/client/src/effect/api/api.ts | 1 + packages/client/src/effect/generated/client.ts | 8 +++++++- packages/client/src/promise/generated/client.ts | 7 ++++++- packages/client/src/promise/generated/types.ts | 9 +++++++++ packages/core/src/session.ts | 2 ++ packages/protocol/src/groups/session.ts | 1 + packages/server/src/handlers/session.ts | 1 + 7 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 59202fe0ef..d70a2838e8 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -154,6 +154,7 @@ export type Endpoint4_12Input = { readonly text: Endpoint4_12Request["payload"]["text"] readonly description?: Endpoint4_12Request["payload"]["description"] readonly metadata?: Endpoint4_12Request["payload"]["metadata"] + readonly resume?: Endpoint4_12Request["payload"]["resume"] } export type Endpoint4_12Output = EffectValue> export type SessionSyntheticOperation = (input: Endpoint4_12Input) => Effect.Effect diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 94de0970f3..ab65a65f92 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -209,11 +209,17 @@ type Endpoint4_12Input = { readonly text: Endpoint4_12Request["payload"]["text"] readonly description?: Endpoint4_12Request["payload"]["description"] readonly metadata?: Endpoint4_12Request["payload"]["metadata"] + readonly resume?: Endpoint4_12Request["payload"]["resume"] } const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, - payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, + payload: { + text: input["text"], + description: input["description"], + metadata: input["metadata"], + resume: input["resume"], + }, }).pipe(Effect.mapError(mapClientError)) type Endpoint4_13Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 4c57621d48..82930a324e 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -538,7 +538,12 @@ export function make(options: ClientOptions) { { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/synthetic`, - body: { text: input["text"], description: input["description"], metadata: input["metadata"] }, + body: { + text: input["text"], + description: input["description"], + metadata: input["metadata"], + resume: input["resume"], + }, successStatus: 204, declaredStatuses: [404, 400, 401], empty: true, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index aaee9778b3..e74b7cec55 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -856,17 +856,26 @@ export type SessionSyntheticInput = { readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null }["text"] readonly description?: { readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null }["description"] readonly metadata?: { readonly text: string readonly description?: string | null readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null }["metadata"] + readonly resume?: { + readonly text: string + readonly description?: string | null + readonly metadata?: { readonly [x: string]: JsonValue } + readonly resume?: boolean | null + }["resume"] } export type SessionSyntheticOutput = void diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b5d28c4612..ea18ed3909 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -243,6 +243,7 @@ export interface Interface { text: string description?: string metadata?: Record + resume?: boolean }) => Effect.Effect readonly revert: { readonly stage: (input: { @@ -682,6 +683,7 @@ const layer = Layer.effect( description: input.description, metadata: input.metadata, }) + if (input.resume === false) return yield* execution .resume(input.sessionID) .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 090df62eb6..bc9cd1ab2f 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -344,6 +344,7 @@ export const makeSessionGroup = (sessionLo text: Schema.String, description: Schema.String.pipe(Schema.optional), metadata: SessionMessage.Synthetic.fields.metadata, + resume: Schema.Boolean.pipe(Schema.optional), }), success: HttpApiSchema.NoContent, error: SessionNotFoundError, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index ce3bc15f36..6059dcb89a 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -329,6 +329,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl text: ctx.payload.text, description: ctx.payload.description, metadata: ctx.payload.metadata, + resume: ctx.payload.resume, }) .pipe( Effect.catchTag("Session.NotFoundError", (error) => From 947bbf9490537af456790d98ec14f7b3ef2e341f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:34:29 -0500 Subject: [PATCH 13/34] feat(mcp): expose resource catalog API (#35773) --- packages/client/src/effect/api/api.ts | 6 +++ .../client/src/effect/generated/client.ts | 7 +++- .../client/src/promise/generated/client.ts | 14 +++++++ .../client/src/promise/generated/types.ts | 38 +++++++++++++++++++ packages/client/test/promise.test.ts | 23 +++++++++++ packages/protocol/src/groups/mcp.ts | 16 +++++++- packages/protocol/test/event.test.ts | 1 + packages/schema/src/event-manifest.ts | 1 + packages/schema/test/event-manifest.test.ts | 2 +- packages/server/src/handlers/mcp.ts | 36 +++++++++++------- 10 files changed, 127 insertions(+), 17 deletions(-) diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index d70a2838e8..e0ab861dbc 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -440,8 +440,14 @@ export type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query export type Endpoint10_0Output = EffectValue> export type ServerMcpListOperation = (input?: Endpoint10_0Input) => Effect.Effect +type Endpoint10_1Request = Parameters[0] +export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } +export type Endpoint10_1Output = EffectValue> +export type ServerMcpCatalogOperation = (input?: Endpoint10_1Input) => Effect.Effect + export interface ServerMcpApi { readonly list: ServerMcpListOperation + readonly catalog: ServerMcpCatalogOperation } type Endpoint11_0Request = Parameters[0] diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index ab65a65f92..68786a4ea3 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -535,7 +535,12 @@ type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["loc const Endpoint10_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_0Input) => raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw) }) +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } +const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) => + raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw), catalog: Endpoint10_1(raw) }) type Endpoint11_0Request = Parameters[0] type Endpoint11_0Input = { diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 82930a324e..575208a614 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -87,6 +87,8 @@ import type { IntegrationAttemptCancelOutput, ServerMcpListInput, ServerMcpListOutput, + ServerMcpCatalogInput, + ServerMcpCatalogOutput, CredentialUpdateInput, CredentialUpdateOutput, CredentialRemoveInput, @@ -897,6 +899,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + catalog: (input?: ServerMcpCatalogInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/mcp/resource`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), }, credential: { update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e74b7cec55..1b674e5ef1 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2487,6 +2487,36 @@ export type ServerMcpListOutput = { }> } +export type ServerMcpCatalogInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ServerMcpCatalogOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly resources: ReadonlyArray<{ + readonly server: string + readonly name: string + readonly uri: string + readonly description?: string + readonly mimeType?: string + }> + readonly templates: ReadonlyArray<{ + readonly server: string + readonly name: string + readonly uriTemplate: string + readonly description?: string + readonly mimeType?: string + }> + } +} + export type CredentialUpdateInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] readonly location?: { @@ -5517,6 +5547,14 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly server: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "mcp.resources.changed" + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly server: string } + } | { readonly id: string readonly created: number diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 4f2419075f..b8433a0d1d 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -50,6 +50,29 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) }) +test("MCP resource catalog uses the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + request = input instanceof Request ? input : new Request(input) + return Response.json({ + location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } }, + data: { + resources: [{ server: "docs", name: "Readme", uri: "docs://readme" }], + templates: [{ server: "docs", name: "File", uriTemplate: "docs://{path}" }], + }, + }) + }, + }) + + const result = await client["server.mcp"].catalog({ location: { directory: "/tmp/project" } }) + + expect(result.data.resources[0]?.uri).toBe("docs://readme") + expect(request?.method).toBe("GET") + expect(request?.url).toBe("http://localhost:3000/api/mcp/resource?location%5Bdirectory%5D=%2Ftmp%2Fproject") +}) + test("file.read returns binary content from the public HTTP contract", async () => { let request: Request | undefined const client = OpenCode.make({ diff --git a/packages/protocol/src/groups/mcp.ts b/packages/protocol/src/groups/mcp.ts index d29749ab15..5f9c03935d 100644 --- a/packages/protocol/src/groups/mcp.ts +++ b/packages/protocol/src/groups/mcp.ts @@ -19,4 +19,18 @@ export const McpGroup = HttpApiGroup.make("server.mcp") }), ), ) - .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server status routes." })) + .add( + HttpApiEndpoint.get("mcp.resource.catalog", "/api/mcp/resource", { + query: LocationQuery, + success: Location.response(Mcp.ResourceCatalog), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.mcp.resource.catalog", + summary: "List MCP resources", + description: "Retrieve resources and resource templates from connected MCP servers.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server and resource routes." })) diff --git a/packages/protocol/test/event.test.ts b/packages/protocol/test/event.test.ts index 33ece777c4..56213ef4cc 100644 --- a/packages/protocol/test/event.test.ts +++ b/packages/protocol/test/event.test.ts @@ -4,5 +4,6 @@ import { isOpenCodeEvent } from "../src/groups/event.js" test("classifies public events by type", () => { expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.status.changed" })).toBe(true) + expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true) expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false) }) diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 23ed3fd044..035f72568e 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -80,6 +80,7 @@ export const ServerDefinitions = Event.inventory( ...InstallationEvent.Definitions, ...VcsEvent.Definitions, McpEvent.StatusChanged, + McpEvent.ResourcesChanged, // Shared transitional: V1 contracts the current TUI still consumes during // the migration (permission.asked/replied, question.asked, session.error). // Remove when the TUI moves to the current permission/question surfaces. diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index f93fb89de7..9ff3a05519 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -51,9 +51,9 @@ describe("public event manifest", () => { expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("plugin.updated")).toBe(Plugin.Event.Updated) expect(EventManifest.Server.get("mcp.status.changed")).toBe(McpEvent.StatusChanged) + expect(EventManifest.Server.get("mcp.resources.changed")).toBe(McpEvent.ResourcesChanged) expect(EventManifest.Server.get("session.deleted")).toBe(SessionEvent.Deleted) expect(EventManifest.Server.has("mcp.tools.changed")).toBe(false) - expect(EventManifest.Server.has("mcp.resources.changed")).toBe(false) expect(Agent.Event.Updated.durable).toBeUndefined() expect(EventManifest.Durable.has("agent.updated")).toBe(false) }) diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts index 4d702f24c9..8f423534d4 100644 --- a/packages/server/src/handlers/mcp.ts +++ b/packages/server/src/handlers/mcp.ts @@ -6,20 +6,28 @@ import { response } from "../location" export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => Effect.gen(function* () { - return handlers.handle( - "mcp.list", - Effect.fn(function* () { - const service = yield* MCP.Service - return yield* response( - service - .servers() - .pipe( - Effect.map((servers) => - servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })), + return handlers + .handle( + "mcp.list", + Effect.fn(function* () { + const service = yield* MCP.Service + return yield* response( + service + .servers() + .pipe( + Effect.map((servers) => + servers.map((info) => ({ name: info.name, status: info.status, integrationID: info.integrationID })), + ), ), - ), - ) - }), - ) + ) + }), + ) + .handle( + "mcp.resource.catalog", + Effect.fn(function* () { + const service = yield* MCP.Service + return yield* response(service.resourceCatalog()) + }), + ) }), ) From f86e1cc1ff5c4f3fd475635975e0713fcb5c3761 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:40:55 -0500 Subject: [PATCH 14/34] fix(codemode): preserve collection value identity (#35776) --- packages/codemode/src/interpreter/runtime.ts | 19 ++++- packages/codemode/src/stdlib/object.ts | 33 ++++---- packages/codemode/test/promise.test.ts | 6 ++ packages/codemode/test/stdlib.test.ts | 84 ++++++++++++++++++++ 4 files changed, 125 insertions(+), 17 deletions(-) diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index bdee13b2a8..7f5d015d18 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -530,17 +530,29 @@ const invokeArrayStatic = (name: string, args: Array, node: AstNode): u if (args[0] instanceof SandboxURLSearchParams) { return Array.from(args[0].params.entries(), ([key, value]) => [key, value]) } - const source = boundedData(args[0], "Array.from input") + const source = args[0] + if (source instanceof SandboxPromise) { + throw new InterpreterRuntimeError( + "Array.from received an un-awaited Promise; await it before creating the array.", + node, + "InvalidDataValue", + ) + } if (typeof source === "string") return Array.from(source) if (Array.isArray(source)) return [...source] if ( source !== null && typeof source === "object" && + (Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) && typeof (source as { length?: unknown }).length === "number" ) { return Array.from(source as ArrayLike) } - throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node) + throw new InterpreterRuntimeError( + "Array.from expects an array, string, Map, Set, or array-like value.", + node, + "InvalidDataValue", + ) } default: throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node) @@ -2005,6 +2017,9 @@ class Interpreter { if (callable.namespace === "Object" && objectMethodsPreservingIdentity.has(callable.name)) { return invokeGlobalMethod(callable, args, node) } + if (callable.namespace === "Array" && (callable.name === "from" || callable.name === "of")) { + return invokeGlobalMethod(callable, args, node) + } return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`) } if (callable instanceof CoercionFunction) { diff --git a/packages/codemode/src/stdlib/object.ts b/packages/codemode/src/stdlib/object.ts index 73b548d433..49a61110dc 100644 --- a/packages/codemode/src/stdlib/object.ts +++ b/packages/codemode/src/stdlib/object.ts @@ -1,6 +1,6 @@ import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js" import { isBlockedMember } from "../tool-runtime.js" -import { isSandboxValue, SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js" +import { isSandboxValue, SandboxMap, SandboxPromise, SandboxSet, SandboxURLSearchParams } from "../values.js" import { boundedData, coerceToString } from "./value.js" export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"]) @@ -10,13 +10,23 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node) const requireObject = (): Record => { const input = args[0] - const value = boundedData(args[0], `Object.${name} input`) if (Array.isArray(input)) return input as unknown as Record - if (isSandboxValue(value)) return {} - if (value === null || typeof value !== "object") { - throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node) + if (isSandboxValue(input)) return {} + if (input instanceof SandboxPromise) { + throw new InterpreterRuntimeError( + `Object.${name} received an un-awaited Promise; await it before inspecting the result.`, + node, + "InvalidDataValue", + ) } - return value as Record + if (input === null || typeof input !== "object") { + throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue") + } + const prototype = Object.getPrototypeOf(input) + if (prototype !== null && prototype !== Object.prototype) { + throw new InterpreterRuntimeError(`Object.${name} expects a data object or array.`, node, "InvalidDataValue") + } + return input as Record } const guardedSet = (out: Record, key: string, item: unknown): void => { if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node) @@ -28,15 +38,8 @@ export const invokeObjectMethod = (name: string, args: Array, node: Ast guardedSet(out, coerceToString(key), item) } switch (name) { - case "keys": { - const value = boundedData(args[0], "Object.keys input") - if (isSandboxValue(value)) return [] - if (Array.isArray(value)) return Object.keys(value) - if (value === null || typeof value !== "object") { - throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node) - } - return Object.keys(value) - } + case "keys": + return Object.keys(requireObject()) case "values": return Object.values(requireObject()) case "entries": diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index 313b9c22cc..ce5f1e03a4 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -245,6 +245,12 @@ describe("promises at data boundaries", () => { expect(diagnostic.message).toContain("await tools.ns.tool(...)") }) + test("collection helpers do not let un-awaited promises cross the result boundary", async () => { + const diagnostic = await error(`return Array.from([Promise.resolve(1)])`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("un-awaited Promise") + }) + test("passing an un-awaited promise as a tool argument is a clear diagnostic", async () => { const diagnostic = await error(`return await tools.host.sleepy({ id: tools.host.sleepy({ id: 1 }) })`) expect(diagnostic.kind).toBe("InvalidDataValue") diff --git a/packages/codemode/test/stdlib.test.ts b/packages/codemode/test/stdlib.test.ts index f15a394850..d5870dab8d 100644 --- a/packages/codemode/test/stdlib.test.ts +++ b/packages/codemode/test/stdlib.test.ts @@ -773,6 +773,43 @@ describe("sandbox values at intra-sandbox checkpoints", () => { ) }) + test("Object.values/entries preserve nested object identity", async () => { + expect( + await value(` + const child = { selected: false } + const rows = { a: child } + Object.values(rows)[0].selected = true + return child.selected + `), + ).toBe(true) + expect( + await value(` + const child = { selected: false } + const rows = { a: child } + Object.entries(rows)[0][1].selected = true + return child.selected + `), + ).toBe(true) + }) + + test("Object enumeration preserves promises and callable references", async () => { + expect( + await value(` + const pending = Promise.resolve(1) + const source = { pending } + return [Object.keys(source), Object.hasOwn(source, "pending"), await Object.values(source)[0], await Object.entries(source)[0][1]] + `), + ).toEqual([["pending"], true, 1, 1]) + expect(await value(`return Object.values({ max: Math.max })[0](1, 2)`)).toBe(2) + }) + + test("Object enumeration rejects invalid receivers and gives promises an await hint", async () => { + const diagnostic = await error(`return Object.keys(Promise.resolve({ a: 1 }))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("await") + expect((await error(`return Object.keys(Math)`)).kind).toBe("InvalidDataValue") + }) + test("Object.assign keeps Maps usable", async () => { expect(await value(`const merged = Object.assign({}, { m: new Map([["a", 1]]) }); return merged.m.get("a")`)).toBe( 1, @@ -795,6 +832,53 @@ describe("sandbox values at intra-sandbox checkpoints", () => { expect(await value(`return Array.from([new Date(5)])[0].getTime()`)).toBe(5) }) + test("Array.from and Array.of preserve nested object identity", async () => { + expect( + await value(` + const child = { selected: false } + Array.from([child])[0].selected = true + return child.selected + `), + ).toBe(true) + expect( + await value(` + const child = { selected: false } + Array.of(child)[0].selected = true + return child.selected + `), + ).toBe(true) + }) + + test("Array.from and Array.of preserve promises and callable references", async () => { + expect( + await value(` + const pending = Promise.resolve(1) + return [await Array.from([pending])[0], await Array.of(pending)[0]] + `), + ).toEqual([1, 1]) + expect(await value(`return [Array.from([Math.max])[0](1, 2), Array.of(Math.max)[0](3, 4)]`)).toEqual([2, 4]) + }) + + test("Array.from preserves identity across supported collection shapes", async () => { + expect( + await value(` + const child = { selected: false } + const fromArrayLike = Array.from({ 0: child, length: 1 }) + const fromMap = Array.from(new Map([["child", child]])) + const fromSet = Array.from(new Set([child])) + fromArrayLike[0].selected = true + return [fromMap[0][1] === child, fromSet[0] === child, child.selected] + `), + ).toEqual([true, true, true]) + }) + + test("Array.from rejects invalid receivers and gives promises an await hint", async () => { + const diagnostic = await error(`return Array.from(Promise.resolve([1]))`) + expect(diagnostic.kind).toBe("InvalidDataValue") + expect(diagnostic.message).toContain("await") + expect((await error(`return Array.from(() => 1)`)).kind).toBe("InvalidDataValue") + }) + test("regexes stay callable through Object.values", async () => { expect(await value(`return Object.values({ r: /ab+/ })[0].test("abb")`)).toBe(true) }) From da68e2865e01d1a77042549f203a7e42184fd6c3 Mon Sep 17 00:00:00 2001 From: James Long Date: Tue, 7 Jul 2026 14:49:11 -0400 Subject: [PATCH 15/34] feat(tui): wire move session command (#35203) --- packages/client/src/effect/api/api.ts | 194 ++++++++------- .../client/src/effect/generated/client.ts | 229 +++++++++--------- .../client/src/promise/generated/client.ts | 14 ++ .../client/src/promise/generated/types.ts | 14 ++ packages/plugin/src/tui.ts | 1 + packages/protocol/src/groups/session.ts | 19 ++ packages/server/src/handlers/session.ts | 41 +++- packages/server/src/routes.ts | 2 + .../tui/src/component/dialog-move-session.tsx | 11 +- .../component/dialog-project-copy-name.tsx | 95 ++++++++ packages/tui/src/component/prompt/index.tsx | 11 +- packages/tui/src/component/prompt/move.tsx | 99 ++++---- packages/tui/src/config/keybind.ts | 1 + packages/tui/src/context/data.tsx | 6 + .../src/feature-plugins/sidebar/footer.tsx | 10 +- .../src/routes/home/session-destination.tsx | 2 +- packages/tui/src/routes/session/sidebar.tsx | 7 +- packages/tui/test/cli/tui/data.test.tsx | 62 +++++ 18 files changed, 546 insertions(+), 272 deletions(-) create mode 100644 packages/tui/src/component/dialog-project-copy-name.tsx diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index e0ab861dbc..d34ff3f07e 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -111,158 +111,167 @@ export type Endpoint4_8Input = { export type Endpoint4_8Output = EffectValue> export type SessionRenameOperation = (input: Endpoint4_8Input) => Effect.Effect -type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Request = Parameters[0] export type Endpoint4_9Input = { readonly sessionID: Endpoint4_9Request["params"]["sessionID"] - readonly id?: Endpoint4_9Request["payload"]["id"] - readonly prompt: Endpoint4_9Request["payload"]["prompt"] - readonly delivery?: Endpoint4_9Request["payload"]["delivery"] - readonly resume?: Endpoint4_9Request["payload"]["resume"] + readonly destination: Endpoint4_9Request["payload"]["destination"] + readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"] } -export type Endpoint4_9Output = EffectValue>["data"] -export type SessionPromptOperation = (input: Endpoint4_9Input) => Effect.Effect +export type Endpoint4_9Output = EffectValue> +export type SessionMoveOperation = (input: Endpoint4_9Input) => Effect.Effect -type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Request = Parameters[0] export type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] readonly id?: Endpoint4_10Request["payload"]["id"] - readonly command: Endpoint4_10Request["payload"]["command"] - readonly arguments?: Endpoint4_10Request["payload"]["arguments"] - readonly agent?: Endpoint4_10Request["payload"]["agent"] - readonly model?: Endpoint4_10Request["payload"]["model"] - readonly files?: Endpoint4_10Request["payload"]["files"] - readonly agents?: Endpoint4_10Request["payload"]["agents"] + readonly prompt: Endpoint4_10Request["payload"]["prompt"] readonly delivery?: Endpoint4_10Request["payload"]["delivery"] readonly resume?: Endpoint4_10Request["payload"]["resume"] } -export type Endpoint4_10Output = EffectValue>["data"] -export type SessionCommandOperation = (input: Endpoint4_10Input) => Effect.Effect +export type Endpoint4_10Output = EffectValue>["data"] +export type SessionPromptOperation = (input: Endpoint4_10Input) => Effect.Effect -type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Request = Parameters[0] export type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] readonly id?: Endpoint4_11Request["payload"]["id"] - readonly skill: Endpoint4_11Request["payload"]["skill"] + readonly command: Endpoint4_11Request["payload"]["command"] + readonly arguments?: Endpoint4_11Request["payload"]["arguments"] + readonly agent?: Endpoint4_11Request["payload"]["agent"] + readonly model?: Endpoint4_11Request["payload"]["model"] + readonly files?: Endpoint4_11Request["payload"]["files"] + readonly agents?: Endpoint4_11Request["payload"]["agents"] + readonly delivery?: Endpoint4_11Request["payload"]["delivery"] readonly resume?: Endpoint4_11Request["payload"]["resume"] } -export type Endpoint4_11Output = EffectValue> -export type SessionSkillOperation = (input: Endpoint4_11Input) => Effect.Effect +export type Endpoint4_11Output = EffectValue>["data"] +export type SessionCommandOperation = (input: Endpoint4_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Request = Parameters[0] export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] - readonly text: Endpoint4_12Request["payload"]["text"] - readonly description?: Endpoint4_12Request["payload"]["description"] - readonly metadata?: Endpoint4_12Request["payload"]["metadata"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly skill: Endpoint4_12Request["payload"]["skill"] readonly resume?: Endpoint4_12Request["payload"]["resume"] } -export type Endpoint4_12Output = EffectValue> -export type SessionSyntheticOperation = (input: Endpoint4_12Input) => Effect.Effect +export type Endpoint4_12Output = EffectValue> +export type SessionSkillOperation = (input: Endpoint4_12Input) => Effect.Effect -type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Request = Parameters[0] export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly id?: Endpoint4_13Request["payload"]["id"] - readonly command: Endpoint4_13Request["payload"]["command"] + readonly text: Endpoint4_13Request["payload"]["text"] + readonly description?: Endpoint4_13Request["payload"]["description"] + readonly metadata?: Endpoint4_13Request["payload"]["metadata"] + readonly resume?: Endpoint4_13Request["payload"]["resume"] } -export type Endpoint4_13Output = EffectValue> -export type SessionShellOperation = (input: Endpoint4_13Input) => Effect.Effect +export type Endpoint4_13Output = EffectValue> +export type SessionSyntheticOperation = (input: Endpoint4_13Input) => Effect.Effect -type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Request = Parameters[0] export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] readonly id?: Endpoint4_14Request["payload"]["id"] + readonly command: Endpoint4_14Request["payload"]["command"] } -export type Endpoint4_14Output = EffectValue>["data"] -export type SessionCompactOperation = (input: Endpoint4_14Input) => Effect.Effect +export type Endpoint4_14Output = EffectValue> +export type SessionShellOperation = (input: Endpoint4_14Input) => Effect.Effect -type Endpoint4_15Request = Parameters[0] -export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -export type Endpoint4_15Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_15Input) => Effect.Effect - -type Endpoint4_16Request = Parameters[0] -export type Endpoint4_16Input = { - readonly sessionID: Endpoint4_16Request["params"]["sessionID"] - readonly messageID: Endpoint4_16Request["payload"]["messageID"] - readonly files?: Endpoint4_16Request["payload"]["files"] +type Endpoint4_15Request = Parameters[0] +export type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly id?: Endpoint4_15Request["payload"]["id"] } -export type Endpoint4_16Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_16Input) => Effect.Effect +export type Endpoint4_15Output = EffectValue>["data"] +export type SessionCompactOperation = (input: Endpoint4_15Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] -export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -export type Endpoint4_17Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_17Input) => Effect.Effect +type Endpoint4_16Request = Parameters[0] +export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } +export type Endpoint4_16Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint4_16Input) => Effect.Effect -type Endpoint4_18Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] +export type Endpoint4_17Input = { + readonly sessionID: Endpoint4_17Request["params"]["sessionID"] + readonly messageID: Endpoint4_17Request["payload"]["messageID"] + readonly files?: Endpoint4_17Request["payload"]["files"] +} +export type Endpoint4_17Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint4_17Input) => Effect.Effect + +type Endpoint4_18Request = Parameters[0] export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } -export type Endpoint4_18Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_18Input) => Effect.Effect +export type Endpoint4_18Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint4_18Input) => Effect.Effect -type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Request = Parameters[0] export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } -export type Endpoint4_19Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_19Input) => Effect.Effect +export type Endpoint4_19Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint4_19Input) => Effect.Effect -type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Request = Parameters[0] export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } -export type Endpoint4_20Output = EffectValue< +export type Endpoint4_20Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint4_20Input) => Effect.Effect + +type Endpoint4_21Request = Parameters[0] +export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] } +export type Endpoint4_21Output = EffectValue< ReturnType >["data"] export type SessionInstructionsEntryListOperation = ( - input: Endpoint4_20Input, -) => Effect.Effect - -type Endpoint4_21Request = Parameters[0] -export type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly key: Endpoint4_21Request["params"]["key"] - readonly value: Endpoint4_21Request["payload"]["value"] -} -export type Endpoint4_21Output = EffectValue> -export type SessionInstructionsEntryPutOperation = ( input: Endpoint4_21Input, ) => Effect.Effect -type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Request = Parameters[0] export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] readonly key: Endpoint4_22Request["params"]["key"] + readonly value: Endpoint4_22Request["payload"]["value"] } -export type Endpoint4_22Output = EffectValue< - ReturnType -> -export type SessionInstructionsEntryRemoveOperation = ( +export type Endpoint4_22Output = EffectValue> +export type SessionInstructionsEntryPutOperation = ( input: Endpoint4_22Input, ) => Effect.Effect -type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Request = Parameters[0] export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] - readonly after?: Endpoint4_23Request["query"]["after"] - readonly follow?: Endpoint4_23Request["query"]["follow"] + readonly key: Endpoint4_23Request["params"]["key"] } -export type Endpoint4_23Output = StreamValue>> -export type SessionLogOperation = (input: Endpoint4_23Input) => Stream.Stream +export type Endpoint4_23Output = EffectValue< + ReturnType +> +export type SessionInstructionsEntryRemoveOperation = ( + input: Endpoint4_23Input, +) => Effect.Effect -type Endpoint4_24Request = Parameters[0] -export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } -export type Endpoint4_24Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_24Input) => Effect.Effect +type Endpoint4_24Request = Parameters[0] +export type Endpoint4_24Input = { + readonly sessionID: Endpoint4_24Request["params"]["sessionID"] + readonly after?: Endpoint4_24Request["query"]["after"] + readonly follow?: Endpoint4_24Request["query"]["follow"] +} +export type Endpoint4_24Output = StreamValue>> +export type SessionLogOperation = (input: Endpoint4_24Input) => Stream.Stream -type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Request = Parameters[0] export type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } -export type Endpoint4_25Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_25Input) => Effect.Effect +export type Endpoint4_25Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint4_25Input) => Effect.Effect -type Endpoint4_26Request = Parameters[0] -export type Endpoint4_26Input = { - readonly sessionID: Endpoint4_26Request["params"]["sessionID"] - readonly messageID: Endpoint4_26Request["params"]["messageID"] +type Endpoint4_26Request = Parameters[0] +export type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] } +export type Endpoint4_26Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_26Input) => Effect.Effect + +type Endpoint4_27Request = Parameters[0] +export type Endpoint4_27Input = { + readonly sessionID: Endpoint4_27Request["params"]["sessionID"] + readonly messageID: Endpoint4_27Request["params"]["messageID"] } -export type Endpoint4_26Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_26Input) => Effect.Effect +export type Endpoint4_27Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_27Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -274,6 +283,7 @@ export interface SessionApi { readonly switchAgent: SessionSwitchAgentOperation readonly switchModel: SessionSwitchModelOperation readonly rename: SessionRenameOperation + readonly move: SessionMoveOperation readonly prompt: SessionPromptOperation readonly command: SessionCommandOperation readonly skill: SessionSkillOperation diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 68786a4ea3..008cfb294d 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -141,15 +141,27 @@ const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Inp Effect.mapError(mapClientError), ) -type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Request = Parameters[0] type Endpoint4_9Input = { readonly sessionID: Endpoint4_9Request["params"]["sessionID"] - readonly id?: Endpoint4_9Request["payload"]["id"] - readonly prompt: Endpoint4_9Request["payload"]["prompt"] - readonly delivery?: Endpoint4_9Request["payload"]["delivery"] - readonly resume?: Endpoint4_9Request["payload"]["resume"] + readonly destination: Endpoint4_9Request["payload"]["destination"] + readonly moveChanges?: Endpoint4_9Request["payload"]["moveChanges"] } const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => + raw["session.move"]({ + params: { sessionID: input["sessionID"] }, + payload: { destination: input["destination"], moveChanges: input["moveChanges"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Input = { + readonly sessionID: Endpoint4_10Request["params"]["sessionID"] + readonly id?: Endpoint4_10Request["payload"]["id"] + readonly prompt: Endpoint4_10Request["payload"]["prompt"] + readonly delivery?: Endpoint4_10Request["payload"]["delivery"] + readonly resume?: Endpoint4_10Request["payload"]["resume"] +} +const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, @@ -158,20 +170,20 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp Effect.map((value) => value.data), ) -type Endpoint4_10Request = Parameters[0] -type Endpoint4_10Input = { - readonly sessionID: Endpoint4_10Request["params"]["sessionID"] - readonly id?: Endpoint4_10Request["payload"]["id"] - readonly command: Endpoint4_10Request["payload"]["command"] - readonly arguments?: Endpoint4_10Request["payload"]["arguments"] - readonly agent?: Endpoint4_10Request["payload"]["agent"] - readonly model?: Endpoint4_10Request["payload"]["model"] - readonly files?: Endpoint4_10Request["payload"]["files"] - readonly agents?: Endpoint4_10Request["payload"]["agents"] - readonly delivery?: Endpoint4_10Request["payload"]["delivery"] - readonly resume?: Endpoint4_10Request["payload"]["resume"] +type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Input = { + readonly sessionID: Endpoint4_11Request["params"]["sessionID"] + readonly id?: Endpoint4_11Request["payload"]["id"] + readonly command: Endpoint4_11Request["payload"]["command"] + readonly arguments?: Endpoint4_11Request["payload"]["arguments"] + readonly agent?: Endpoint4_11Request["payload"]["agent"] + readonly model?: Endpoint4_11Request["payload"]["model"] + readonly files?: Endpoint4_11Request["payload"]["files"] + readonly agents?: Endpoint4_11Request["payload"]["agents"] + readonly delivery?: Endpoint4_11Request["payload"]["delivery"] + readonly resume?: Endpoint4_11Request["payload"]["resume"] } -const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => +const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => raw["session.command"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -190,28 +202,28 @@ const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10I Effect.map((value) => value.data), ) -type Endpoint4_11Request = Parameters[0] -type Endpoint4_11Input = { - readonly sessionID: Endpoint4_11Request["params"]["sessionID"] - readonly id?: Endpoint4_11Request["payload"]["id"] - readonly skill: Endpoint4_11Request["payload"]["skill"] - readonly resume?: Endpoint4_11Request["payload"]["resume"] +type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Input = { + readonly sessionID: Endpoint4_12Request["params"]["sessionID"] + readonly id?: Endpoint4_12Request["payload"]["id"] + readonly skill: Endpoint4_12Request["payload"]["skill"] + readonly resume?: Endpoint4_12Request["payload"]["resume"] } -const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => +const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => raw["session.skill"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_12Request = Parameters[0] -type Endpoint4_12Input = { - readonly sessionID: Endpoint4_12Request["params"]["sessionID"] - readonly text: Endpoint4_12Request["payload"]["text"] - readonly description?: Endpoint4_12Request["payload"]["description"] - readonly metadata?: Endpoint4_12Request["payload"]["metadata"] - readonly resume?: Endpoint4_12Request["payload"]["resume"] +type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Input = { + readonly sessionID: Endpoint4_13Request["params"]["sessionID"] + readonly text: Endpoint4_13Request["payload"]["text"] + readonly description?: Endpoint4_13Request["payload"]["description"] + readonly metadata?: Endpoint4_13Request["payload"]["metadata"] + readonly resume?: Endpoint4_13Request["payload"]["resume"] } -const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => +const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -222,41 +234,41 @@ const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12I }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_13Request = Parameters[0] -type Endpoint4_13Input = { - readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly id?: Endpoint4_13Request["payload"]["id"] - readonly command: Endpoint4_13Request["payload"]["command"] +type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Input = { + readonly sessionID: Endpoint4_14Request["params"]["sessionID"] + readonly id?: Endpoint4_14Request["payload"]["id"] + readonly command: Endpoint4_14Request["payload"]["command"] } -const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => +const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => raw["session.shell"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], command: input["command"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_14Request = Parameters[0] -type Endpoint4_14Input = { - readonly sessionID: Endpoint4_14Request["params"]["sessionID"] - readonly id?: Endpoint4_14Request["payload"]["id"] +type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Input = { + readonly sessionID: Endpoint4_15Request["params"]["sessionID"] + readonly id?: Endpoint4_15Request["payload"]["id"] } -const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => +const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_15Request = Parameters[0] -type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => +type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } +const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_16Request = Parameters[0] -type Endpoint4_16Input = { - readonly sessionID: Endpoint4_16Request["params"]["sessionID"] - readonly messageID: Endpoint4_16Request["payload"]["messageID"] - readonly files?: Endpoint4_16Request["payload"]["files"] +type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Input = { + readonly sessionID: Endpoint4_17Request["params"]["sessionID"] + readonly messageID: Endpoint4_17Request["payload"]["messageID"] + readonly files?: Endpoint4_17Request["payload"]["files"] } -const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => +const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -265,61 +277,61 @@ const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16I Effect.map((value) => value.data), ) -type Endpoint4_17Request = Parameters[0] -type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Request = Parameters[0] type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Request = Parameters[0] type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] } +const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_21Request = Parameters[0] -type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly key: Endpoint4_21Request["params"]["key"] - readonly value: Endpoint4_21Request["payload"]["value"] +type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly key: Endpoint4_22Request["params"]["key"] + readonly value: Endpoint4_22Request["payload"]["value"] } -const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => +const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => raw["session.instructions.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_22Request = Parameters[0] -type Endpoint4_22Input = { - readonly sessionID: Endpoint4_22Request["params"]["sessionID"] - readonly key: Endpoint4_22Request["params"]["key"] +type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Input = { + readonly sessionID: Endpoint4_23Request["params"]["sessionID"] + readonly key: Endpoint4_23Request["params"]["key"] } -const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => +const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_23Request = Parameters[0] -type Endpoint4_23Input = { - readonly sessionID: Endpoint4_23Request["params"]["sessionID"] - readonly after?: Endpoint4_23Request["query"]["after"] - readonly follow?: Endpoint4_23Request["query"]["follow"] +type Endpoint4_24Request = Parameters[0] +type Endpoint4_24Input = { + readonly sessionID: Endpoint4_24Request["params"]["sessionID"] + readonly after?: Endpoint4_24Request["query"]["after"] + readonly follow?: Endpoint4_24Request["query"]["follow"] } -const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => +const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => Stream.unwrap( raw["session.log"]({ params: { sessionID: input["sessionID"] }, @@ -330,22 +342,22 @@ const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23I ), ) -type Endpoint4_24Request = Parameters[0] -type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } -const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => - raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Request = Parameters[0] type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] } const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_26Request = Parameters[0] +type Endpoint4_26Input = { readonly sessionID: Endpoint4_26Request["params"]["sessionID"] } +const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_26Request = Parameters[0] -type Endpoint4_26Input = { - readonly sessionID: Endpoint4_26Request["params"]["sessionID"] - readonly messageID: Endpoint4_26Request["params"]["messageID"] +type Endpoint4_27Request = Parameters[0] +type Endpoint4_27Input = { + readonly sessionID: Endpoint4_27Request["params"]["sessionID"] + readonly messageID: Endpoint4_27Request["params"]["messageID"] } -const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) => +const Endpoint4_27 = (raw: RawClient["server.session"]) => (input: Endpoint4_27Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -361,22 +373,23 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ switchAgent: Endpoint4_6(raw), switchModel: Endpoint4_7(raw), rename: Endpoint4_8(raw), - prompt: Endpoint4_9(raw), - command: Endpoint4_10(raw), - skill: Endpoint4_11(raw), - synthetic: Endpoint4_12(raw), - shell: Endpoint4_13(raw), - compact: Endpoint4_14(raw), - wait: Endpoint4_15(raw), - revertStage: Endpoint4_16(raw), - revertClear: Endpoint4_17(raw), - revertCommit: Endpoint4_18(raw), - context: Endpoint4_19(raw), - instructions: { entry: { list: Endpoint4_20(raw), put: Endpoint4_21(raw), remove: Endpoint4_22(raw) } }, - log: Endpoint4_23(raw), - interrupt: Endpoint4_24(raw), - background: Endpoint4_25(raw), - message: Endpoint4_26(raw), + move: Endpoint4_9(raw), + prompt: Endpoint4_10(raw), + command: Endpoint4_11(raw), + skill: Endpoint4_12(raw), + synthetic: Endpoint4_13(raw), + shell: Endpoint4_14(raw), + compact: Endpoint4_15(raw), + wait: Endpoint4_16(raw), + revertStage: Endpoint4_17(raw), + revertClear: Endpoint4_18(raw), + revertCommit: Endpoint4_19(raw), + context: Endpoint4_20(raw), + instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } }, + log: Endpoint4_24(raw), + interrupt: Endpoint4_25(raw), + background: Endpoint4_26(raw), + message: Endpoint4_27(raw), }) type Endpoint5_0Request = Parameters[0] diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 575208a614..79ecf0e630 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -23,6 +23,8 @@ import type { SessionSwitchModelOutput, SessionRenameInput, SessionRenameOutput, + SessionMoveInput, + SessionMoveOutput, SessionPromptInput, SessionPromptOutput, SessionCommandInput, @@ -489,6 +491,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + move: (input: SessionMoveInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/move`, + body: { destination: input["destination"], moveChanges: input["moveChanges"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), prompt: (input: SessionPromptInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionPromptOutput }>( { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 1b674e5ef1..3bb87c1111 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -526,6 +526,20 @@ export type SessionRenameInput = { export type SessionRenameOutput = void +export type SessionMoveInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly destination: { + readonly destination: { readonly directory: string } + readonly moveChanges?: boolean | undefined + }["destination"] + readonly moveChanges?: { + readonly destination: { readonly directory: string } + readonly moveChanges?: boolean | undefined + }["moveChanges"] +} + +export type SessionMoveOutput = void + export type SessionPromptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index a9d8d7dcd3..8e2fee5908 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -475,6 +475,7 @@ export type TuiHostSlotMap = { } sidebar_footer: { session_id: string + directory: string } } diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index bc9cd1ab2f..eac40a4c22 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -269,6 +269,25 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.move", "/api/session/:sessionID/move", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + destination: Schema.Struct({ directory: AbsolutePath }), + moveChanges: Schema.Boolean.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, InvalidRequestError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.move", + summary: "Move session", + description: "Move a session to another project directory, optionally transferring local changes.", + }), + ), + ) .add( HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { params: { sessionID: Session.ID }, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 6059dcb89a..e3160cc231 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,5 +1,6 @@ import { SessionV2 } from "@opencode-ai/core/session" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" @@ -8,8 +9,8 @@ import { ConflictError, CommandEvaluationError, CommandNotFoundError, - InvalidCursorError, InvalidRequestError, + InvalidCursorError, MessageNotFoundError, ServiceUnavailableError, SessionBusyError, @@ -24,6 +25,7 @@ const DefaultSessionsLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service + const moveSession = yield* MoveSession.Service return handlers .handle( @@ -201,6 +203,43 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl return HttpApiSchema.NoContent.make() }), ) + .handle( + "session.move", + Effect.fn(function* (ctx) { + yield* moveSession.moveSession({ + sessionID: ctx.params.sessionID, + destination: ctx.payload.destination, + moveChanges: ctx.payload.moveChanges, + }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("MoveSession.DestinationProjectMismatchError", () => + Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })), + ), + Effect.catchTag("MoveSession.ApplyChangesError", () => + Effect.fail( + new InvalidRequestError({ + message: + "Unable to apply your changes in the destination directory. The files may conflict with existing changes.", + }), + ), + ), + Effect.catchTag("MoveSession.CaptureChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + Effect.catchTag("MoveSession.ResetSourceChangesError", (error) => + Effect.fail(new InvalidRequestError({ message: error.message })), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.prompt", Effect.fn(function* (ctx) { diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index e4d1ae4930..773b01ad2f 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -8,6 +8,7 @@ import { Observability } from "@opencode-ai/core/observability" import { Credential } from "@opencode-ai/core/credential" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Project } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" @@ -37,6 +38,7 @@ const applicationServices = LayerNode.group([ httpClient, ToolOutputStore.cleanupNode, Job.node, + MoveSession.node, Project.node, SessionV2.node, PluginRuntime.providerNode, diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 405f2e0693..e0beff09ed 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -19,8 +19,9 @@ import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise" import { useRoute } from "../context/route" +import { DialogProjectCopyName } from "./dialog-project-copy-name" -export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } +export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } type ProjectDirectory = ProjectDirectoriesOutput[number] type DialogMoveSessionProps = { @@ -291,6 +292,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (await removedCurrent(deletingCurrent)) return } + async function create() { + const name = await DialogProjectCopyName.show(dialog) + if (name === null) return + props.onSelect({ type: "new", name }) + } + const fullHeight = createMemo(() => Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)), ) @@ -334,7 +341,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { { command: "dialog.move_session.new", title: "new", - onTrigger: () => props.onSelect({ type: "new" }), + onTrigger: () => void create(), }, { command: "dialog.move_session.delete", diff --git a/packages/tui/src/component/dialog-project-copy-name.tsx b/packages/tui/src/component/dialog-project-copy-name.tsx new file mode 100644 index 0000000000..7ac357e1c8 --- /dev/null +++ b/packages/tui/src/component/dialog-project-copy-name.tsx @@ -0,0 +1,95 @@ +import { InputRenderable, TextAttributes } from "@opentui/core" +import { Slug } from "@opencode-ai/core/util/slug" +import { createSignal, onMount } from "solid-js" +import { useTuiConfig } from "../config" +import { useTheme } from "../context/theme" +import { useBindings, useCommandShortcut } from "../keymap" +import { useDialog, type DialogContext } from "../ui/dialog" + +export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) { + const dialog = useDialog() + const { theme } = useTheme() + const tuiConfig = useTuiConfig() + const generateShortcut = useCommandShortcut("dialog.project_copy.generate") + const [inputTarget, setInputTarget] = createSignal() + let input: InputRenderable + + function generate() { + input.value = Slug.create() + input.gotoLineEnd() + } + + function confirm() { + props.onConfirm(slugify(input.value) || Slug.create()) + } + + useBindings(() => ({ + target: inputTarget, + enabled: inputTarget() !== undefined, + priority: 1, + commands: [ + { + name: "dialog.project_copy.generate", + title: "Generate project copy name", + category: "Dialog", + run: generate, + }, + ], + bindings: tuiConfig.keybinds.get("dialog.project_copy.generate"), + })) + + onMount(() => { + dialog.setSize("medium") + setTimeout(() => { + if (!input || input.isDestroyed) return + input.focus() + }, 1) + }) + + return ( + + + + Name project copy + + dialog.clear()}> + esc + + + { + input = value + setInputTarget(value) + }} + onSubmit={confirm} + placeholder="Project copy name" + placeholderColor={theme.textMuted} + textColor={theme.text} + focusedTextColor={theme.text} + cursorColor={theme.text} + /> + + + enter submit + + + {generateShortcut()} generate one + + + + ) +} + +DialogProjectCopyName.show = (dialog: DialogContext) => + new Promise((resolve) => { + dialog.replace(() => , () => resolve(null)) + }) + +function slugify(input: string) { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") +} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 7aac079b03..c15f5547b2 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -218,7 +218,10 @@ export function Prompt(props: PromptProps) { const editorContextLabelState = createMemo(() => editor.labelState()) const [auto, setAuto] = createSignal() const workspace = usePromptWorkspace(props.sessionID) - const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) + const move = usePromptMove({ + projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(), + sessionID: () => props.sessionID, + }) const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const connected = useConnected() @@ -955,13 +958,13 @@ export function Prompt(props: PromptProps) { if (workspace.creating() || move.creating()) return false if (auto()?.visible) return false if (!store.prompt.text) return false - const agent = local.agent.current() - if (!agent) return false const trimmed = store.prompt.text.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { void exit() return true } + const agent = local.agent.current() + if (!agent) return false const selectedModel = local.model.current() if (!selectedModel) { void promptModelWarning() @@ -991,7 +994,7 @@ export function Prompt(props: PromptProps) { const selectedWorkspace = workspace.selection() const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined - const directory = await move.getDirectory(store.prompt.text) + const directory = await move.getDirectory() if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) const location = data.location.default() diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 9fdd5c9d6a..1139385034 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -4,12 +4,12 @@ import { useTuiPaths } from "../../context/runtime" import { errorMessage } from "../../util/error" import { useDialog } from "../../ui/dialog" import { useSDK } from "../../context/sdk" -import { useSync } from "../../context/sync" import { useToast } from "../../ui/toast" import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session" import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes" import { useHomeSessionDestination } from "../../routes/home/session-destination" import { useProject } from "../../context/project" +import { useData } from "../../context/data" function moveReminderText(directory: string) { return `The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.` @@ -18,38 +18,33 @@ function moveReminderText(directory: string) { export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) { const dialog = useDialog() const sdk = useSDK() - const sync = useSync() const toast = useToast() const homeDestination = useHomeSessionDestination() const project = useProject() + const data = useData() const paths = useTuiPaths() const [creating, setCreating] = createSignal(false) const [creatingDots, setCreatingDots] = createSignal(3) const [progress, setProgress] = createSignal() - async function create(context?: string) { - const projectID = input.projectID() + async function create(name: string) { + const projectID = await resolveProjectID() if (!projectID) return setCreating(true) setProgress("Creating copy") try { - const generated = await sdk.client.experimental.projectCopy.generateName( - { projectID, context }, - { throwOnError: true }, - ) const result = await sdk.api.projectCopy.create({ projectID, location: { directory: project.instance.directory() || paths.cwd }, strategy: "git_worktree", directory: path.join(paths.worktree, projectID.slice(0, 6)), - name: generated.data.name, + name, }) const directory = result.directory if (!directory) throw new Error("No project copy directory returned") - // Call a location-based route to make sure it's bootstrapped - // before moving on - await sdk.client.path.get({ directory }, { throwOnError: true }) + // Call a location-based route to make sure it's bootstrapped before moving on. + await sdk.api.location.get({ location: { directory } }) setProgress("Creating session") return directory @@ -62,11 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } } - function open() { - const projectID = input.projectID() - if (!projectID) return + async function open() { + const projectID = await resolveProjectID() + if (!projectID) { + toast.show({ message: "Unable to determine current project", variant: "error" }) + return + } const sessionID = input.sessionID() - const session = sessionID ? sync.session.get(sessionID) : undefined + const session = sessionID ? await resolveSession(sessionID) : undefined dialog.replace(() => ( string | undefined; sess (session ? { type: "directory", - directory: session.directory, - subdirectory: !!session.path, + directory: session.location.directory, + subdirectory: !!session.subpath, } : { type: "directory", @@ -98,26 +96,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess )) } - function sessionContext(sessionID: string) { - const session = sync.session.get(sessionID) - const messages = (sync.data.message[sessionID] ?? []) - .slice(-6) - .map((message) => - [ - message.role + ":", - ...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])), - ].join(" "), - ) - return [session?.title, ...messages].filter(Boolean).join("\n") || undefined - } - async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) { - const session = sync.session.get(sessionID) - const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined) + const session = await resolveSession(sessionID) + const status = await sdk.client.vcs.status({ directory: session?.location.directory }).catch(() => undefined) const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no" if (!choice) return dialog.clear() - const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory + const directory = selection.type === "new" ? await create(selection.name) : selection.directory if (!directory) { setProgress(undefined) dialog.clear() @@ -125,27 +110,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } setProgress("Moving session") try { - await sdk.client.experimental.controlPlane.moveSession( - { - sessionID, - destination: { directory }, - moveChanges: choice === "yes", - }, - { throwOnError: true }, - ) - await sdk.client.session - .promptAsync({ - sessionID, - directory, - noReply: true, - parts: [ - { - type: "text", - text: moveReminderText(directory), - synthetic: true, - }, - ], - }) + await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" }) + await sdk.api.session + .synthetic({ sessionID, text: moveReminderText(directory), resume: false }) .catch(() => undefined) dialog.clear() } catch (error) { @@ -157,16 +124,34 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess } } + async function resolveProjectID() { + const projectID = input.projectID() + if (projectID) return projectID + const sessionID = input.sessionID() + if (sessionID) return (await resolveSession(sessionID))?.projectID + return sdk.api.project + .current({ location: { directory: project.instance.directory() || paths.cwd } }) + .then((project) => project.id) + .catch(() => undefined) + } + + async function resolveSession(sessionID: string) { + const session = data.session.get(sessionID) + if (session) return session + await data.session.refresh(sessionID).catch(() => undefined) + return data.session.get(sessionID) + } + const pending = createMemo(() => Boolean(homeDestination?.destination())) const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new") - async function getDirectory(context?: string) { + async function getDirectory() { const value = homeDestination?.destination() if (!value) return if (value.type === "directory") { return value.directory } - return await create(context) + return await create(value.name) } function startSubmit() { diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 7acb093a7b..0f97f8277d 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -207,6 +207,7 @@ export const Definitions = { "dialog.select.end": keybind("end", "Move to last dialog item"), "dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), + "dialog.project_copy.generate": keybind("tab", "Generate project copy name"), "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), "dialog.move_session.new": keybind("ctrl+m", "New project copy"), "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 723f80633d..4136b5dc36 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -304,6 +304,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "title", event.data.title) break + case "session.moved": + if (store.session.info[event.data.sessionID]) { + setStore("session", "info", event.data.sessionID, "location", mutable(event.data.location)) + setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath) + } + break case "session.prompt.promoted": { message.update(event.data.sessionID, (draft, index) => { const position = index.get(event.data.inputID) diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index c59046a017..6fb51ffafc 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -6,7 +6,7 @@ import { useTuiPaths } from "../../context/runtime" const id = "internal:sidebar-footer" -function View(props: { api: TuiPluginApi; sessionID: string }) { +function View(props: { api: TuiPluginApi; directory: string }) { const paths = useTuiPaths() const theme = () => props.api.theme.current const has = createMemo(() => @@ -17,10 +17,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false)) const show = createMemo(() => !has() && !done()) const path = createMemo(() => { - const session = props.api.state.session.get(props.sessionID) - const dir = session?.directory || props.api.state.path.directory || paths.cwd - const out = abbreviateHome(dir, paths.home) - const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined + const out = abbreviateHome(props.directory, paths.home) + const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined const text = branch ? out + ":" + branch : out const list = text.split("/") return { @@ -84,7 +82,7 @@ const tui: TuiPlugin = async (api) => { order: 100, slots: { sidebar_footer(_ctx, props) { - return + return }, }, }) diff --git a/packages/tui/src/routes/home/session-destination.tsx b/packages/tui/src/routes/home/session-destination.tsx index 352010840b..35611b00b7 100644 --- a/packages/tui/src/routes/home/session-destination.tsx +++ b/packages/tui/src/routes/home/session-destination.tsx @@ -10,7 +10,7 @@ import { import { useSync } from "../../context/sync" import { useTuiPaths } from "../../context/runtime" -export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } +export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string } type Context = { destination: Accessor diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 5639f6451f..bd415768a2 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -85,7 +85,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - + Open diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index b7b349a196..ef2aa57a9b 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -108,6 +108,68 @@ test("refreshes resources into reactive getters", async () => { } }) +test("updates session location when moved", async () => { + const events = createEventStream() + const destination = "/tmp/opencode-moved" + const calls = createFetch((url) => { + if (url.pathname === "/api/session/ses_test") + return json({ + data: { + id: "ses_test", + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Test session", + location: { directory }, + }, + }) + }, events) + let data!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + data = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + await data.session.refresh("ses_test") + emitEvent(events, { + id: "evt_moved_1", + created: 1, + type: "session.moved", + durable: durable("ses_test"), + data: { + sessionID: "ses_test", + location: { directory: destination }, + subpath: "packages/cli", + }, + }) + await wait(() => data.session.get("ses_test")?.location.directory === destination) + expect(data.session.get("ses_test")?.subpath).toBe("packages/cli") + } finally { + app.renderer.destroy() + } +}) + test("restores running manual compaction before applying live deltas", async () => { const events = createEventStream() const calls = createFetch((url) => { From 81f6e06681859aece656bd4fb15d35b1bcff1b2a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 15:17:45 -0400 Subject: [PATCH 16/34] feat: enable background subagents by default --- packages/app/e2e/utils/mock-server.ts | 2 +- packages/cli/src/mini/footer.ts | 2 - packages/cli/src/mini/footer.view.tsx | 5 +- packages/cli/src/mini/mini.ts | 4 -- packages/cli/src/mini/runtime.lifecycle.ts | 2 - packages/cli/src/mini/runtime.ts | 5 -- packages/cli/src/mini/types.ts | 1 - packages/opencode/src/effect/runtime-flags.ts | 1 - .../instance/httpapi/handlers/experimental.ts | 5 +- packages/opencode/src/tool/task.ts | 15 +----- .../test/cli/run/footer.view.test.tsx | 11 ++-- .../opencode/test/cli/run/runtime.test.ts | 7 --- .../test/effect/runtime-flags.test.ts | 1 - .../test/server/httpapi-exercise/index.ts | 6 ++- .../test/server/session-actions.test.ts | 49 ++++++++++++++++-- packages/opencode/test/tool/registry.test.ts | 7 +-- packages/opencode/test/tool/task.test.ts | 50 ++++--------------- packages/tui/src/context/sync.tsx | 6 --- packages/tui/test/fixture/tui-sdk.ts | 2 +- packages/web/src/content/docs/ar/cli.mdx | 1 - packages/web/src/content/docs/bs/cli.mdx | 1 - packages/web/src/content/docs/cli.mdx | 1 - packages/web/src/content/docs/da/cli.mdx | 1 - packages/web/src/content/docs/de/cli.mdx | 1 - packages/web/src/content/docs/es/cli.mdx | 1 - packages/web/src/content/docs/fr/cli.mdx | 1 - packages/web/src/content/docs/it/cli.mdx | 1 - packages/web/src/content/docs/ja/cli.mdx | 1 - packages/web/src/content/docs/ko/cli.mdx | 1 - packages/web/src/content/docs/nb/cli.mdx | 1 - packages/web/src/content/docs/pl/cli.mdx | 1 - packages/web/src/content/docs/pt-br/cli.mdx | 1 - packages/web/src/content/docs/ru/cli.mdx | 1 - packages/web/src/content/docs/th/cli.mdx | 1 - packages/web/src/content/docs/tr/cli.mdx | 1 - packages/web/src/content/docs/zh-cn/cli.mdx | 1 - packages/web/src/content/docs/zh-tw/cli.mdx | 1 - 37 files changed, 71 insertions(+), 128 deletions(-) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index e5df40c7d2..53adfd155b 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -55,7 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const path = url.pathname if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/health") return json(route, { healthy: true }) - if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") diff --git a/packages/cli/src/mini/footer.ts b/packages/cli/src/mini/footer.ts index ab53e9162e..0a2510f167 100644 --- a/packages/cli/src/mini/footer.ts +++ b/packages/cli/src/mini/footer.ts @@ -84,7 +84,6 @@ type RunFooterOptions = { theme: RunTheme keymap: Keymap tuiConfig: RunTuiConfig - backgroundSubagents: boolean diffStyle: RunDiffStyle onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise @@ -326,7 +325,6 @@ export class RunFooter implements FooterApi { theme: footer.theme, diffStyle: options.diffStyle, tuiConfig: options.tuiConfig, - backgroundSubagents: options.backgroundSubagents, history: footer.history, agent: options.agentLabel, onSubmit: footer.handlePrompt, diff --git a/packages/cli/src/mini/footer.view.tsx b/packages/cli/src/mini/footer.view.tsx index cabb4ee1c8..a7824dddcf 100644 --- a/packages/cli/src/mini/footer.view.tsx +++ b/packages/cli/src/mini/footer.view.tsx @@ -89,7 +89,6 @@ type RunFooterViewProps = { theme: () => RunTheme diffStyle?: RunDiffStyle tuiConfig: RunTuiConfig - backgroundSubagents: boolean history?: () => RunPrompt[] agent: string onSubmit: (input: RunPrompt) => boolean @@ -169,9 +168,7 @@ export function RunFooterView(props: RunFooterViewProps) { return tabs().findIndex((item) => item.sessionID === sessionID) + 1 }) - const foregroundSubagents = createMemo( - () => props.backgroundSubagents && activeTabs().some((item) => !item.background), - ) + const foregroundSubagents = createMemo(() => activeTabs().some((item) => !item.background)) const model = createMemo(() => { const current = props.currentModel() return current ? modelInfo(props.providers(), current) : { model: props.state().model, provider: undefined } diff --git a/packages/cli/src/mini/mini.ts b/packages/cli/src/mini/mini.ts index 9259bd5dbe..8a47529816 100644 --- a/packages/cli/src/mini/mini.ts +++ b/packages/cli/src/mini/mini.ts @@ -1,6 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" -import { truthy } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { Effect } from "effect" import path from "node:path" @@ -84,9 +83,6 @@ export async function runMini(input: MiniCommandInput) { files: [], initialInput, thinking: true, - backgroundSubagents: - truthy("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS") || - (process.env.OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS === undefined && truthy("OPENCODE_EXPERIMENTAL")), replay: input.replay ?? true, replayLimit: input.replayLimit, demo: input.demo, diff --git a/packages/cli/src/mini/runtime.lifecycle.ts b/packages/cli/src/mini/runtime.lifecycle.ts index 4700f7c479..e25c29f02e 100644 --- a/packages/cli/src/mini/runtime.lifecycle.ts +++ b/packages/cli/src/mini/runtime.lifecycle.ts @@ -64,7 +64,6 @@ export type LifecycleInput = { model: RunInput["model"] variant: string | undefined tuiConfig: RunTuiConfig | Promise - backgroundSubagents: boolean onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise onQuestionReject: (input: QuestionReject) => void | Promise @@ -236,7 +235,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { if (state.demo?.permission(next)) { return @@ -876,7 +873,6 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?: files: input.files, initialInput: input.initialInput, thinking: input.thinking, - backgroundSubagents: input.backgroundSubagents, replay: input.replay, replayLimit: input.replayLimit, demo: input.demo, @@ -928,7 +924,6 @@ export async function runInteractiveMode( files: input.files, initialInput: input.initialInput, thinking: input.thinking, - backgroundSubagents: input.backgroundSubagents, replay: input.replay, replayLimit: input.replayLimit, demo: input.demo, diff --git a/packages/cli/src/mini/types.ts b/packages/cli/src/mini/types.ts index 1077dc1c9f..6df9d24fa7 100644 --- a/packages/cli/src/mini/types.ts +++ b/packages/cli/src/mini/types.ts @@ -135,7 +135,6 @@ export type RunInput = { files: RunFilePart[] initialInput?: string thinking: boolean - backgroundSubagents: boolean demo?: boolean } diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 65e02f0763..95dc7f0b18 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -40,7 +40,6 @@ export class Service extends ConfigService.Service()("@opencode/Runtime enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"), enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"), experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"), - experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"), experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"), experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index 18d64188a4..2e2292f1af 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -3,7 +3,6 @@ import { Agent } from "@/agent/agent" import { Job } from "@/job" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" -import { RuntimeFlags } from "@/effect/runtime-flags" import { MCP } from "@/mcp" import { Project } from "@/project/project" import { Session } from "@/session/session" @@ -34,10 +33,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const worktreeSvc = yield* Worktree.Service const sessions = yield* Session.Service const jobs = yield* Job.Service - const flags = yield* RuntimeFlags.Service const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () { - return { backgroundSubagents: flags.experimentalBackgroundSubagents } + return { backgroundSubagents: true } }) const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { @@ -159,7 +157,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const sessionBackground = Effect.fn("ExperimentalHttpApi.sessionBackground")(function* (ctx: { params: { sessionID: SessionID } }) { - if (!flags.experimentalBackgroundSubagents) return false return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0 }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index f61cea1048..4039fd36fa 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -1,6 +1,5 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" -import { ToolJsonSchema } from "./json-schema" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Job } from "@/job" import { Session } from "@/session/session" @@ -12,7 +11,6 @@ import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" import { Effect, Exit, Schema, Scope } from "effect" import { EffectBridge } from "@/effect/bridge" -import { RuntimeFlags } from "@/effect/runtime-flags" import { Database } from "@opencode-ai/core/database/database" export interface TaskPromptOps { @@ -51,8 +49,6 @@ const BaseParameterFields = { command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }), } -const BaseParameters = Schema.Struct(BaseParameterFields) - export const Parameters = Schema.Struct({ ...BaseParameterFields, background: Schema.optional(Schema.Boolean).annotate({ @@ -86,7 +82,6 @@ export const TaskTool = Tool.define( const config = yield* Config.Service const sessions = yield* Session.Service const scope = yield* Scope.Scope - const flags = yield* RuntimeFlags.Service const database = yield* Database.Service const run = Effect.fn("TaskTool.execute")(function* ( @@ -95,11 +90,6 @@ export const TaskTool = Tool.define( ) { const cfg = yield* config.get() const runInBackground = params.background === true - if (runInBackground && !flags.experimentalBackgroundSubagents) { - return yield* Effect.fail( - new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"), - ) - } if (!ctx.extra?.bypassAgentCheck) { yield* ctx.ask({ @@ -333,11 +323,8 @@ export const TaskTool = Tool.define( }) return { - description: flags.experimentalBackgroundSubagents - ? [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n") - : DESCRIPTION, + description: [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n"), parameters: Parameters, - jsonSchema: flags.experimentalBackgroundSubagents ? undefined : ToolJsonSchema.fromSchema(BaseParameters), execute: (params: Schema.Schema.Type, ctx: Tool.Context) => run(params, ctx).pipe(Effect.orDie), } diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 4c3139ce9c..f4f29cc453 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -161,7 +161,6 @@ async function renderFooter( currentModel?: RunInput["model"] currentVariant?: string subagents?: FooterSubagentState - backgroundSubagents?: boolean width?: number height?: number state?: Partial @@ -199,7 +198,6 @@ async function renderFooter( subagent={subagents} theme={input.theme ?? (() => RUN_THEME_FALLBACK)} tuiConfig={config} - backgroundSubagents={input.backgroundSubagents ?? true} agent="opencode" onSubmit={input.onSubmit ?? (() => true)} onPermissionReply={() => {}} @@ -998,7 +996,6 @@ test("direct footer shows editable prompts and additional queued work while runn ]} theme={() => RUN_THEME_FALLBACK} tuiConfig={tuiConfig} - backgroundSubagents={true} agent="opencode" onSubmit={() => true} onPermissionReply={() => {}} @@ -1071,7 +1068,7 @@ test("direct footer shows editable prompts and additional queued work while runn } }) -test("direct footer separates a lone context hint from model and command hint", async () => { +test("direct footer always offers backgrounding for a foreground subagent", async () => { const app = await renderFooter({ providers: [provider()], currentModel: { providerID: "opencode", modelID: "gpt-5" }, @@ -1082,7 +1079,6 @@ test("direct footer separates a lone context hint from model and command hint", permissions: [], questions: [], }, - backgroundSubagents: false, width: 160, }) @@ -1091,8 +1087,8 @@ test("direct footer separates a lone context hint from model and command hint", const frame = app.captureCharFrame() expect(frame).toContain("GPT-5") - expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd") - expect(frame).not.toContain("ctrl+b background") + expect(frame).toContain("xhigh · ctrl+b background · ctrl+x down subagents · ctrl+p cmd") + expect(frame).toContain("ctrl+b background") expect(frame).not.toContain("queued") } finally { app.cleanup() @@ -1110,7 +1106,6 @@ test("direct footer hides the subagent hint when only completed subagents remain permissions: [], questions: [], }, - backgroundSubagents: false, width: 160, }) diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index fcba5842fe..5eafd1266c 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -134,7 +134,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async () => { @@ -233,7 +232,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async () => { @@ -406,7 +404,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: true, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { @@ -496,7 +493,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { @@ -557,7 +553,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async () => { @@ -603,7 +598,6 @@ describe("run interactive runtime", () => { variant: undefined, files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { @@ -716,7 +710,6 @@ describe("run interactive runtime", () => { variant: "low", files: [], thinking: false, - backgroundSubagents: false, }, { createRuntimeLifecycle: async (input) => { diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index ca5ca1d7b0..51633f57b7 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -51,7 +51,6 @@ describe("RuntimeFlags", () => { expect(flags.enableExperimentalModels).toBe(true) expect(flags.enableQuestionTool).toBe(true) expect(flags.experimentalReferences).toBe(true) - expect(flags.experimentalBackgroundSubagents).toBe(true) expect(flags.experimentalLspTy).toBe(false) expect(flags.experimentalLspTool).toBe(true) expect(flags.experimentalOxfmt).toBe(true) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index fa3b1e670e..195699f9c9 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -579,8 +579,10 @@ const scenarios: Scenario[] = [ .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) .json(200, array), http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => { - check(typeof body === "object" && body !== null, "capabilities should be an object") - check("backgroundSubagents" in body, "capabilities should report background subagents") + check( + typeof body === "object" && body !== null && "backgroundSubagents" in body && body.backgroundSubagents === true, + "capabilities should report background subagents as available", + ) }), http.protected .post("/experimental/session/{sessionID}/background", "experimental.session.background") diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index cf27c74cbe..4aa750c39d 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,12 +1,13 @@ import { afterEach, describe, expect, mock } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Layer } from "effect" +import { Effect, Fiber, Layer } from "effect" import { Session as SessionNs } from "@/session/session" +import { Job } from "@/job" import { disposeAllInstances, TestInstance } from "../fixture/fixture" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(Job.node), httpApiLayer)) afterEach(async () => { mock.restore() @@ -14,6 +15,19 @@ afterEach(async () => { }) describe("session action routes", () => { + it.instance( + "reports background subagents as available", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const res = yield* requestInDirectory("/experimental/capabilities", test.directory) + + expect(res.status).toBe(200) + expect(yield* res.json).toEqual({ backgroundSubagents: true }) + }), + { git: true }, + ) + it.instance( "session routes expose metadata on create, update, get, and fork", () => @@ -107,4 +121,33 @@ describe("session action routes", () => { }), { git: true }, ) + + it.instance( + "experimental background route backgrounds a synchronous subagent", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) => + SessionNs.use.remove(created.id).pipe(Effect.ignore), + ) + const jobs = yield* Job.Service + const job = yield* jobs.start({ type: "task", run: Effect.never }) + const waiting = yield* jobs.block({ id: job.id, sessionID: session.id }).pipe(Effect.forkChild) + + const backgrounded = yield* pollWithTimeout( + requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, { + method: "POST", + }).pipe( + Effect.flatMap((res) => res.json), + Effect.map((value) => (value === true ? true : undefined)), + ), + "background route never released the synchronous subagent", + ) + + expect(backgrounded).toBe(true) + expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } }) + yield* jobs.cancel(job.id) + }), + { git: true }, + ) }) diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c8c5fac595..456d665950 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -150,7 +150,7 @@ describe("tool.registry", () => { }), ) - it.instance("hides task background parameter unless experimental background subagents are enabled", () => + it.instance("exposes the task background parameter by default", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service const agent = yield* Agent.Service @@ -162,8 +162,9 @@ describe("tool.registry", () => { agent: build, })).find((tool) => tool.id === "task") - expect(task?.jsonSchema).toBeDefined() - expect((task?.jsonSchema?.properties as Record | undefined)?.background).toBeUndefined() + if (!task) throw new Error("task tool not found") + const jsonSchema = ToolJsonSchema.fromTool(task) + expect((jsonSchema.properties as Record | undefined)?.background).toBeDefined() }), ) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index f62c8b4d31..32171789c8 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -34,7 +34,7 @@ const ref = { modelID: ModelV2.ID.make("test-model"), } -const layer = (flags: Partial = {}) => +const layer = () => LayerNode.compile( LayerNode.group([ Agent.node, @@ -52,11 +52,10 @@ const layer = (flags: Partial = {}) => RuntimeFlags.node, Ripgrep.node, ]), - [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], + [[RuntimeFlags.node, RuntimeFlags.layer()]], ) const it = testEffect(layer()) -const background = testEffect(layer({ experimentalBackgroundSubagents: true })) function defer() { let resolve!: (value: T | PromiseLike) => void @@ -456,37 +455,6 @@ describe("tool.task", () => { }, ) - it.instance("rejects background execution when the experiment is disabled", () => - Effect.gen(function* () { - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - - const exit = yield* def - .execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - background: true, - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps: stubOps() }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - .pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - }), - ) - it.instance("backgrounds a running foreground task without restarting it", () => Effect.gen(function* () { const jobs = yield* Job.Service @@ -558,7 +526,7 @@ describe("tool.task", () => { }), ) - background.instance("execute launches background tasks without waiting for completion", () => + it.instance("execute launches background tasks without waiting for completion", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -596,7 +564,7 @@ describe("tool.task", () => { }), ) - background.instance("running task_id reports the existing background task", () => + it.instance("running task_id reports the existing background task", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -661,7 +629,7 @@ describe("tool.task", () => { }), ) - background.instance("background tasks complete through the job service", () => + it.instance("background tasks complete through the job service", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -694,7 +662,7 @@ describe("tool.task", () => { }), ) - background.instance("background task completion does not wait for the parent async prompt", () => + it.instance("background task completion does not wait for the parent async prompt", () => Effect.gen(function* () { const jobs = yield* Job.Service const { chat, assistant } = yield* seed() @@ -732,7 +700,7 @@ describe("tool.task", () => { }), ) - background.instance("removing the parent session cancels running background tasks", () => + it.instance("removing the parent session cancels running background tasks", () => Effect.gen(function* () { const jobs = yield* Job.Service const sessions = yield* Session.Service @@ -771,7 +739,7 @@ describe("tool.task", () => { }), ) - background.instance("removing the child task session cancels its running background task", () => + it.instance("removing the child task session cancels its running background task", () => Effect.gen(function* () { const jobs = yield* Job.Service const sessions = yield* Session.Service @@ -810,7 +778,7 @@ describe("tool.task", () => { }), ) - background.instance("cancelling the parent run cancels running background tasks", () => + it.instance("cancelling the parent run cancels running background tasks", () => Effect.gen(function* () { const jobs = yield* Job.Service const runState = yield* SessionRunState.Service diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 7e691ad29c..b218660e9f 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -42,9 +42,6 @@ export const { provider_default: Record provider_next: ProviderListResponse console_state: ConsoleState - capabilities: { - experimentalBackgroundSubagents: boolean - } provider_auth: Record agent: Agent[] command: Command[] @@ -71,9 +68,6 @@ export const { connected: [], }, console_state: emptyConsoleState, - capabilities: { - experimentalBackgroundSubagents: false, - }, provider_auth: {}, agent: [], command: [], diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 8c6e476db0..30c2abe6e2 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -90,7 +90,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType Date: Tue, 7 Jul 2026 14:31:54 -0500 Subject: [PATCH 17/34] fix: update v2 session usage metrics (#35468) --- .../client/src/promise/generated/types.ts | 31 ++ packages/client/test/promise.test.ts | 2 +- packages/core/src/aisdk.ts | 5 +- packages/core/src/session/message-updater.ts | 5 + packages/core/src/session/projector.ts | 107 ++--- packages/core/src/session/runner/llm.ts | 36 +- packages/core/src/session/runner/model.ts | 6 +- .../src/session/runner/publish-llm-event.ts | 8 +- packages/core/test/aisdk.test.ts | 12 +- packages/core/test/session-create.test.ts | 42 +- packages/core/test/session-projector.test.ts | 65 ++- .../test/session-runner-tool-events.test.ts | 30 +- packages/core/test/session-runner.test.ts | 37 +- packages/schema/src/session-event.ts | 32 +- packages/schema/test/event-manifest.test.ts | 2 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 35 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 168 ++++++++ packages/tui/src/context/data.tsx | 60 ++- .../src/feature-plugins/sidebar/context.tsx | 14 +- .../src/routes/session/subagent-footer.tsx | 17 +- packages/tui/src/util/session.ts | 14 + packages/tui/test/cli/tui/data.test.tsx | 371 +++++++++++++++++- packages/tui/test/util/session.test.ts | 21 +- 23 files changed, 1013 insertions(+), 107 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 3bb87c1111..07964f950e 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1439,6 +1439,13 @@ export type SessionLogOutput = readonly sessionID: string readonly assistantMessageID: string readonly error: { readonly type: string; readonly message: string } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } } | { @@ -4542,6 +4549,23 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly title: string } } + | { + readonly id: string + readonly created: number + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.usage.updated" + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + } + } | { readonly id: string readonly created: number @@ -4765,6 +4789,13 @@ export type EventSubscribeOutput = readonly sessionID: string readonly assistantMessageID: string readonly error: { readonly type: string; readonly message: string } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } } } | { diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index b8433a0d1d..9b26efe773 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -32,7 +32,7 @@ test("exposes every standard HTTP API group", () => { "vcs", "debug", ]) - expect(Object.keys(client.debug)).toEqual(["location"]) + expect(Object.keys(client.debug)).toEqual(["location", "evictLocation"]) expect(Object.keys(client.message)).toEqual(["list"]) expect(Object.keys(client.integration)).toEqual([ "list", diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index f7474b244a..aecf7a6482 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -6,6 +6,7 @@ import type { JSONValue, LanguageModelV3, LanguageModelV3CallOptions, + LanguageModelV3FinishReason, LanguageModelV3FunctionTool, LanguageModelV3Message, LanguageModelV3Prompt, @@ -624,8 +625,8 @@ function usage(input: Extract["us return Object.values(output).some((value) => value !== undefined) ? output : undefined } -function finishReason(value: unknown): FinishReason { - return Schema.is(FinishReason)(value) ? value : "unknown" +function finishReason(value: LanguageModelV3FinishReason): FinishReason { + return value.unified === "other" ? "unknown" : value.unified } function providerMetadata(value: unknown) { diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 96881e7c69..dc61f96dd4 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -143,6 +143,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return Effect.gen(function* () { yield* SessionEvent.All.match(event, { + "session.usage.updated": () => Effect.void, "session.agent.selected": (event) => { return adapter.appendMessage( SessionMessage.AgentSelected.make({ @@ -296,6 +297,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { draft.finish = "error" draft.error = castDraft(event.data.error) draft.retry = undefined + if (event.data.cost !== undefined && event.data.tokens !== undefined) { + draft.cost = event.data.cost + draft.tokens = castDraft(event.data.tokens) + } }) }, "session.text.started": (event) => { diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 625b6f1553..f0a2f83d3f 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,7 +1,7 @@ export * as SessionProjector from "./projector" import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql } from "drizzle-orm" -import { DateTime, Effect, Layer, Schema } from "effect" +import { DateTime, Effect, Layer, Schema, Stream } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" import { makeGlobalNode } from "../effect/app-node" @@ -48,11 +48,6 @@ type Usage = { const ForkBatchSize = 500 -const emptyUsage = (): Usage => ({ - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, -}) - const forkTitle = (value: string) => { const match = value.match(/^(.+) \(fork #(\d+)\)$/) if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})` @@ -67,22 +62,6 @@ function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] } } -function addUsage(target: Usage, value: Usage) { - target.cost += value.cost - target.tokens.input += value.tokens.input - target.tokens.output += value.tokens.output - target.tokens.reasoning += value.tokens.reasoning - target.tokens.cache.read += value.tokens.cache.read - target.tokens.cache.write += value.tokens.cache.write -} - -function messageUsage(row: typeof SessionMessageTable.$inferSelect): Usage | undefined { - if (row.type !== "assistant") return undefined - const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) - if (message.type !== "assistant" || message.cost === undefined || message.tokens === undefined) return undefined - return { cost: message.cost, tokens: message.tokens } -} - function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert { return { id: info.id, @@ -151,6 +130,37 @@ function applyUsage( .pipe(Effect.orDie) } +const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* ( + db: DatabaseService, + events: EventV2.Interface, + sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"], +) { + const row = yield* db + .select({ + cost: SessionTable.cost, + input: SessionTable.tokens_input, + output: SessionTable.tokens_output, + reasoning: SessionTable.tokens_reasoning, + cacheRead: SessionTable.tokens_cache_read, + cacheWrite: SessionTable.tokens_cache_write, + }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get() + .pipe(Effect.orDie) + if (!row) return + yield* events.publish(SessionEvent.UsageUpdated, { + sessionID, + cost: row.cost, + tokens: { + input: row.input, + output: row.output, + reasoning: row.reasoning, + cache: { read: row.cacheRead, write: row.cacheWrite }, + }, + }) +}) + const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( db: DatabaseService, event: typeof SessionEvent.Forked.Type, @@ -187,7 +197,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .limit(1) .get() .pipe(Effect.orDie) - const copiedSeq = copied?.seq ?? 0 + const copiedSeq = copied?.seq const stored = yield* db .insert(SessionTable) @@ -237,9 +247,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) } - const usage = emptyUsage() let cursor = -1 - while (true) { + while (copiedSeq !== undefined) { const rows = yield* db .select() .from(SessionMessageTable) @@ -247,7 +256,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( and( eq(SessionMessageTable.session_id, event.data.parentID), gt(SessionMessageTable.seq, cursor), - copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1), + lt(SessionMessageTable.seq, copiedSeq + 1), sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`, ), ) @@ -318,27 +327,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) } - for (const row of rows) { - const value = messageUsage(row) - if (value) addUsage(usage, value) - } cursor = rows.at(-1)!.seq } - - yield* db - .update(SessionTable) - .set({ - cost: usage.cost, - tokens_input: usage.tokens.input, - tokens_output: usage.tokens.output, - tokens_reasoning: usage.tokens.reasoning, - tokens_cache_read: usage.tokens.cache.read, - tokens_cache_write: usage.tokens.cache.write, - }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie) - if (copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq) + if (copiedSeq !== undefined) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq) }) function run(db: DatabaseService, event: MessageEvent) { @@ -697,8 +688,19 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Started, (event) => run(db, event)) - yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event)) - yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event)) + yield* events.project(SessionEvent.Step.Ended, (event) => + Effect.gen(function* () { + yield* run(db, event) + yield* applyUsage(db, event.data.sessionID, event.data) + }), + ) + yield* events.project(SessionEvent.Step.Failed, (event) => + Effect.gen(function* () { + yield* run(db, event) + if (event.data.cost !== undefined && event.data.tokens !== undefined) + yield* applyUsage(db, event.data.sessionID, { cost: event.data.cost, tokens: event.data.tokens }) + }), + ) yield* events.project(SessionEvent.Text.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event)) @@ -790,6 +792,17 @@ const layer = Layer.effectDiscard( yield* InstructionCheckpoint.reset(db, event.data.sessionID) }), ) + yield* events.subscribe([SessionEvent.Step.Ended, SessionEvent.Step.Failed]).pipe( + Stream.runForEach((event) => { + if ( + event.type === SessionEvent.Step.Failed.type && + (event.data.cost === undefined || event.data.tokens === undefined) + ) + return Effect.void + return publishSessionUsage(db, events, event.data.sessionID) + }), + Effect.forkScoped({ startImmediately: true }), + ) }), ) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 2e05d1edcf..2d5220310c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -17,6 +17,7 @@ import { Config } from "../../config" import { Database } from "../../database/database" import { EventV2 } from "../../event" import { Location } from "../../location" +import { ModelV2 } from "../../model" import { PermissionV2 } from "../../permission" import { Instructions } from "../../instructions/index" import { InstructionBuiltIns } from "../../instructions/builtins" @@ -50,6 +51,30 @@ import { StepFailedError, UserInterruptedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" +type StepTokens = { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } +} + +// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract. +export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) { + const context = tokens.input + tokens.cache.read + tokens.cache.write + const tier = costs + .filter((cost) => cost.tier?.type === "context" && context > cost.tier.size) + .toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0] + const cost = tier ?? costs.find((cost) => cost.tier === undefined) + if (!cost) return 0 + return ( + (tokens.input * cost.input + + (tokens.output + tokens.reasoning) * cost.output + + tokens.cache.read * cost.cache.read + + tokens.cache.write * cost.cache.write) / + 1_000_000 + ) +} + /** * Runs one durable coding-agent Session until it settles. * @@ -312,6 +337,11 @@ const layer = Layer.effect( Effect.ensuring(serialized(publisher.flush())), ) + const stepUsage = (settlement: NonNullable>) => ({ + cost: calculateCost(resolved.cost, settlement.tokens), + tokens: settlement.tokens, + }) + // Captures the end snapshot, diffs it against the step's start, and durably ends the // assistant step. const publishStepEnd = (settlement: NonNullable>) => @@ -328,8 +358,7 @@ const layer = Layer.effect( sessionID: session.id, assistantMessageID: yield* publisher.startAssistant(), finish: settlement.finish, - cost: 0, - tokens: settlement.tokens, + ...stepUsage(settlement), snapshot: endSnapshot, files, }), @@ -452,7 +481,8 @@ const layer = Layer.effect( const stepEndedCleanly = !streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement) - if (stepFailure) yield* serialized(publisher.publishStepFailure()) + if (stepFailure) + yield* serialized(publisher.publishStepFailure(stepSettlement ? stepUsage(stepSettlement) : undefined)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (userDeclined) return yield* Effect.interrupt diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 6cc0d0845d..defd95f9c1 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -82,6 +82,8 @@ export interface Resolved { readonly model: Model /** Selected catalog identity. Durable records and displays must use this, never the API model id. */ readonly ref: ModelV2.Ref + /** Catalog pricing in dollars per million tokens. */ + readonly cost: ModelV2.Info["cost"] } export interface Interface { @@ -94,13 +96,14 @@ export class Service extends Context.Service()("@opencode/v2 export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) /** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */ -export const resolved = (model: Model, variant?: ModelV2.VariantID): Resolved => ({ +export const resolved = (model: Model, variant?: ModelV2.VariantID, cost: ModelV2.Info["cost"] = []): Resolved => ({ model, ref: ModelV2.Ref.make({ id: ModelV2.ID.make(model.id), providerID: ProviderV2.ID.make(model.provider), ...(variant === undefined ? {} : { variant }), }), + cost, }) const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => { @@ -341,6 +344,7 @@ const layer = Layer.effect( providerID: selected.providerID, ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }), }), + cost: selected.cost, } }), }) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 5f4eeebc84..7b4d30df87 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -216,7 +216,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) if (replace || stepFailure === undefined) stepFailure = error }) - const publishStepFailure = Effect.fnUntraced(function* () { + const publishStepFailure = Effect.fnUntraced(function* (usage?: { + readonly cost: number + readonly tokens: ReturnType + }) { if (stepFailed || stepFailure === undefined) return const assistantMessageID = yield* startAssistant() stepFailed = true @@ -224,6 +227,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) sessionID: input.sessionID, assistantMessageID, error: stepFailure, + ...usage, }) }) @@ -409,12 +413,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) case "step-finish": yield* flush() if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) + stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } if (event.reason === "content-filter") { providerFailed = true yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true) return } - stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } return case "finish": return diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 495c125a1e..4037949427 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -51,13 +51,11 @@ it.effect("projects request settings, headers, and body overlays", () => apiKey: "secret", thinkingConfig: { thinkingBudget: 1024 }, }) - const resolved = yield* aisdk.model( - { - ...input, - headers: { "x-test": "header" }, - body: { safety_setting: "strict" }, - }, - ) + const resolved = yield* aisdk.model({ + ...input, + headers: { "x-test": "header" }, + body: { safety_setting: "strict" }, + }) const prepared = yield* LLMClient.prepare( LLM.request({ model: resolved, prompt: "Hello" }), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 0a2e57edf5..86e593c722 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -225,9 +225,17 @@ describe("SessionV2.create", () => { promotedSeq: 2, }) - yield* session.prompt({ sessionID: parent.id, prompt: PromptInput.Prompt.make({ text: "Parent changed" }), resume: false }) + yield* session.prompt({ + sessionID: parent.id, + prompt: PromptInput.Prompt.make({ text: "Parent changed" }), + resume: false, + }) yield* SessionInput.promoteSteers(db, events, parent.id) - yield* session.prompt({ sessionID: forked.id, prompt: PromptInput.Prompt.make({ text: "Child continues" }), resume: false }) + yield* session.prompt({ + sessionID: forked.id, + prompt: PromptInput.Prompt.make({ text: "Child continues" }), + resume: false, + }) yield* SessionInput.promoteSteers(db, events, forked.id) expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) @@ -260,8 +268,25 @@ describe("SessionV2.create", () => { resume: false, }) yield* SessionInput.promoteSteers(db, events, parent.id) + const assistantMessageID = SessionMessage.ID.create() + const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) + yield* events.publish(SessionEvent.Step.Started, { + sessionID: parent.id, + assistantMessageID, + agent: "build", + model, + }) + yield* events.publish(SessionEvent.Step.Ended, { + sessionID: parent.id, + assistantMessageID, + finish: "stop", + cost: 0.75, + tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, + }) const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id }) + const beforeFirst = yield* session.fork({ sessionID: parent.id, messageID: first.id }) + const complete = yield* session.fork({ sessionID: parent.id }) const context = yield* session.context(forked.id) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) @@ -269,6 +294,13 @@ describe("SessionV2.create", () => { expect(context).toMatchObject([{ text: "First" }]) expect(context[0]?.id).not.toBe(first.id) expect(history[0]).toMatchObject({ data: { from: second.id } }) + expect(forked).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) + expect(yield* session.context(beforeFirst.id)).toEqual([]) + expect(beforeFirst).toMatchObject({ cost: 0, tokens: { input: 0, output: 0, reasoning: 0 } }) + expect(complete).toMatchObject({ + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) }), ) @@ -375,7 +407,11 @@ describe("SessionV2.create", () => { const events = yield* EventV2.Service const { db } = yield* Database.Service const created = yield* session.create({ location }) - yield* session.prompt({ sessionID: created.id, prompt: PromptInput.Prompt.make({ text: "Hello" }), resume: false }) + yield* session.prompt({ + sessionID: created.id, + prompt: PromptInput.Prompt.make({ text: "Hello" }), + resume: false, + }) yield* SessionInput.promoteSteers(db, events, created.id) expect( diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index ce30170362..cc0655c55f 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Schema } from "effect" +import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -41,12 +41,15 @@ const assistantRow = ( id: SessionMessage.ID, seq: number, time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created }, + usage?: Pick, ) => { const { id: _, type, ...data - } = encodeMessage(SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time })) + } = encodeMessage( + SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time, ...usage }), + ) return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } } @@ -67,6 +70,12 @@ describe("SessionProjector", () => { directory: "/project", title: "test", version: "test", + cost: 1.25, + tokens_input: 10, + tokens_output: 4, + tokens_reasoning: 2, + tokens_cache_read: 3, + tokens_cache_write: 1, }) .run() const boundary = SessionMessage.ID.make("msg_boundary") @@ -75,8 +84,24 @@ describe("SessionProjector", () => { .insert(SessionMessageTable) .values([ assistantRow(earlier, 0), - assistantRow(boundary, 1), - assistantRow(SessionMessage.ID.make("msg_later"), 2), + assistantRow( + boundary, + 1, + { created }, + { + cost: 0.5, + tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, + }, + ), + assistantRow( + SessionMessage.ID.make("msg_later"), + 2, + { created }, + { + cost: 0.75, + tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, + }, + ), ]) .run() yield* db @@ -106,6 +131,14 @@ describe("SessionProjector", () => { expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), ).toEqual([earlier]) + expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({ + cost: 1.25, + tokens_input: 10, + tokens_output: 4, + tokens_reasoning: 2, + tokens_cache_read: 3, + tokens_cache_write: 1, + }) // A committed revert resets the context checkpoint so the next turn re-initializes. expect(yield* db.select().from(InstructionCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined() }), @@ -534,12 +567,15 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const service = yield* EventV2.Service + const usageUpdated = yield* service + .subscribe(SessionEvent.UsageUpdated) + .pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true })) yield* service.publish(SessionEvent.Step.Ended, { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), finish: "stop", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: 1.25, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, }) const rows = yield* db @@ -556,8 +592,25 @@ describe("SessionProjector", () => { expect(messages[1]).toMatchObject({ type: "assistant", finish: "stop", + cost: 1.25, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, time: { completed: DateTime.makeUnsafe(0) }, }) + expect( + yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie), + ).toMatchObject({ + cost: 1.25, + tokens_input: 10, + tokens_output: 4, + tokens_reasoning: 2, + tokens_cache_read: 3, + tokens_cache_write: 1, + }) + expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({ + sessionID, + cost: 1.25, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, + }) }), ) diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 4fdbb49e8b..0e54513e8f 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -151,15 +151,39 @@ test("step finish records settlement without publishing step ended", async () => test("content-filter finish retains failure evidence until step closeout", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 }))) - await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "content-filter" }))) + await Effect.runPromise( + publisher.publish( + LLMEvent.stepFinish({ + index: 0, + reason: "content-filter", + usage: { + nonCachedInputTokens: 8, + outputTokens: 3, + reasoningTokens: 1, + }, + }), + ), + ) expect(published.map((event) => event.type)).toEqual(["session.step.started.1"]) - await Effect.runPromise(publisher.publishStepFailure()) + const settlement = publisher.stepSettlement() + expect(settlement).toMatchObject({ + finish: "content-filter", + tokens: { input: 8, output: 2, reasoning: 1 }, + }) + if (!settlement) throw new Error("Expected content-filter settlement") + await Effect.runPromise( + publisher.publishStepFailure({ + cost: 1.25, + tokens: settlement.tokens, + }), + ) expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"]) expect(published.at(-1)?.data).toMatchObject({ error: { type: "provider.content-filter", message: "Provider blocked the response" }, + cost: 1.25, + tokens: { input: 8, output: 2, reasoning: 1 }, }) - expect(publisher.stepSettlement()).toBeUndefined() }) test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => { diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index ec893a5527..b98c5ef106 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { LLMClient, LLMError, @@ -116,6 +116,28 @@ const recoveryModel = Model.make({ provider: "fake", route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }), }) + +test("calculates step cost using the matching context tier", () => { + expect( + SessionRunnerLLM.calculateCost( + [ + { input: 1, output: 2, cache: { read: 0.1, write: 0.5 } }, + { tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } }, + ], + { input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } }, + ), + ).toBeCloseTo(0.0002926) +}) + +test("does not apply an ineligible tier without base pricing", () => { + expect( + SessionRunnerLLM.calculateCost( + [{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } }], + { input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 0 } }, + ), + ).toBe(0) +}) + const authorizations: Tool.Context[] = [] const executions: string[] = [] const permission = Layer.succeed( @@ -1704,6 +1726,7 @@ describe("SessionRunnerLLM", () => { { type: "assistant", finish: "tool-calls", + cost: 0, tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 2, write: 0 } }, content: [ { type: "reasoning", text: "Think" }, @@ -3635,7 +3658,11 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "partial" }), LLMEvent.textDelta({ id: "partial", text: "Partial" }), - LLMEvent.stepFinish({ index: 0, reason: "content-filter" }), + LLMEvent.stepFinish({ + index: 0, + reason: "content-filter", + usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 }, + }), LLMEvent.finish({ reason: "content-filter" }), ] @@ -3646,9 +3673,15 @@ describe("SessionRunnerLLM", () => { type: "assistant", finish: "error", error: { type: "provider.content-filter" }, + cost: 0, + tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } }, content: [{ type: "text", text: "Partial" }], }, ]) + expect(yield* session.get(sessionID)).toMatchObject({ + cost: 0, + tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } }, + }) expect(yield* recordedEventTypes(sessionID)).not.toContain("session.step.ended.1") }), ) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 8e96321ce2..9f98b35226 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -30,6 +30,15 @@ export interface Source extends Schema.Schema.Type {} const Base = { sessionID: SessionID, } +const Tokens = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) const PromptFields = { ...Base, inputID: SessionMessage.ID, @@ -84,6 +93,16 @@ export const Renamed = Event.durable({ }) export type Renamed = typeof Renamed.Type +export const UsageUpdated = Event.ephemeral({ + type: "session.usage.updated", + schema: { + ...Base, + cost: Schema.Finite, + tokens: Tokens, + }, +}) +export type UsageUpdated = typeof UsageUpdated.Type + export const Deleted = Event.durable({ type: "session.deleted", durable: { @@ -224,15 +243,7 @@ export namespace Step { assistantMessageID: SessionMessage.ID, finish: FinishReason, cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), + tokens: Tokens, snapshot: Schema.String.pipe(optional), files: Schema.Array(RelativePath).pipe(optional), }, @@ -246,6 +257,8 @@ export namespace Step { ...Base, assistantMessageID: SessionMessage.ID, error: SessionError.Error, + cost: Schema.Finite.pipe(optional), + tokens: Tokens.pipe(optional), }, }) export type Failed = typeof Failed.Type @@ -504,6 +517,7 @@ export const Definitions = Event.inventory( ModelSelected, Moved, Renamed, + UsageUpdated, Deleted, Forked, PromptPromoted, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 9ff3a05519..8f8f112176 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -144,6 +144,8 @@ describe("public event manifest", () => { expect(SessionEvent.DurableDefinitions).toEqual( SessionEvent.Definitions.filter((definition) => definition.durability === "durable"), ) + expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral") + expect(EventManifest.ServerDefinitions).toContain(SessionEvent.UsageUpdated) expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 458f9c2d2e..239783dc87 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -277,6 +277,8 @@ import type { V2CredentialUpdateErrors, V2CredentialUpdateResponses, V2DebugLocationErrors, + V2DebugLocationEvictErrors, + V2DebugLocationEvictResponses, V2DebugLocationResponses, V2EventSubscribeErrors, V2EventSubscribeResponses, @@ -8117,6 +8119,34 @@ export class Vcs2 extends HeyApiClient { } } +export class Location2 extends HeyApiClient { + /** + * Evict a loaded location + * + * Dispose the requested location's cached services so its next use boots them fresh. + */ + public evict( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).delete< + V2DebugLocationEvictResponses, + V2DebugLocationEvictErrors, + ThrowOnError + >({ + url: "/api/debug/location", + ...options, + ...params, + }) + } +} + export class Debug extends HeyApiClient { /** * List loaded locations @@ -8129,6 +8159,11 @@ export class Debug extends HeyApiClient { ...options, }) } + + private _location?: Location2 + get location2(): Location2 { + return (this._location ??= new Location2({ client: this.client })) + } } export class V2 extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 87aa5ddbe1..68ad7c2a0e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -21,6 +21,7 @@ export type Event = | EventSessionModelSelected | EventSessionMoved | EventSessionRenamed + | EventSessionUsageUpdated | EventSessionForked | EventSessionPromptPromoted | EventSessionPromptAdmitted @@ -895,6 +896,23 @@ export type GlobalEvent = { title: string } } + | { + id: string + type: "session.usage.updated" + properties: { + sessionID: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + } + } | { id: string type: "session.forked" @@ -1042,6 +1060,16 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } } } | { @@ -3079,6 +3107,7 @@ export type V2Event = | SessionModelSelected | SessionMoved | SessionRenamed + | SessionUsageUpdated | SessionForked | SessionPromptPromoted | SessionPromptAdmitted @@ -3982,6 +4011,16 @@ export type SyncEventSessionStepFailed = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } } } } @@ -5088,6 +5127,16 @@ export type SessionStepFailed = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } } } @@ -5955,6 +6004,29 @@ export type MessagePartRemoved = { } } +export type SessionUsageUpdated = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.usage.updated" + location?: LocationRef + data: { + sessionID: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + } +} + export type SessionTextDelta = { id: string created: number @@ -7010,6 +7082,24 @@ export type EventSessionRenamed = { } } +export type EventSessionUsageUpdated = { + id: string + type: "session.usage.updated" + properties: { + sessionID: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + } +} + export type EventSessionForked = { id: string type: "session.forked" @@ -7171,6 +7261,16 @@ export type EventSessionStepFailed = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } } } @@ -8525,6 +8625,7 @@ export type V2EventV2 = | SessionModelSelectedV2 | SessionMovedV2 | SessionRenamedV2 + | SessionUsageUpdatedV2 | SessionDeletedV2 | SessionForkedV2 | SessionPromptPromotedV2 @@ -9272,6 +9373,16 @@ export type SessionStepFailedV2 = { sessionID: string assistantMessageID: string error: SessionStructuredError + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } } } @@ -9996,6 +10107,29 @@ export type MessagePartRemovedV2 = { } } +export type SessionUsageUpdatedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "session.usage.updated" + location?: LocationRefV2 + data: { + sessionID: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + } +} + export type SessionTextDeltaV2 = { id: string created: number @@ -18549,6 +18683,40 @@ export type V2VcsDiffResponses = { export type V2VcsDiffResponse = V2VcsDiffResponses[keyof V2VcsDiffResponses] +export type V2DebugLocationEvictData = { + body?: never + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/debug/location" +} + +export type V2DebugLocationEvictErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2DebugLocationEvictError = V2DebugLocationEvictErrors[keyof V2DebugLocationEvictErrors] + +export type V2DebugLocationEvictResponses = { + /** + * + */ + 204: void +} + +export type V2DebugLocationEvictResponse = V2DebugLocationEvictResponses[keyof V2DebugLocationEvictResponses] + export type V2DebugLocationData = { body?: never path?: never diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 4136b5dc36..04a81cb0b4 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -110,6 +110,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() + const sessionRefreshGeneration = new Map() + const sessionRefreshApplied = new Map() + const sessionUsage = new Map() let connectionGeneration = 0 let statusChanges: Set | undefined let bootstrapping: Promise | undefined @@ -119,6 +122,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "status", sessionID, status) } + function nextSessionRefresh(sessionID: string) { + const generation = (sessionRefreshGeneration.get(sessionID) ?? 0) + 1 + sessionRefreshGeneration.set(sessionID, generation) + return generation + } + + function applySessionRefresh(sessionID: string, generation: number) { + if ((sessionRefreshApplied.get(sessionID) ?? 0) > generation) return false + sessionRefreshApplied.set(sessionID, generation) + return true + } + + function updateSessionUsage(sessionID: string, cost: number, tokens: SessionV2Info["tokens"]) { + sessionUsage.set(sessionID, { generation: (sessionUsage.get(sessionID)?.generation ?? 0) + 1, cost, tokens }) + if (!store.session.info[sessionID]) return + setStore("session", "info", sessionID, { cost, tokens }) + } + const message = { update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => void) { setStore( @@ -222,6 +243,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function removeSession(sessionID: string) { + sessionRefreshApplied.set(sessionID, nextSessionRefresh(sessionID)) + sessionUsage.delete(sessionID) messageIndex.delete(sessionID) setStore( "session", @@ -250,6 +273,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.deleted": removeSession(event.data.sessionID) break + case "session.usage.updated": + updateSessionUsage(event.data.sessionID, event.data.cost, event.data.tokens) + break case "catalog.updated": void Promise.all([ result.location.model.refresh(event.location), @@ -420,7 +446,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) }) break - case "session.step.ended": + case "session.step.ended": { message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) if (!currentAssistant) return @@ -432,6 +458,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot } }) break + } case "session.step.failed": message.update(event.data.sessionID, (draft, index) => { const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID) @@ -440,6 +467,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ currentAssistant.finish = "error" currentAssistant.error = event.data.error currentAssistant.retry = undefined + if (event.data.cost !== undefined && event.data.tokens !== undefined) { + currentAssistant.cost = event.data.cost + currentAssistant.tokens = event.data.tokens + } }) break case "session.text.started": @@ -639,8 +670,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "info", event.data.sessionID, "revert", undefined) break case "session.revert.committed": - if (store.session.info[event.data.sessionID]) + if (store.session.info[event.data.sessionID]) { setStore("session", "info", event.data.sessionID, "revert", undefined) + } setStore( "session", "input", @@ -811,7 +843,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.compaction[sessionID] }, async refresh(sessionID: string) { - setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) + const generation = nextSessionRefresh(sessionID) + const usageGeneration = sessionUsage.get(sessionID)?.generation ?? 0 + const info = mutable(await sdk.api.session.get({ sessionID })) + if (!applySessionRefresh(sessionID, generation)) return + const usage = sessionUsage.get(sessionID) + setStore( + "session", + "info", + sessionID, + usage && usage.generation !== usageGeneration ? { ...info, cost: usage.cost, tokens: usage.tokens } : info, + ) registerSession(sessionID) }, message: { @@ -994,6 +1036,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async function bootstrap() { if (bootstrapping) return bootstrapping + const generation = new Map(sessionRefreshApplied) + const usageGeneration = new Map(Array.from(sessionUsage, ([id, usage]) => [id, usage.generation])) bootstrapping = Promise.allSettled([ sdk.api.session .list({ @@ -1007,7 +1051,15 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ "session", "info", produce((draft) => { - for (const session of response.data) draft[session.id] = mutable(session) + for (const session of response.data) { + if ((sessionRefreshApplied.get(session.id) ?? 0) !== (generation.get(session.id) ?? 0)) continue + const usage = sessionUsage.get(session.id) + draft[session.id] = mutable( + usage && usage.generation !== (usageGeneration.get(session.id) ?? 0) + ? { ...session, cost: usage.cost, tokens: usage.tokens } + : session, + ) + } }), ) for (const session of response.data) registerSession(session.id) diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index f1c99d9679..ae41cec303 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -1,7 +1,8 @@ -import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "../builtins" import { createMemo } from "solid-js" +import { useData } from "../../context/data" +import { lastAssistantWithUsage } from "../../util/session" const id = "internal:sidebar-context" @@ -11,13 +12,14 @@ const money = new Intl.NumberFormat("en-US", { }) function View(props: { api: TuiPluginApi; session_id: string }) { + const data = useData() const theme = () => props.api.theme.current - const msg = createMemo(() => props.api.state.session.messages(props.session_id)) - const session = createMemo(() => props.api.state.session.get(props.session_id)) + const msg = createMemo(() => data.session.message.list(props.session_id)) + const session = createMemo(() => data.session.get(props.session_id)) const cost = createMemo(() => session()?.cost ?? 0) const state = createMemo(() => { - const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID) if (!last) { return { tokens: 0, @@ -27,7 +29,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) { const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write - const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + const model = data.location + .model.list(session()?.location) + ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) return { tokens, percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, diff --git a/packages/tui/src/routes/session/subagent-footer.tsx b/packages/tui/src/routes/session/subagent-footer.tsx index 71b3a161e5..f02ef3f20e 100644 --- a/packages/tui/src/routes/session/subagent-footer.tsx +++ b/packages/tui/src/routes/session/subagent-footer.tsx @@ -6,6 +6,7 @@ import { SplitBorder } from "../../ui/border" import { Locale } from "../../util/locale" import { useTerminalDimensions } from "@opentui/solid" import { useCommandShortcut, useOpencodeKeymap } from "../../keymap" +import { lastAssistantWithUsage } from "../../util/session" export function SubagentFooter() { const route = useRouteData("session") @@ -22,17 +23,15 @@ export function SubagentFooter() { const usage = createMemo(() => { const current = session() if (!current) return + const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID) + if (!last) return const tokens = - current.tokens.input + - current.tokens.output + - current.tokens.reasoning + - current.tokens.cache.read + - current.tokens.cache.write + last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write if (tokens <= 0) return const model = data.location .model.list(current.location) - ?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id) + ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined const cost = current.cost @@ -83,10 +82,10 @@ export function SubagentFooter() { setHover("parent")} - onMouseOut={() => setHover(null)} + onMouseOver={() => setHover("parent")} + onMouseOut={() => setHover(null)} onMouseUp={() => keymap.dispatchCommand("session.parent")} - backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} + backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > Parent {parentShortcut()} diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 94ccad22d0..3a49ea1a9c 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,3 +1,17 @@ +import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" + export function isDefaultTitle(title: string) { return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) } + +export function lastAssistantWithUsage(messages: ReadonlyArray, boundary?: string) { + const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1 + if (boundary && boundaryIndex === -1) return undefined + return messages.findLast( + ( + message, + index, + ): message is SessionMessageAssistant & { tokens: NonNullable } => + message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex), + ) +} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index ef2aa57a9b..3d87dce647 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -108,6 +108,309 @@ test("refreshes resources into reactive getters", async () => { } }) +test("applies absolute usage events without losing full session updates", async () => { + const events = createEventStream() + const sessionID = "ses_usage_refresh" + let resolveSessions!: (response: Response) => void + const resolveSession: Array<(response: Response) => void> = [] + let sessionsRequested = false + const calls = createFetch((url) => { + if (url.pathname === "/api/session") { + sessionsRequested = true + return new Promise((resolve) => { + resolveSessions = resolve + }) + } + if (url.pathname === `/api/session/${sessionID}`) { + return new Promise((resolve) => { + resolveSession.push(resolve) + }) + } + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => sessionsRequested) + emitEvent(events, { + id: "evt_usage_2", + created: 2, + type: "session.usage.updated", + data: { + sessionID, + cost: 0.5, + tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + const initialRefresh = data.session.refresh(sessionID) + await wait(() => resolveSession.length === 1) + resolveSessions( + json({ + data: [ + { + id: sessionID, + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Stale usage", + location: { directory }, + }, + ], + cursor: {}, + }), + ) + resolveSession[0]( + json({ + data: { + id: sessionID, + projectID: "proj_test", + cost: 0.5, + tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, + time: { created: 0, updated: 0 }, + title: "Current usage", + location: { directory }, + }, + }), + ) + await initialRefresh + await wait(() => data.session.get(sessionID)?.cost === 0.5) + expect(data.session.get(sessionID)?.tokens).toEqual({ + input: 5, + output: 2, + reasoning: 1, + cache: { read: 1, write: 1 }, + }) + + const fullRefresh = data.session.refresh(sessionID) + emitEvent(events, { + id: "evt_usage_3", + created: 3, + type: "session.usage.updated", + data: { + sessionID, + cost: 1, + tokens: { input: 10, output: 4, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + await wait(() => data.session.get(sessionID)?.cost === 1) + resolveSession[1]( + json({ + data: { + id: sessionID, + projectID: "proj_test", + cost: 0.75, + tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } }, + time: { created: 0, updated: 0 }, + title: "Older usage", + location: { directory }, + }, + }), + ) + await fullRefresh + await Bun.sleep(20) + expect(data.session.get(sessionID)?.cost).toBe(1) + expect(data.session.get(sessionID)?.title).toBe("Older usage") + + emitEvent(events, { + id: "evt_usage_6", + created: 6, + type: "session.usage.updated", + data: { + sessionID, + cost: 1.25, + tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + emitEvent(events, { + id: "evt_usage_7", + created: 7, + type: "session.usage.updated", + data: { + sessionID, + cost: 1.25, + tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + await wait(() => data.session.get(sessionID)?.cost === 1.25) + expect(data.session.get(sessionID)?.title).toBe("Older usage") + + emitEvent(events, { + id: "evt_usage_8", + created: 8, + type: "session.usage.updated", + data: { + sessionID, + cost: 1.5, + tokens: { input: 14, output: 6, reasoning: 1, cache: { read: 1, write: 1 } }, + }, + }) + emitEvent(events, { + id: "evt_usage_deleted", + created: 9, + type: "session.deleted", + durable: durable(sessionID, 9), + data: { sessionID }, + }) + await Bun.sleep(20) + expect(data.session.get(sessionID)).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) + +test("truncates committed revert messages without changing lifetime usage", async () => { + const events = createEventStream() + const sessionID = "ses_revert_usage" + let cost = 0 + let tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + if (url.pathname !== `/api/session/${sessionID}`) return + return json({ + data: { + id: sessionID, + projectID: "proj_test", + cost, + tokens, + time: { created: 0, updated: 0 }, + title: "Revert usage", + location: { directory }, + }, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await data.session.refresh(sessionID) + emitEvent(events, { + id: "evt_revert_boundary_started", + created: 1, + type: "session.step.started", + durable: durable(sessionID, 1), + data: { + sessionID, + assistantMessageID: "msg_revert_boundary", + agent: "build", + model: { providerID: "provider", id: "model" }, + }, + }) + cost = 0.5 + tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } } + emitEvent(events, { + id: "evt_revert_boundary_ended", + created: 2, + type: "session.step.ended", + durable: durable(sessionID, 2), + data: { + sessionID, + assistantMessageID: "msg_revert_boundary", + finish: "stop", + cost: 0.5, + tokens, + }, + }) + emitEvent(events, { + id: "evt_revert_boundary_usage", + created: 2, + type: "session.usage.updated", + data: { sessionID, cost, tokens }, + }) + await wait(() => data.session.get(sessionID)?.cost === 0.5) + + emitEvent(events, { + id: "evt_revert_later_started", + created: 3, + type: "session.step.started", + durable: durable(sessionID, 3), + data: { + sessionID, + assistantMessageID: "msg_revert_later", + agent: "build", + model: { providerID: "provider", id: "model" }, + }, + }) + cost = 0.75 + tokens = { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } } + emitEvent(events, { + id: "evt_revert_later_ended", + created: 4, + type: "session.step.ended", + durable: durable(sessionID, 4), + data: { + sessionID, + assistantMessageID: "msg_revert_later", + finish: "stop", + cost: 0.25, + tokens: { input: 3, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + }) + emitEvent(events, { + id: "evt_revert_later_usage", + created: 4, + type: "session.usage.updated", + data: { sessionID, cost, tokens }, + }) + await wait(() => data.session.get(sessionID)?.cost === 0.75) + emitEvent(events, { + id: "evt_revert_staged", + created: 5, + type: "session.revert.staged", + durable: durable(sessionID, 5), + data: { sessionID, revert: { messageID: "msg_revert_later" } }, + }) + await wait(() => data.session.get(sessionID)?.revert?.messageID === "msg_revert_later") + + emitEvent(events, { + id: "evt_revert_committed", + created: 6, + type: "session.revert.committed", + durable: durable(sessionID, 6), + data: { sessionID, to: "msg_revert_later" }, + }) + await wait(() => data.session.message.ids(sessionID).length === 1) + expect(data.session.get(sessionID)?.cost).toBe(0.75) + expect(data.session.message.ids(sessionID)).toEqual(["msg_revert_boundary"]) + expect(data.session.get(sessionID)?.revert).toBeUndefined() + expect(data.session.get(sessionID)?.tokens).toEqual(tokens) + } finally { + app.renderer.destroy() + } +}) + test("updates session location when moved", async () => { const events = createEventStream() const destination = "/tmp/opencode-moved" @@ -517,8 +820,35 @@ test("connectedOnce is false until first connect and persists across disconnect" test("tracks session status from active sessions and execution events", async () => { const events = createEventStream() + let settled = false const calls = createFetch((url) => { if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } }) + if (url.pathname === "/api/session/session-live") + return json({ + data: { + id: "session-live", + projectID: "proj_test", + cost: settled ? 0.75 : 0, + tokens: settled + ? { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } } + : { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Live session", + location: { directory }, + }, + }) + if (url.pathname === "/api/session/session-failed") + return json({ + data: { + id: "session-failed", + projectID: "proj_test", + cost: 0.25, + tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Failed session", + location: { directory }, + }, + }) }, events) let data!: ReturnType let rows!: SessionRow[] @@ -546,7 +876,9 @@ test("tracks session status from active sessions and execution events", async () try { await wait(() => data.session.status("session-active") === "running") expect(data.session.status("session-idle")).toBe("idle") + await data.session.refresh("session-live") + settled = true emitEvent(events, { id: "evt_execution_started", created: 0, @@ -577,15 +909,30 @@ test("tracks session status from active sessions and execution events", async () sessionID: "session-live", assistantMessageID: "message-live", finish: "stop", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + cost: 0.75, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, + }, + }) + emitEvent(events, { + id: "evt_step_usage", + created: 0, + type: "session.usage.updated", + data: { + sessionID: "session-live", + cost: 0.75, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, }, }) await wait(() => { const assistant = data.session.message.get("session-live", "message-live") return assistant?.type === "assistant" && assistant.finish === "stop" }) + await wait(() => data.session.get("session-live")?.cost === 0.75) expect(data.session.status("session-live")).toBe("running") + expect(data.session.get("session-live")).toMatchObject({ + cost: 0.75, + tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, + }) emitEvent(events, { id: "evt_execution_succeeded", @@ -596,6 +943,7 @@ test("tracks session status from active sessions and execution events", async () }) await wait(() => data.session.status("session-live") === "idle") + await data.session.refresh("session-failed") emitEvent(events, { id: "evt_failed_execution_started", created: 0, @@ -626,6 +974,18 @@ test("tracks session status from active sessions and execution events", async () sessionID: "session-failed", assistantMessageID: "message-failed", error: { type: "provider.content-filter", message: "Provider blocked the response" }, + cost: 0.25, + tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, + }, + }) + emitEvent(events, { + id: "evt_failed_step_usage", + created: 0, + type: "session.usage.updated", + data: { + sessionID: "session-failed", + cost: 0.25, + tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, }, }) await wait(() => { @@ -636,6 +996,13 @@ test("tracks session status from active sessions and execution events", async () assistant.error?.type === "provider.content-filter" ) }) + await wait(() => data.session.get("session-failed")?.cost === 0.25) + expect(data.session.get("session-failed")?.tokens).toEqual({ + input: 5, + output: 1, + reasoning: 1, + cache: { read: 1, write: 0 }, + }) expect(data.session.status("session-failed")).toBe("running") emitEvent(events, { diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index faf889781f..752f490247 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { isDefaultTitle } from "../../src/util/session" +import type { SessionMessage } from "@opencode-ai/sdk/v2" +import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session" describe("util.session", () => { test("recognizes generated parent and child titles", () => { @@ -7,4 +8,22 @@ describe("util.session", () => { expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue() expect(isDefaultTitle("New session - custom")).toBeFalse() }) + + test("tracks usage across undo and redo boundaries", () => { + const assistant = (id: string, input: number): SessionMessage => ({ + id, + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0 }, + }) + const messages = [assistant("msg_z", 10), assistant("msg_a", 30)] + + expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30) + expect(lastAssistantWithUsage(messages, "msg_a")?.tokens.input).toBe(10) + expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined() + expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30) + }) }) From 5db320b02da1daf1372c5a55a0b1bc8bc175f996 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 7 Jul 2026 16:27:03 -0400 Subject: [PATCH 18/34] refactor: rename apply_patch tool --- .../performance/timeline-stability/file-matrix.spec.ts | 2 +- .../timeline-stability/file-mutation.spec.ts | 6 +++--- .../timeline/session-timeline-benchmark.fixture.ts | 2 +- .../timeline/session-timeline-stress.fixture.ts | 4 ++-- .../session-timeline-file-projection.spec.ts | 2 +- .../e2e/regression/session-timeline-file-state.spec.ts | 2 +- .../e2e/regression/session-timeline-projection.spec.ts | 2 +- .../session-timeline-tool-projection.spec.ts | 4 ++-- packages/app/e2e/smoke/session-timeline.fixture.ts | 4 ++-- .../src/pages/session/timeline/message-timeline.tsx | 2 +- packages/cli/src/mini/demo.ts | 4 ++-- packages/cli/src/mini/tool.ts | 4 ++-- packages/core/src/file-mutation.ts | 2 +- packages/core/src/tool/AGENTS.md | 2 +- packages/core/src/tool/apply-patch.ts | 6 +++--- packages/core/src/tool/registry.ts | 4 ++-- .../core/test/session-runner-tool-registry.test.ts | 8 ++++---- packages/core/test/tool-apply-patch.test.ts | 10 +++++----- packages/session-ui/src/components/message-part.tsx | 7 ++++--- .../src/components/tool-error-card.stories.tsx | 10 +++++----- packages/session-ui/src/components/tool-error-card.tsx | 1 + packages/tui/src/routes/session/index.tsx | 6 +++--- 22 files changed, 48 insertions(+), 46 deletions(-) diff --git a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts index e0f0d72233..0690f02706 100644 --- a/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-matrix.spec.ts @@ -20,7 +20,7 @@ const profiles = [ { name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } }, { name: "multi patch", - tool: "apply_patch", + tool: "patch", input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] }, }, ] as const diff --git a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts index 798bf0df3b..f411339adb 100644 --- a/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts +++ b/packages/app/e2e/performance/timeline-stability/file-mutation.spec.ts @@ -25,7 +25,7 @@ test("adds patch files incrementally without resetting outer expansion", async ( userMessage(), assistantMessage( [ - toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), + toolPart(patchID, "patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }), textPart(followingID, "Following incremental patch"), ], { completed: false }, @@ -49,7 +49,7 @@ test("adds patch files incrementally without resetting outer expansion", async ( partUpdated( toolPart( patchID, - "apply_patch", + "patch", "running", { files: [first.filePath, second.filePath] }, { metadata: { files: [first, second] } }, @@ -61,7 +61,7 @@ test("adds patch files incrementally without resetting outer expansion", async ( partUpdated( toolPart( patchID, - "apply_patch", + "patch", "completed", { files: [first.filePath, second.filePath, third.filePath] }, { metadata: { files: [first, second, third] } }, diff --git a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts index a86a55cff2..a22d5cc331 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-benchmark.fixture.ts @@ -295,7 +295,7 @@ function performanceTurn(index: number) { messageID: assistantID, type: "tool", callID: `call_0000_${suffix}_patch`, - tool: "apply_patch", + tool: "patch", state: { status: "completed", input: { patchText: realisticPatch(index) }, diff --git a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts index 529081a1d9..e6fdd5d47b 100644 --- a/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts +++ b/packages/app/e2e/performance/timeline/session-timeline-stress.fixture.ts @@ -131,7 +131,7 @@ function toolPart( ): MessagePart { const metadata = metadataOverride ?? - (tool === "apply_patch" + (tool === "patch" ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } : tool === "edit" || tool === "write" ? { @@ -219,7 +219,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] : []), ...(index % 8 === 0 - ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] + ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)] diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index f07da121c6..a591ff9470 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -24,7 +24,7 @@ test("renders a completed single-file patch", async ({ page }) => { assistantMessage([ toolPart( id, - "apply_patch", + "patch", "completed", { files: ["src/a.ts"] }, { diff --git a/packages/app/e2e/regression/session-timeline-file-state.spec.ts b/packages/app/e2e/regression/session-timeline-file-state.spec.ts index cb228c13c7..f0871a0da3 100644 --- a/packages/app/e2e/regression/session-timeline-file-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-state.spec.ts @@ -35,7 +35,7 @@ test("preserves nested patch file state through outer collapse and reopen", asyn assistantMessage([ toolPart( patchID, - "apply_patch", + "patch", "completed", { files: files.map((file) => file.filePath) }, { metadata: { files } }, diff --git a/packages/app/e2e/regression/session-timeline-projection.spec.ts b/packages/app/e2e/regression/session-timeline-projection.spec.ts index 9fd2ca8d0b..b6679ab4ed 100644 --- a/packages/app/e2e/regression/session-timeline-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-projection.spec.ts @@ -246,7 +246,7 @@ function editPart(id: string) { function patchPart(id: string) { return toolPart( id, - "apply_patch", + "patch", "completed", { files: ["src/a.ts", "src/b.ts"] }, { diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index 99f1acf270..071b030078 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -8,7 +8,7 @@ import { } from "../performance/timeline-stability/fixture" test("renders every tool error outcome without leaking hidden tools", async ({ page }) => { - const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] + const ordinary = ["bash", "edit", "write", "patch", "webfetch", "websearch", "task", "skill", "mcp_probe"] const parts = ordinary.map((tool, index) => toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }), ) @@ -90,7 +90,7 @@ function questionInput() { function errorInput(tool: string) { if (tool === "bash") return { command: "exit 1" } if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" } - if (tool === "apply_patch") return { files: ["src/error.ts"] } + if (tool === "patch") return { files: ["src/error.ts"] } if (tool === "webfetch") return { url: "https://example.com" } if (tool === "websearch") return { query: "failure" } if (tool === "task") return { description: "Fail task", subagent_type: "explore" } diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 3dce37cafd..ff857d8179 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -120,7 +120,7 @@ function toolPart( outputLength = 160, ): MessagePart { const metadata = - tool === "apply_patch" + tool === "patch" ? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] } : tool === "edit" || tool === "write" ? { @@ -199,7 +199,7 @@ function turn(index: number): Message[] { ? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)] : []), ...(index % 8 === 0 - ? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] + ? [toolPart(index, 8, "patch", { files: [`src/generated/patch-${index}.ts`] }, 620)] : []), ...(index % 7 === 0 ? [toolPart(index, 4, "bash", { command: "bun typecheck" }, 620)] : []), ...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []), diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 2e2c65af3b..bb7950079e 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1245,7 +1245,7 @@ export function MessageTimeline(props: { const value = row() if (value._tag !== "AssistantPart" || value.group.type !== "part") return false const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID) - return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool) + return part?.type === "tool" && ["edit", "write", "patch", "apply_patch"].includes(part.tool) } const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile()) let contentMeasureFrame: number | undefined diff --git a/packages/cli/src/mini/demo.ts b/packages/cli/src/mini/demo.ts index b06b3c4537..ed0b6878f5 100644 --- a/packages/cli/src/mini/demo.ts +++ b/packages/cli/src/mini/demo.ts @@ -628,11 +628,11 @@ function emitEdit(state: State): void { function emitPatch(state: State): void { const file = path.join(process.cwd(), "src", "demo-format.ts") - const ref = make(state, "apply_patch", { + const ref = make(state, "patch", { patchText: "*** Begin Patch\n*** End Patch", }) doneTool(state, ref, { - title: "apply_patch", + title: "patch", output: "", metadata: { files: [ diff --git a/packages/cli/src/mini/tool.ts b/packages/cli/src/mini/tool.ts index 8d9b06aee1..9eb8782a17 100644 --- a/packages/cli/src/mini/tool.ts +++ b/packages/cli/src/mini/tool.ts @@ -119,7 +119,7 @@ type ToolName = | "bash" | "write" | "edit" - | "apply_patch" + | "patch" | "batch" | "task" | "todowrite" @@ -1094,7 +1094,7 @@ const TOOL_RULES = { }, permission: permEdit, }, - apply_patch: { + patch: { view: { output: false, final: true, diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index baac20d200..7981bb26de 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -200,6 +200,6 @@ export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.no // TODO: Publish watcher/file-edit events after V2 watcher integration exists. // TODO: Add snapshots / undo after V2 snapshot design exists. // TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists. -// TODO: Design multi-file transactions / rollback if apply_patch needs atomic edits. +// TODO: Design multi-file transactions / rollback if patch needs atomic edits. // Until then, edits are sequential and report partial application. // TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement. diff --git a/packages/core/src/tool/AGENTS.md b/packages/core/src/tool/AGENTS.md index 4b427f9835..0b3f9c3628 100644 --- a/packages/core/src/tool/AGENTS.md +++ b/packages/core/src/tool/AGENTS.md @@ -41,7 +41,7 @@ Registrations are scoped: ## Permissions -The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action. +The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `patch` declare the shared `edit` action. Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement. diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index aee442169e..e359787f2c 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -12,7 +12,7 @@ import { Patch } from "../patch" import { PermissionV2 } from "../permission" import { Tool } from "./tool" -export const name = "apply_patch" +export const name = "patch" export const Input = Schema.Struct({ patchText: Schema.String.annotate({ @@ -91,11 +91,11 @@ export const Plugin = { if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" }) const hunks = yield* Effect.try({ try: () => Patch.parse(input.patchText), - catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }), + catch: (cause) => new ToolFailure({ message: `patch verification failed: ${String(cause)}` }), }) if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" }) const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined) - if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" }) + if (move) return yield* new ToolFailure({ message: "patch moves are not supported yet" }) const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = [] for (const hunk of hunks) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 0275cd8d87..ae5cd3d352 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -191,10 +191,10 @@ const registryLayer = Layer.effect( const registration = entries.at(-1)?.registration if (registration) registrations.set(name, registration) } - // OpenAI/GPT models use apply_patch; every other model uses edit and write. + // OpenAI/GPT models use patch; every other model uses edit and write. const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt") for (const [name, registration] of registrations) { - const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch + const wrongEditTool = name === "patch" ? !usePatch : (name === "edit" || name === "write") && usePatch if ( wrongEditTool || (registration.deferred && !Flag.CODEMODE_ENABLED) || diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 3c03094b5e..01e417ee2f 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -89,16 +89,16 @@ describe("ToolRegistry", () => { read: make(), edit: make("edit"), write: make("edit"), - apply_patch: make("edit"), + patch: make("edit"), }) const names = (model: ToolRegistry.MaterializeInput["model"]) => service .materialize({ model }) .pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name))) - expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "apply_patch"]) - expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "apply_patch"]) - expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "apply_patch"]) + expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "patch"]) + expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "patch"]) + expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "patch"]) expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"]) }), ) diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 6aa7c89aa2..cb6745af32 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -26,7 +26,7 @@ const applyPatchToolNode = makeLocationNode({ deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], }) -const sessionID = SessionV2.ID.make("ses_apply_patch_tool_test") +const sessionID = SessionV2.ID.make("ses_patch_tool_test") const assertions: PermissionV2.AssertInput[] = [] let denyAction: string | undefined let failRemoveTarget: string | undefined @@ -132,10 +132,10 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte const call = (patchText: string, id = "call-apply-patch") => ({ sessionID, ...toolIdentity, - call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } }, + call: { type: "tool-call" as const, id, name: "patch", input: { patchText } }, }) -// apply_patch is only materialized for OpenAI/GPT models. +// patch is only materialized for OpenAI/GPT models. const model = { id: "gpt-5", provider: "openai" } const exists = (target: string) => @@ -162,7 +162,7 @@ describe("ApplyPatchTool", () => { withTool(tmp.path, (registry) => Effect.gen(function* () { expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([ - "apply_patch", + "patch", ]) const settled = yield* settleTool( registry, @@ -241,7 +241,7 @@ describe("ApplyPatchTool", () => { ), model, ), - ).toEqual({ type: "error", value: "apply_patch moves are not supported yet" }) + ).toEqual({ type: "error", value: "patch moves are not supported yet" }) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) expect(assertions).toEqual([]) }), diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 0e5dec34b2..f147cd7388 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -536,6 +536,7 @@ export function getToolInfo( title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } + case "patch": case "apply_patch": return { icon: "code-lines", @@ -728,7 +729,7 @@ export function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit } export function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -1449,7 +1450,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { - return state[name]?.render + return state[name === "apply_patch" ? "patch" : name]?.render } export const ToolRegistry = { @@ -2272,7 +2273,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "apply_patch", + name: "patch", render(props) { const i18n = useI18n() const fileComponent = useFileComponent() diff --git a/packages/session-ui/src/components/tool-error-card.stories.tsx b/packages/session-ui/src/components/tool-error-card.stories.tsx index dd60075812..90348b2a95 100644 --- a/packages/session-ui/src/components/tool-error-card.stories.tsx +++ b/packages/session-ui/src/components/tool-error-card.stories.tsx @@ -5,7 +5,7 @@ const docs = `### Overview Tool call failure summary styled like a tool trigger. ### API -- Required: \`tool\` (tool id, e.g. apply_patch, bash) +- Required: \`tool\` (tool id, e.g. patch, bash) - Required: \`error\` (error string) ### Behavior @@ -14,9 +14,9 @@ Tool call failure summary styled like a tool trigger. const samples = [ { - tool: "apply_patch", + tool: "patch", error: - "apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", + "patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx", }, { tool: "bash", @@ -62,13 +62,13 @@ export default { }, }, args: { - tool: "apply_patch", + tool: "patch", error: samples[0].error, }, argTypes: { tool: { control: "select", - options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"], + options: ["patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"], }, error: { control: "text", diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 35720a2753..aaedded025 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -51,6 +51,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", bash: "ui.tool.shell", + patch: "ui.tool.patch", apply_patch: "ui.tool.patch", question: "ui.tool.questions", } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d1ea5e1272..f76838de46 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1908,7 +1908,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { - + @@ -2737,7 +2737,7 @@ const toolDisplays = new Set([ "edit", "subagent", "execute", - "apply_patch", + "patch", "todowrite", "question", "skill", @@ -2746,7 +2746,7 @@ const toolDisplays = new Set([ export function toolDisplay(tool: string) { // Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render // them with the renamed views. - const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool + const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool return toolDisplays.has(normalized) ? normalized : "generic" } From 52ad916ba427254a5dffa41fcb108ea601fe4471 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 7 Jul 2026 22:44:26 +0000 Subject: [PATCH 19/34] fix(tui): restore permissions on reconnect Co-authored-by: opencode-agent[bot] --- packages/tui/src/context/data.tsx | 10 ++++++ packages/tui/test/cli/tui/data.test.tsx | 46 +++++++++++++++++++++++++ packages/tui/test/fixture/tui-sdk.ts | 2 ++ 3 files changed, 58 insertions(+) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 04a81cb0b4..0a03f81549 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1064,6 +1064,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) for (const session of response.data) registerSession(session.id) }), + sdk.api.permission.listRequests({ location: locationQuery(defaultLocation()) }).then((response) => { + const permissions = mutable(response.data).reduce>( + (result, request) => ({ + ...result, + [request.sessionID]: [...(result[request.sessionID] ?? []), request], + }), + {}, + ) + setStore("session", "permission", reconcile(permissions)) + }), result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 3d87dce647..480dcbbec7 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1521,6 +1521,52 @@ test("adds and dismisses permission requests from live events", async () => { } }) +test("reconciles all pending permission requests when the event stream reconnects", async () => { + const events = createEventStream() + let requests = [ + { id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] }, + { id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] }, + ] + let calls = 0 + const fetch = createFetch((url) => { + if (url.pathname !== "/api/permission/request") return + calls++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old") + expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep") + + requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }] + events.disconnect() + + await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new") + expect(data.session.permission.list("ses_old")).toBeUndefined() + expect(data.session.permission.list("ses_keep")).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) + test("adds, dismisses, and refreshes form requests", async () => { const events = createEventStream() const calls = createFetch((url) => { diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 30c2abe6e2..5752009911 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -101,6 +101,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType Date: Tue, 7 Jul 2026 22:47:13 +0000 Subject: [PATCH 20/34] fix(tui): restore forms on reconnect Co-authored-by: opencode-agent[bot] --- packages/tui/src/context/data.tsx | 10 ++++++ packages/tui/test/cli/tui/data.test.tsx | 46 +++++++++++++++++++++++++ packages/tui/test/fixture/tui-sdk.ts | 2 ++ 3 files changed, 58 insertions(+) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 0a03f81549..7b15781ffc 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1074,6 +1074,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) setStore("session", "permission", reconcile(permissions)) }), + sdk.api.form.listRequests({ location: locationQuery(defaultLocation()) }).then((response) => { + const forms = mutable(response.data).reduce>( + (result, form) => ({ + ...result, + [form.sessionID]: [...(result[form.sessionID] ?? []), form], + }), + {}, + ) + setStore("session", "form", reconcile(forms)) + }), result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 480dcbbec7..fdd5980fb5 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -1637,6 +1637,52 @@ test("adds, dismisses, and refreshes form requests", async () => { } }) +test("reconciles all pending form requests when the event stream reconnects", async () => { + const events = createEventStream() + let requests = [ + { id: "frm_old", sessionID: "ses_old", mode: "form" as const, fields: [] }, + { id: "frm_keep", sessionID: "ses_keep", mode: "url" as const, url: "https://example.com" }, + ] + let calls = 0 + const fetch = createFetch((url) => { + if (url.pathname !== "/api/form/request") return + calls++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old") + expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep") + + requests = [{ id: "frm_new", sessionID: "ses_new", mode: "form" as const, fields: [] }] + events.disconnect() + + await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new") + expect(data.session.form.list("ses_old")).toBeUndefined() + expect(data.session.form.list("ses_keep")).toBeUndefined() + } finally { + app.renderer.destroy() + } +}) + test("settles pending tools when a live failure arrives", async () => { const events = createEventStream() const calls = createFetch((url) => { diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 5752009911..2c10ed43a2 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -103,6 +103,8 @@ export function createFetch(override?: FetchHandler, events?: ReturnType Date: Tue, 7 Jul 2026 17:49:33 -0500 Subject: [PATCH 21/34] fix(tui): restore prompt context usage (#35795) --- packages/tui/src/component/prompt/index.tsx | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index c15f5547b2..1e5a9132c9 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" +import type { SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { createColors, createFrames } from "../../ui/spinner" @@ -57,6 +57,7 @@ import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" import { useLocation } from "../../context/location" +import { lastAssistantWithUsage } from "../../util/session" registerOpencodeSpinner() @@ -276,18 +277,20 @@ export function Prompt(props: PromptProps) { const usage = createMemo(() => { if (!props.sessionID) return - const session = sync.session.get(props.sessionID) - const msg = sync.data.message[props.sessionID] ?? [] - const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) + const session = data.session.get(props.sessionID) + if (!session) return + const last = lastAssistantWithUsage(data.session.message.list(props.sessionID), session.revert?.messageID) if (!last) return const tokens = last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write if (tokens <= 0) return - const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] + const model = data.location + .model.list(session.location) + ?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id) const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined - const cost = session?.cost ?? 0 + const cost = session.cost return { context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), cost: cost > 0 ? money.format(cost) : undefined, @@ -1624,11 +1627,11 @@ export function Prompt(props: PromptProps) { {agentShortcut()} agents - - {paletteShortcut()} commands - + + {paletteShortcut()} commands + From 521ea87192a9af3cf38cc54fdeb5d5d2f830f1ea Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 19:44:40 -0400 Subject: [PATCH 22/34] fix(core): retain reasoning delta state (#35758) --- .../src/session/runner/publish-llm-event.ts | 27 ++++++++++++++----- .../test/session-runner-tool-events.test.ts | 19 +++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 7b4d30df87..c93223c307 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -101,27 +101,36 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) ended: (id: string, value: string, ordinal: number, state?: Record) => Effect.Effect, single = false, ) => { - const chunks = new Map() + const chunks = new Map< + string, + { readonly ordinal: number; readonly values: string[]; state?: Record } + >() let nextOrdinal = 0 - const start = (id: string) => + const start = (id: string, state?: Record) => Effect.suspend(() => { if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`)) const ordinal = nextOrdinal++ - chunks.set(id, { ordinal, values: [] }) + chunks.set(id, { ordinal, values: [], state }) return Effect.succeed(ordinal) }) - const append = (id: string, value: string) => + const append = (id: string, value: string, state?: Record) => Effect.suspend(() => { const current = chunks.get(id) if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) current.values.push(value) + if (state !== undefined) current.state = { ...current.state, ...state } return Effect.succeed(current.ordinal) }) const end = Effect.fnUntraced(function* (id: string, state?: Record) { const current = chunks.get(id) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) - yield* ended(id, current.values.join(""), current.ordinal, state) + yield* ended( + id, + current.values.join(""), + current.ordinal, + state === undefined ? current.state : { ...current.state, ...state }, + ) chunks.delete(id) }) const flush = Effect.fnUntraced(function* () { @@ -288,7 +297,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return case "reasoning-start": retryEvidence = true - const startedReasoningOrdinal = yield* reasoning.start(event.id) + const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata)) yield* events.publish(SessionEvent.Reasoning.Started, { sessionID: input.sessionID, assistantMessageID: yield* startAssistant(), @@ -297,7 +306,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) }) return case "reasoning-delta": - const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text) + const deltaReasoningOrdinal = yield* reasoning.append( + event.id, + event.text, + providerState(event.providerMetadata), + ) yield* events.publish(SessionEvent.Reasoning.Delta, { sessionID: input.sessionID, assistantMessageID: yield* currentAssistantMessageID(), diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 0e54513e8f..d3aaf152c4 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -110,6 +110,25 @@ test("provider state uses the route provider instead of the catalog provider", a }) }) +test("reasoning state from an empty delta is retained at reasoning end", async () => { + const { published, publisher } = capture() + await Effect.runPromise(publisher.publish(LLMEvent.reasoningStart({ id: "reasoning" }))) + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningDelta({ + id: "reasoning", + text: "", + providerMetadata: { openai: { signature: "signed" } }, + }), + ), + ) + await Effect.runPromise(publisher.publish(LLMEvent.reasoningEnd({ id: "reasoning" }))) + + expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({ + state: { signature: "signed" }, + }) +}) + test("binary failure emits no success event", async () => { const { published, publisher } = capture() await Effect.runPromise(publisher.publish(call)) From e25a724bc2bd54d3cf749f8c46babb512197a0c4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 19:44:54 -0400 Subject: [PATCH 23/34] test(tui): cover patch diff display aliases (#35810) --- packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 402db8b5fe..c95e3834d2 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -122,6 +122,8 @@ describe("TUI inline tool wrapping", () => { // Legacy tool names normalize to their renamed views. expect(toolDisplay("bash")).toBe("shell") expect(toolDisplay("task")).toBe("subagent") + expect(toolDisplay("apply_patch")).toBe("patch") + expect(toolDisplay("patch")).toBe("patch") expect(toolDisplay("plugin_tool")).toBe("generic") }) From 4f976bcf1a40ea4c205b29f0e74addf4eb6d77c9 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 7 Jul 2026 19:56:47 -0400 Subject: [PATCH 24/34] feat(plugin): add session request hook (#35794) --- packages/client/script/build.ts | 5 +- packages/client/src/contract.ts | 1 - packages/client/src/effect/api/api.ts | 54 +- .../client/src/effect/generated/client.ts | 28 +- .../client/src/promise/generated/client.ts | 430 +- .../client/src/promise/generated/types.ts | 30 +- .../client/test/contract-identity.test.ts | 6 +- packages/client/test/promise.test.ts | 17 +- .../test/fixtures/opencode-v2-openapi.json | 6722 +++++++++-------- packages/codemode/test/openapi.test.ts | 8 +- packages/core/src/plugin.ts | 19 +- packages/core/src/plugin/hooks.ts | 67 + packages/core/src/plugin/host.ts | 142 +- packages/core/src/plugin/internal.ts | 2 +- packages/core/src/plugin/promise.ts | 38 +- packages/core/src/plugin/provider/alibaba.ts | 3 +- .../src/plugin/provider/amazon-bedrock.ts | 6 +- .../core/src/plugin/provider/anthropic.ts | 3 +- packages/core/src/plugin/provider/azure.ts | 9 +- packages/core/src/plugin/provider/cerebras.ts | 3 +- .../plugin/provider/cloudflare-ai-gateway.ts | 3 +- .../plugin/provider/cloudflare-workers-ai.ts | 6 +- packages/core/src/plugin/provider/cohere.ts | 3 +- .../core/src/plugin/provider/deepinfra.ts | 3 +- packages/core/src/plugin/provider/dynamic.ts | 3 +- packages/core/src/plugin/provider/gateway.ts | 3 +- .../src/plugin/provider/github-copilot.ts | 6 +- packages/core/src/plugin/provider/gitlab.ts | 6 +- .../core/src/plugin/provider/google-vertex.ts | 12 +- packages/core/src/plugin/provider/google.ts | 3 +- packages/core/src/plugin/provider/groq.ts | 3 +- packages/core/src/plugin/provider/mistral.ts | 3 +- .../src/plugin/provider/openai-compatible.ts | 3 +- packages/core/src/plugin/provider/openai.ts | 6 +- .../core/src/plugin/provider/openrouter.ts | 3 +- .../core/src/plugin/provider/perplexity.ts | 3 +- .../core/src/plugin/provider/sap-ai-core.ts | 6 +- .../src/plugin/provider/snowflake-cortex.ts | 3 +- .../core/src/plugin/provider/togetherai.ts | 3 +- packages/core/src/plugin/provider/venice.ts | 3 +- packages/core/src/plugin/provider/vercel.ts | 3 +- packages/core/src/plugin/provider/xai.ts | 6 +- packages/core/src/plugin/sdk.ts | 2 +- packages/core/src/plugin/supervisor.ts | 2 +- packages/core/src/session/runner/llm.ts | 36 +- packages/core/src/tool/apply-patch.ts | 15 +- packages/core/src/tool/edit.ts | 2 +- packages/core/src/tool/glob.ts | 2 +- packages/core/src/tool/grep.ts | 2 +- packages/core/src/tool/question.ts | 2 +- packages/core/src/tool/read.ts | 2 +- packages/core/src/tool/registry.ts | 4 - packages/core/src/tool/shell.ts | 2 +- packages/core/src/tool/skill.ts | 2 +- packages/core/src/tool/subagent.ts | 45 +- packages/core/src/tool/todowrite.ts | 2 +- packages/core/src/tool/webfetch.ts | 2 +- packages/core/src/tool/websearch.ts | 2 +- packages/core/src/tool/write.ts | 2 +- .../fixtures/plugin/directory-plugin.ts | 4 +- .../fixtures/plugins/folder-plugin/index.ts | 4 +- packages/core/test/config/plugin.test.ts | 6 +- packages/core/test/lib/tool.ts | 10 +- packages/core/test/location-layer.test.ts | 6 +- packages/core/test/plugin-hooks.test.ts | 47 + packages/core/test/plugin.test.ts | 73 +- packages/core/test/plugin/fixture.ts | 1 + .../plugin/fixtures/config-effect-plugin.ts | 4 +- .../plugin/fixtures/config-promise-plugin.ts | 4 +- .../test/plugin/fixtures/failing-plugin.ts | 4 +- .../plugin/fixtures/variant-source-plugin.ts | 4 +- packages/core/test/plugin/host.ts | 39 +- packages/core/test/plugin/promise.test.ts | 8 +- .../test/session-runner-tool-registry.test.ts | 13 +- packages/docs/openapi.json | 2554 +++++-- packages/httpapi-codegen/src/index.ts | 28 +- .../httpapi-codegen/test/generate.test.ts | 192 +- packages/plugin/package.json | 7 +- packages/plugin/src/v2/effect/PLAN.md | 2 +- packages/plugin/src/v2/effect/README.md | 23 +- packages/plugin/src/v2/effect/agent.ts | 6 +- packages/plugin/src/v2/effect/aisdk.ts | 8 +- packages/plugin/src/v2/effect/catalog.ts | 6 +- packages/plugin/src/v2/effect/command.ts | 6 +- packages/plugin/src/v2/effect/context.ts | 27 - packages/plugin/src/v2/effect/event.ts | 2 +- packages/plugin/src/v2/effect/index.ts | 15 +- packages/plugin/src/v2/effect/integration.ts | 6 +- packages/plugin/src/v2/effect/plugin.ts | 31 +- packages/plugin/src/v2/effect/reference.ts | 6 +- packages/plugin/src/v2/effect/registration.ts | 11 +- packages/plugin/src/v2/effect/runtime.ts | 4 - packages/plugin/src/v2/effect/session.ts | 25 + packages/plugin/src/v2/effect/skill.ts | 6 +- packages/plugin/src/v2/effect/tool.ts | 13 +- packages/plugin/src/v2/promise/README.md | 37 +- packages/plugin/src/v2/promise/agent.ts | 6 +- packages/plugin/src/v2/promise/aisdk.ts | 8 +- packages/plugin/src/v2/promise/catalog.ts | 6 +- packages/plugin/src/v2/promise/command.ts | 6 +- packages/plugin/src/v2/promise/context.ts | 25 - packages/plugin/src/v2/promise/event.ts | 2 +- packages/plugin/src/v2/promise/index.ts | 13 +- packages/plugin/src/v2/promise/integration.ts | 6 +- packages/plugin/src/v2/promise/plugin.ts | 31 +- packages/plugin/src/v2/promise/reference.ts | 6 +- .../plugin/src/v2/promise/registration.ts | 9 +- packages/plugin/src/v2/promise/runtime.ts | 3 - packages/plugin/src/v2/promise/session.ts | 24 + packages/plugin/src/v2/promise/skill.ts | 6 +- packages/plugin/src/v2/promise/tool.ts | 110 + packages/protocol/src/client.ts | 21 - packages/protocol/src/groups/debug.ts | 2 +- packages/protocol/src/groups/message.ts | 2 +- packages/protocol/src/groups/pty.ts | 2 +- packages/sdk-next/test/embedded.test.ts | 2 +- packages/sdk/js/script/build.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 403 +- packages/sdk/js/src/v2/gen/types.gen.ts | 176 +- .../tui/src/component/dialog-integration.tsx | 12 +- packages/tui/src/component/prompt/index.tsx | 2 +- packages/tui/src/context/data.tsx | 6 +- .../tui/src/routes/session/dialog-message.tsx | 2 +- packages/tui/src/routes/session/index.tsx | 6 +- 124 files changed, 7038 insertions(+), 4915 deletions(-) create mode 100644 packages/core/src/plugin/hooks.ts create mode 100644 packages/core/test/plugin-hooks.test.ts delete mode 100644 packages/plugin/src/v2/effect/context.ts delete mode 100644 packages/plugin/src/v2/effect/runtime.ts create mode 100644 packages/plugin/src/v2/effect/session.ts delete mode 100644 packages/plugin/src/v2/promise/context.ts delete mode 100644 packages/plugin/src/v2/promise/runtime.ts create mode 100644 packages/plugin/src/v2/promise/session.ts create mode 100644 packages/plugin/src/v2/promise/tool.ts diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 6a83d52b99..70f1dcc587 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -3,15 +3,14 @@ import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from import { ClientApi, effectOmitEndpoints, - endpointNames, groupNames, promiseOmitEndpoints, } from "@opencode-ai/protocol/client" import { Effect } from "effect" import { fileURLToPath } from "url" -const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) -const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints }) +const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints }) +const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints }) await Effect.runPromise( Effect.all( diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 319a9f72e2..8fd9994f5c 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -1,7 +1,6 @@ export { ClientApi, effectOmitEndpoints, - endpointNames, groupNames, promiseOmitEndpoints, } from "@opencode-ai/protocol/client" diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index d34ff3f07e..0c7b7a3af3 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -291,9 +291,11 @@ export interface SessionApi { readonly shell: SessionShellOperation readonly compact: SessionCompactOperation readonly wait: SessionWaitOperation - readonly revertStage: SessionRevertStageOperation - readonly revertClear: SessionRevertClearOperation - readonly revertCommit: SessionRevertCommitOperation + readonly revert: { + readonly stage: SessionRevertStageOperation + readonly clear: SessionRevertClearOperation + readonly commit: SessionRevertCommitOperation + } readonly context: SessionContextOperation readonly instructions: { readonly entry: { @@ -438,11 +440,15 @@ export type IntegrationAttemptCancelOperation = ( export interface IntegrationApi { readonly list: IntegrationListOperation readonly get: IntegrationGetOperation - readonly connectKey: IntegrationConnectKeyOperation - readonly connectOauth: IntegrationConnectOauthOperation - readonly attemptStatus: IntegrationAttemptStatusOperation - readonly attemptComplete: IntegrationAttemptCompleteOperation - readonly attemptCancel: IntegrationAttemptCancelOperation + readonly connect: { + readonly key: IntegrationConnectKeyOperation + readonly oauth: IntegrationConnectOauthOperation + } + readonly attempt: { + readonly status: IntegrationAttemptStatusOperation + readonly complete: IntegrationAttemptCompleteOperation + readonly cancel: IntegrationAttemptCancelOperation + } } type Endpoint10_0Request = Parameters[0] @@ -453,11 +459,13 @@ export type ServerMcpListOperation = (input?: Endpoint10_0Input) => E type Endpoint10_1Request = Parameters[0] export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] } export type Endpoint10_1Output = EffectValue> -export type ServerMcpCatalogOperation = (input?: Endpoint10_1Input) => Effect.Effect +export type ServerMcpResourceCatalogOperation = ( + input?: Endpoint10_1Input, +) => Effect.Effect export interface ServerMcpApi { readonly list: ServerMcpListOperation - readonly catalog: ServerMcpCatalogOperation + readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation } } type Endpoint11_0Request = Parameters[0] @@ -507,7 +515,7 @@ export interface ProjectApi { type Endpoint13_0Request = Parameters[0] export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } export type Endpoint13_0Output = EffectValue> -export type FormListRequestsOperation = (input?: Endpoint13_0Input) => Effect.Effect +export type FormRequestListOperation = (input?: Endpoint13_0Input) => Effect.Effect type Endpoint13_1Request = Parameters[0] export type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] } @@ -561,7 +569,7 @@ export type Endpoint13_6Output = EffectValue = (input: Endpoint13_6Input) => Effect.Effect export interface FormApi { - readonly listRequests: FormListRequestsOperation + readonly request: { readonly list: FormRequestListOperation } readonly list: FormListOperation readonly create: FormCreateOperation readonly get: FormGetOperation @@ -573,7 +581,7 @@ export interface FormApi { type Endpoint14_0Request = Parameters[0] export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } export type Endpoint14_0Output = EffectValue> -export type PermissionListRequestsOperation = ( +export type PermissionRequestListOperation = ( input?: Endpoint14_0Input, ) => Effect.Effect @@ -582,14 +590,14 @@ export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["quer export type Endpoint14_1Output = EffectValue< ReturnType >["data"] -export type PermissionListSavedOperation = ( +export type PermissionSavedListOperation = ( input?: Endpoint14_1Input, ) => Effect.Effect type Endpoint14_2Request = Parameters[0] export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] } export type Endpoint14_2Output = EffectValue> -export type PermissionRemoveSavedOperation = ( +export type PermissionSavedRemoveOperation = ( input: Endpoint14_2Input, ) => Effect.Effect @@ -637,9 +645,8 @@ export type Endpoint14_6Output = EffectValue = (input: Endpoint14_6Input) => Effect.Effect export interface PermissionApi { - readonly listRequests: PermissionListRequestsOperation - readonly listSaved: PermissionListSavedOperation - readonly removeSaved: PermissionRemoveSavedOperation + readonly request: { readonly list: PermissionRequestListOperation } + readonly saved: { readonly list: PermissionSavedListOperation; readonly remove: PermissionSavedRemoveOperation } readonly create: PermissionCreateOperation readonly list: PermissionListOperation readonly get: PermissionGetOperation @@ -808,7 +815,7 @@ export interface ShellApi { type Endpoint21_0Request = Parameters[0] export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] } export type Endpoint21_0Output = EffectValue> -export type QuestionListRequestsOperation = ( +export type QuestionRequestListOperation = ( input?: Endpoint21_0Input, ) => Effect.Effect @@ -835,7 +842,7 @@ export type Endpoint21_3Output = EffectValue = (input: Endpoint21_3Input) => Effect.Effect export interface QuestionApi { - readonly listRequests: QuestionListRequestsOperation + readonly request: { readonly list: QuestionRequestListOperation } readonly list: QuestionListOperation readonly reply: QuestionReplyOperation readonly reject: QuestionRejectOperation @@ -905,16 +912,15 @@ export interface VcsApi { } export type Endpoint25_0Output = EffectValue> -export type DebugLocationOperation = () => Effect.Effect +export type DebugLocationListOperation = () => Effect.Effect type Endpoint25_1Request = Parameters[0] export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] } export type Endpoint25_1Output = EffectValue> -export type DebugEvictLocationOperation = (input?: Endpoint25_1Input) => Effect.Effect +export type DebugLocationEvictOperation = (input?: Endpoint25_1Input) => Effect.Effect export interface DebugApi { - readonly location: DebugLocationOperation - readonly evictLocation: DebugEvictLocationOperation + readonly location: { readonly list: DebugLocationListOperation; readonly evict: DebugLocationEvictOperation } } export interface AppApi { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 008cfb294d..5175567f45 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -381,9 +381,7 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ shell: Endpoint4_14(raw), compact: Endpoint4_15(raw), wait: Endpoint4_16(raw), - revertStage: Endpoint4_17(raw), - revertClear: Endpoint4_18(raw), - revertCommit: Endpoint4_19(raw), + revert: { stage: Endpoint4_17(raw), clear: Endpoint4_18(raw), commit: Endpoint4_19(raw) }, context: Endpoint4_20(raw), instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } }, log: Endpoint4_24(raw), @@ -536,11 +534,8 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_ const adaptGroup9 = (raw: RawClient["server.integration"]) => ({ list: Endpoint9_0(raw), get: Endpoint9_1(raw), - connectKey: Endpoint9_2(raw), - connectOauth: Endpoint9_3(raw), - attemptStatus: Endpoint9_4(raw), - attemptComplete: Endpoint9_5(raw), - attemptCancel: Endpoint9_6(raw), + connect: { key: Endpoint9_2(raw), oauth: Endpoint9_3(raw) }, + attempt: { status: Endpoint9_4(raw), complete: Endpoint9_5(raw), cancel: Endpoint9_6(raw) }, }) type Endpoint10_0Request = Parameters[0] @@ -553,7 +548,10 @@ type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["loc const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) => raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw), catalog: Endpoint10_1(raw) }) +const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ + list: Endpoint10_0(raw), + resource: { catalog: Endpoint10_1(raw) }, +}) type Endpoint11_0Request = Parameters[0] type Endpoint11_0Input = { @@ -690,7 +688,7 @@ const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Inpu ) const adaptGroup13 = (raw: RawClient["server.form"]) => ({ - listRequests: Endpoint13_0(raw), + request: { list: Endpoint13_0(raw) }, list: Endpoint13_1(raw), create: Endpoint13_2(raw), get: Endpoint13_3(raw), @@ -778,9 +776,8 @@ const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14 }).pipe(Effect.mapError(mapClientError)) const adaptGroup14 = (raw: RawClient["server.permission"]) => ({ - listRequests: Endpoint14_0(raw), - listSaved: Endpoint14_1(raw), - removeSaved: Endpoint14_2(raw), + request: { list: Endpoint14_0(raw) }, + saved: { list: Endpoint14_1(raw), remove: Endpoint14_2(raw) }, create: Endpoint14_3(raw), list: Endpoint14_4(raw), get: Endpoint14_5(raw), @@ -1013,7 +1010,7 @@ const Endpoint21_3 = (raw: RawClient["server.question"]) => (input: Endpoint21_3 ) const adaptGroup21 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint21_0(raw), + request: { list: Endpoint21_0(raw) }, list: Endpoint21_1(raw), reply: Endpoint21_2(raw), reject: Endpoint21_3(raw), @@ -1099,8 +1096,7 @@ const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1In raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) const adaptGroup25 = (raw: RawClient["server.debug"]) => ({ - location: Endpoint25_0(raw), - evictLocation: Endpoint25_1(raw), + location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) }, }) const adaptClient = (raw: RawClient) => ({ diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 79ecf0e630..cc1b6b1854 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -89,8 +89,8 @@ import type { IntegrationAttemptCancelOutput, ServerMcpListInput, ServerMcpListOutput, - ServerMcpCatalogInput, - ServerMcpCatalogOutput, + ServerMcpResourceCatalogInput, + ServerMcpResourceCatalogOutput, CredentialUpdateInput, CredentialUpdateOutput, CredentialRemoveInput, @@ -100,8 +100,8 @@ import type { ProjectCurrentOutput, ProjectDirectoriesInput, ProjectDirectoriesOutput, - FormListRequestsInput, - FormListRequestsOutput, + FormRequestListInput, + FormRequestListOutput, FormListInput, FormListOutput, FormCreateInput, @@ -114,12 +114,12 @@ import type { FormReplyOutput, FormCancelInput, FormCancelOutput, - PermissionListRequestsInput, - PermissionListRequestsOutput, - PermissionListSavedInput, - PermissionListSavedOutput, - PermissionRemoveSavedInput, - PermissionRemoveSavedOutput, + PermissionRequestListInput, + PermissionRequestListOutput, + PermissionSavedListInput, + PermissionSavedListOutput, + PermissionSavedRemoveInput, + PermissionSavedRemoveOutput, PermissionCreateInput, PermissionCreateOutput, PermissionListInput, @@ -161,8 +161,8 @@ import type { ShellOutputOutput, ShellRemoveInput, ShellRemoveOutput, - QuestionListRequestsInput, - QuestionListRequestsOutput, + QuestionRequestListInput, + QuestionRequestListOutput, QuestionListInput, QuestionListOutput, QuestionReplyInput, @@ -181,9 +181,9 @@ import type { VcsStatusOutput, VcsDiffInput, VcsDiffOutput, - DebugLocationOutput, - DebugEvictLocationInput, - DebugEvictLocationOutput, + DebugLocationListOutput, + DebugLocationEvictInput, + DebugLocationEvictOutput, } from "./types" import { ClientError } from "./client-error" @@ -601,40 +601,42 @@ export function make(options: ClientOptions) { }, requestOptions, ), - revertStage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionRevertStageOutput }>( - { - method: "POST", - path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, - body: { messageID: input["messageID"], files: input["files"] }, - successStatus: 200, - declaredStatuses: [404, 409, 500, 400, 401], - empty: false, - }, - requestOptions, - ).then((value) => value.data), - revertClear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, - successStatus: 204, - declaredStatuses: [404, 409, 500, 400, 401], - empty: true, - }, - requestOptions, - ), - revertCommit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, - successStatus: 204, - declaredStatuses: [404, 409, 400, 401], - empty: true, - }, - requestOptions, - ), + revert: { + stage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionRevertStageOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, + body: { messageID: input["messageID"], files: input["files"] }, + successStatus: 200, + declaredStatuses: [404, 409, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + clear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, + successStatus: 204, + declaredStatuses: [404, 409, 500, 400, 401], + empty: true, + }, + requestOptions, + ), + commit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, + successStatus: 204, + declaredStatuses: [404, 409, 400, 401], + empty: true, + }, + requestOptions, + ), + }, context: (input: SessionContextInput, requestOptions?: RequestOptions) => request<{ readonly data: SessionContextOutput }>( { @@ -836,69 +838,73 @@ export function make(options: ClientOptions) { }, requestOptions, ), - connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, - query: { location: input["location"] }, - body: { key: input["key"], label: input["label"] }, - successStatus: 204, - declaredStatuses: [400, 401], - empty: true, - }, - requestOptions, - ), - connectOauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, - query: { location: input["location"] }, - body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, - successStatus: 200, - declaredStatuses: [400, 401], - empty: false, - }, - requestOptions, - ), - attemptStatus: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, - query: { location: input["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), - attemptComplete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, - query: { location: input["location"] }, - body: { code: input["code"] }, - successStatus: 204, - declaredStatuses: [400, 401], - empty: true, - }, - requestOptions, - ), - attemptCancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => - request( - { - method: "DELETE", - path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, - query: { location: input["location"] }, - successStatus: 204, - declaredStatuses: [401, 400], - empty: true, - }, - requestOptions, - ), + connect: { + key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, + query: { location: input["location"] }, + body: { key: input["key"], label: input["label"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, + query: { location: input["location"] }, + body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] }, + successStatus: 200, + declaredStatuses: [400, 401], + empty: false, + }, + requestOptions, + ), + }, + attempt: { + status: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + complete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, + query: { location: input["location"] }, + body: { code: input["code"] }, + successStatus: 204, + declaredStatuses: [400, 401], + empty: true, + }, + requestOptions, + ), + cancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, + query: { location: input["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, }, "server.mcp": { list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) => @@ -913,18 +919,20 @@ export function make(options: ClientOptions) { }, requestOptions, ), - catalog: (input?: ServerMcpCatalogInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/mcp/resource`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), + resource: { + catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/mcp/resource`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, }, credential: { update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => @@ -985,18 +993,20 @@ export function make(options: ClientOptions) { ), }, form: { - listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/form/request`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), + request: { + list: (input?: FormRequestListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/form/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, list: (input: FormListInput, requestOptions?: RequestOptions) => request<{ readonly data: FormListOutput }>( { @@ -1074,41 +1084,45 @@ export function make(options: ClientOptions) { ), }, permission: { - listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/permission/request`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), - listSaved: (input?: PermissionListSavedInput, requestOptions?: RequestOptions) => - request<{ readonly data: PermissionListSavedOutput }>( - { - method: "GET", - path: `/api/permission/saved`, - query: { projectID: input?.["projectID"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ).then((value) => value.data), - removeSaved: (input: PermissionRemoveSavedInput, requestOptions?: RequestOptions) => - request( - { - method: "DELETE", - path: `/api/permission/saved/${encodeURIComponent(input.id)}`, - successStatus: 204, - declaredStatuses: [401, 400], - empty: true, - }, - requestOptions, - ), + request: { + list: (input?: PermissionRequestListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/permission/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, + saved: { + list: (input?: PermissionSavedListInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionSavedListOutput }>( + { + method: "GET", + path: `/api/permission/saved`, + query: { projectID: input?.["projectID"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + remove: (input: PermissionSavedRemoveInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/permission/saved/${encodeURIComponent(input.id)}`, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, create: (input: PermissionCreateInput, requestOptions?: RequestOptions) => request<{ readonly data: PermissionCreateOutput }>( { @@ -1390,18 +1404,20 @@ export function make(options: ClientOptions) { ), }, question: { - listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/question/request`, - query: { location: input?.["location"] }, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), + request: { + list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/question/request`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, list: (input: QuestionListInput, requestOptions?: RequestOptions) => request<{ readonly data: QuestionListOutput }>( { @@ -1518,29 +1534,31 @@ export function make(options: ClientOptions) { ), }, debug: { - location: (requestOptions?: RequestOptions) => - request( - { - method: "GET", - path: `/api/debug/location`, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), - evictLocation: (input?: DebugEvictLocationInput, requestOptions?: RequestOptions) => - request( - { - method: "DELETE", - path: `/api/debug/location`, - query: { location: input?.["location"] }, - successStatus: 204, - declaredStatuses: [401, 400], - empty: true, - }, - requestOptions, - ), + location: { + list: (requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/debug/location`, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + evict: (input?: DebugLocationEvictInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/debug/location`, + query: { location: input?.["location"] }, + successStatus: 204, + declaredStatuses: [401, 400], + empty: true, + }, + requestOptions, + ), + }, }, } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 07964f950e..79eae71375 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2508,13 +2508,13 @@ export type ServerMcpListOutput = { }> } -export type ServerMcpCatalogInput = { +export type ServerMcpResourceCatalogInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ServerMcpCatalogOutput = { +export type ServerMcpResourceCatalogOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2585,13 +2585,13 @@ export type ProjectDirectoriesInput = { export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }> -export type FormListRequestsInput = { +export type FormRequestListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type FormListRequestsOutput = { +export type FormRequestListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -3659,13 +3659,13 @@ export type FormCancelInput = { export type FormCancelOutput = void -export type PermissionListRequestsInput = { +export type PermissionRequestListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type PermissionListRequestsOutput = { +export type PermissionRequestListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -3682,9 +3682,9 @@ export type PermissionListRequestsOutput = { }> } -export type PermissionListSavedInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] } +export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] } -export type PermissionListSavedOutput = { +export type PermissionSavedListOutput = { readonly data: ReadonlyArray<{ readonly id: string readonly projectID: string @@ -3693,9 +3693,9 @@ export type PermissionListSavedOutput = { }> }["data"] -export type PermissionRemoveSavedInput = { readonly id: { readonly id: string }["id"] } +export type PermissionSavedRemoveInput = { readonly id: { readonly id: string }["id"] } -export type PermissionRemoveSavedOutput = void +export type PermissionSavedRemoveOutput = void export type PermissionCreateInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] @@ -6049,13 +6049,13 @@ export type ShellRemoveInput = { export type ShellRemoveOutput = void -export type QuestionListRequestsInput = { +export type QuestionRequestListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type QuestionListRequestsOutput = { +export type QuestionRequestListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -6221,12 +6221,12 @@ export type VcsDiffOutput = { }> } -export type DebugLocationOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> +export type DebugLocationListOutput = ReadonlyArray<{ readonly directory: string; readonly workspaceID?: string }> -export type DebugEvictLocationInput = { +export type DebugLocationEvictInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type DebugEvictLocationOutput = void +export type DebugLocationEvictOutput = void diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index a2afc9a002..8e14ad1ee8 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -19,7 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message" import { Workspace } from "@opencode-ai/schema/workspace" import { Api } from "@opencode-ai/server/api" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" -import { ClientApi, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract" +import { ClientApi, groupNames, promiseOmitEndpoints } from "../src/contract" const Client = await import("../src/effect") @@ -49,8 +49,8 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => }) test("client and Server contracts generate identically", () => { - const server = compile(Api, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) - const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) + const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints }) + const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints }) expect(emitPromise(client)).toEqual(emitPromise(server)) }) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 9b26efe773..e67d737942 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -32,17 +32,12 @@ test("exposes every standard HTTP API group", () => { "vcs", "debug", ]) - expect(Object.keys(client.debug)).toEqual(["location", "evictLocation"]) + expect(Object.keys(client.debug)).toEqual(["location"]) + expect(Object.keys(client.debug.location)).toEqual(["list", "evict"]) expect(Object.keys(client.message)).toEqual(["list"]) - expect(Object.keys(client.integration)).toEqual([ - "list", - "get", - "connectKey", - "connectOauth", - "attemptStatus", - "attemptComplete", - "attemptCancel", - ]) + expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"]) + expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"]) + expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) @@ -66,7 +61,7 @@ test("MCP resource catalog uses the public HTTP contract", async () => { }, }) - const result = await client["server.mcp"].catalog({ location: { directory: "/tmp/project" } }) + const result = await client["server.mcp"].resource.catalog({ location: { directory: "/tmp/project" } }) expect(result.data.resources[0]?.uri).toBe("docs://readme") expect(request?.method).toBe("GET") diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index b20a25b25e..a37a3c5bd7 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -9,7 +9,7 @@ "/api/health": { "get": { "tags": [ - "server.health" + "health" ], "operationId": "v2.health.get", "parameters": [], @@ -27,10 +27,23 @@ "enum": [ true ] + }, + "version": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] } }, "required": [ - "healthy" + "healthy", + "version", + "pid" ], "additionalProperties": false } @@ -65,7 +78,7 @@ "/api/location": { "get": { "tags": [ - "server.location" + "location" ], "operationId": "v2.location.get", "parameters": [ @@ -150,7 +163,7 @@ "/api/agent": { "get": { "tags": [ - "server.agent" + "agent" ], "operationId": "v2.agent.list", "parameters": [ @@ -251,7 +264,7 @@ "/api/plugin": { "get": { "tags": [ - "plugins" + "plugin" ], "operationId": "v2.plugin.list", "parameters": [ @@ -352,7 +365,7 @@ "/api/session": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.list", "parameters": [ @@ -568,7 +581,7 @@ }, "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.create", "parameters": [], @@ -679,7 +692,7 @@ "/api/session/active": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.active", "parameters": [], @@ -699,14 +712,10 @@ "$ref": "#/components/schemas/SessionActive" } } - }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" } }, "required": [ - "data", - "watermarks" + "data" ], "additionalProperties": false } @@ -734,14 +743,14 @@ } } }, - "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.", + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.", "summary": "List active sessions" } }, "/api/session/{sessionID}": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.get", "parameters": [ @@ -820,12 +829,78 @@ }, "description": "Retrieve a session by ID.", "summary": "Get session" + }, + "delete": { + "tags": [ + "session" + ], + "operationId": "v2.session.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Delete a session and its child sessions.", + "summary": "Delete session" } }, "/api/session/{sessionID}/fork": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.fork", "parameters": [ @@ -940,7 +1015,7 @@ "/api/session/{sessionID}/agent": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.switchAgent", "parameters": [ @@ -1027,7 +1102,7 @@ "/api/session/{sessionID}/model": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.switchModel", "parameters": [ @@ -1114,7 +1189,7 @@ "/api/session/{sessionID}/rename": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.rename", "parameters": [ @@ -1198,10 +1273,123 @@ } } }, + "/api/session/{sessionID}/move": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.move", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Move a session to another project directory, optionally transferring local changes.", + "summary": "Move session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destination": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "moveChanges": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "destination" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.prompt", "parameters": [ @@ -1245,7 +1433,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1353,7 +1548,7 @@ "/api/session/{sessionID}/command": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.command", "parameters": [ @@ -1397,7 +1592,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1560,7 +1762,7 @@ "/api/session/{sessionID}/skill": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.skill", "parameters": [ @@ -1675,7 +1877,7 @@ "/api/session/{sessionID}/synthetic": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.synthetic", "parameters": [ @@ -1759,6 +1961,16 @@ }, "metadata": { "type": "object" + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1772,12 +1984,12 @@ } } }, - "/api/session/{sessionID}/compact": { + "/api/session/{sessionID}/shell": { "post": { "tags": [ - "sessions" + "session" ], - "operationId": "v2.session.compact", + "operationId": "v2.session.shell", "parameters": [ { "name": "sessionID", @@ -1818,6 +2030,124 @@ } } }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.", + "summary": "Run shell command", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + { + "type": "null" + } + ] + }, + "command": { + "type": "string" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, + "/api/session/{sessionID}/compact": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.compact", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Compaction" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, "404": { "description": "SessionNotFoundError", "content": { @@ -1836,44 +2166,52 @@ } }, "409": { - "description": "SessionBusyError", + "description": "ConflictError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionBusyError" - } - } - } - }, - "500": { - "description": "UnknownError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnknownError" - } - } - } - }, - "503": { - "description": "ServiceUnavailableError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceUnavailableError" + "$ref": "#/components/schemas/ConflictError" } } } } }, - "description": "Compact a session conversation.", - "summary": "Compact session" + "description": "Queue a durable session compaction request.", + "summary": "Compact session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } } }, "/api/session/{sessionID}/wait": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.wait", "parameters": [ @@ -1951,7 +2289,7 @@ "/api/session/{sessionID}/revert/stage": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.revert.stage", "parameters": [ @@ -2092,7 +2430,7 @@ "/api/session/{sessionID}/revert/clear": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.revert.clear", "parameters": [ @@ -2179,7 +2517,7 @@ "/api/session/{sessionID}/revert/commit": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.revert.commit", "parameters": [ @@ -2256,7 +2594,7 @@ "/api/session/{sessionID}/context": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.context", "parameters": [ @@ -2353,7 +2691,7 @@ "/api/session/{sessionID}/instructions/entries": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.instructions.entry.list", "parameters": [ @@ -2440,7 +2778,7 @@ "/api/session/{sessionID}/instructions/entries/{key}": { "put": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.instructions.entry.put", "parameters": [ @@ -2531,7 +2869,7 @@ }, "delete": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.instructions.entry.remove", "parameters": [ @@ -2604,10 +2942,10 @@ "summary": "Remove instruction entry" } }, - "/api/session/{sessionID}/log": { + "/api/experimental/session/{sessionID}/log": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.log", "parameters": [ @@ -2809,14 +3147,14 @@ } } }, - "description": "Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.", + "description": "Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.", "summary": "Read the session log" } }, "/api/session/{sessionID}/interrupt": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.interrupt", "parameters": [ @@ -2884,7 +3222,7 @@ "/api/session/{sessionID}/background": { "post": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.background", "parameters": [ @@ -2952,7 +3290,7 @@ "/api/session/{sessionID}/message/{messageID}": { "get": { "tags": [ - "sessions" + "session" ], "operationId": "v2.session.message", "parameters": [ @@ -3052,9 +3390,9 @@ "/api/session/{sessionID}/message": { "get": { "tags": [ - "messages" + "session" ], - "operationId": "v2.session.messages", + "operationId": "v2.message.list", "parameters": [ { "name": "sessionID", @@ -3196,7 +3534,7 @@ "/api/model": { "get": { "tags": [ - "models" + "model" ], "operationId": "v2.model.list", "parameters": [ @@ -3307,7 +3645,7 @@ "/api/model/default": { "get": { "tags": [ - "models" + "model" ], "operationId": "v2.model.default", "parameters": [ @@ -3553,7 +3891,7 @@ "/api/provider": { "get": { "tags": [ - "providers" + "provider" ], "operationId": "v2.provider.list", "parameters": [ @@ -3664,7 +4002,7 @@ "/api/provider/{providerID}": { "get": { "tags": [ - "providers" + "provider" ], "operationId": "v2.provider.get", "parameters": [ @@ -3790,7 +4128,7 @@ "/api/integration": { "get": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.list", "parameters": [ @@ -3891,7 +4229,7 @@ "/api/integration/{integrationID}": { "get": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.get", "parameters": [ @@ -4004,7 +4342,7 @@ "/api/integration/{integrationID}/connect/key": { "post": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.connect.key", "parameters": [ @@ -4126,7 +4464,7 @@ "/api/integration/{integrationID}/connect/oauth": { "post": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.connect.oauth", "parameters": [ @@ -4275,7 +4613,7 @@ "/api/integration/attempt/{attemptID}": { "get": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.attempt.status", "parameters": [ @@ -4379,7 +4717,7 @@ }, "delete": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.attempt.cancel", "parameters": [ @@ -4465,7 +4803,7 @@ "/api/integration/attempt/{attemptID}/complete": { "post": { "tags": [ - "integrations" + "integration" ], "operationId": "v2.integration.attempt.complete", "parameters": [ @@ -4679,10 +5017,108 @@ "summary": "List MCP servers" } }, + "/api/mcp/resource": { + "get": { + "tags": [ + "mcp" + ], + "operationId": "v2.mcp.resource.catalog", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Mcp.ResourceCatalog" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve resources and resource templates from connected MCP servers.", + "summary": "List MCP resources" + } + }, "/api/credential/{credentialID}": { "patch": { "tags": [ - "server.credential" + "credential" ], "operationId": "v2.credential.update", "parameters": [ @@ -4785,7 +5221,7 @@ }, "delete": { "tags": [ - "server.credential" + "credential" ], "operationId": "v2.credential.remove", "parameters": [ @@ -4868,10 +5304,57 @@ "summary": "Remove credential" } }, + "/api/project": { + "get": { + "tags": [ + "project" + ], + "operationId": "v2.project.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List known projects.", + "summary": "List projects" + } + }, "/api/project/current": { "get": { "tags": [ - "projects" + "project" ], "operationId": "v2.project.current", "parameters": [ @@ -4956,7 +5439,7 @@ "/api/project/{projectID}/directories": { "get": { "tags": [ - "projects" + "project" ], "operationId": "v2.project.directories", "parameters": [ @@ -5049,7 +5532,7 @@ "/api/form/request": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.form.request.list", "parameters": [ @@ -5157,7 +5640,7 @@ "/api/session/{sessionID}/form": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.list", "parameters": [ @@ -5244,7 +5727,7 @@ }, "post": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.create", "parameters": [ @@ -5357,7 +5840,7 @@ "/api/session/{sessionID}/form/{formID}": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.get", "parameters": [ @@ -5459,7 +5942,7 @@ "/api/session/{sessionID}/form/{formID}/state": { "get": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.state", "parameters": [ @@ -5554,7 +6037,7 @@ "/api/session/{sessionID}/form/{formID}/reply": { "post": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.reply", "parameters": [ @@ -5660,7 +6143,7 @@ "/api/session/{sessionID}/form/{formID}/cancel": { "post": { "tags": [ - "forms" + "form" ], "operationId": "v2.session.form.cancel", "parameters": [ @@ -5749,7 +6232,7 @@ "/api/permission/request": { "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.permission.request.list", "parameters": [ @@ -5850,7 +6333,7 @@ "/api/permission/saved": { "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.permission.saved.list", "parameters": [ @@ -5922,7 +6405,7 @@ "/api/permission/saved/{id}": { "delete": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.permission.saved.remove", "parameters": [ @@ -5968,7 +6451,7 @@ "/api/session/{sessionID}/permission": { "post": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.create", "parameters": [ @@ -6131,7 +6614,7 @@ }, "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.list", "parameters": [ @@ -6218,7 +6701,7 @@ "/api/session/{sessionID}/permission/{requestID}": { "get": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.get", "parameters": [ @@ -6318,7 +6801,7 @@ "/api/session/{sessionID}/permission/{requestID}/reply": { "post": { "tags": [ - "permissions" + "permission" ], "operationId": "v2.session.permission.reply", "parameters": [ @@ -6769,7 +7252,7 @@ "/api/command": { "get": { "tags": [ - "commands" + "command" ], "operationId": "v2.command.list", "parameters": [ @@ -6870,7 +7353,7 @@ "/api/skill": { "get": { "tags": [ - "skills" + "skill" ], "operationId": "v2.skill.list", "parameters": [ @@ -6971,7 +7454,7 @@ "/api/event": { "get": { "tags": [ - "events" + "event" ], "operationId": "v2.event.subscribe", "parameters": [], @@ -7108,154 +7591,10 @@ } } }, - "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.", + "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.", "summary": "Subscribe to events" } }, - "/api/event/changes": { - "get": { - "tags": [ - "events" - ], - "operationId": "v2.event.changes", - "parameters": [], - "security": [], - "responses": { - "200": { - "description": "Success", - "content": { - "text/event-stream": { - "schema": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "event": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/EventLog.ChangeStream" - } - }, - "required": [ - "id", - "event", - "data" - ], - "additionalProperties": false - }, - "x-effect-stream": { - "encoding": "sse", - "causeSchema": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Fail" - ] - }, - "error": { - "not": {} - } - }, - "required": [ - "_tag", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Die" - ] - }, - "defect": {} - }, - "required": [ - "_tag", - "defect" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Interrupt" - ] - }, - "fiberId": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "fiberId" - ], - "additionalProperties": false - } - ] - } - }, - "errorSchema": { - "not": {} - }, - "failureEvent": "effect/httpapi/stream/failure" - } - } - } - }, - "400": { - "description": "InvalidRequestError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InvalidRequestError" - } - } - } - }, - "401": { - "description": "UnauthorizedError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedError" - } - } - } - } - }, - "description": "Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.", - "summary": "Subscribe to change hints" - } - }, "/api/pty": { "get": { "tags": [ @@ -7873,7 +8212,7 @@ "tags": [ "pty" ], - "operationId": "v2.pty.connectToken", + "operationId": "v2.pty.connect.token", "parameters": [ { "name": "ptyID", @@ -8005,7 +8344,6 @@ "pty" ], "operationId": "v2.pty.connect", - "x-websocket": true, "parameters": [ { "name": "ptyID", @@ -8103,7 +8441,8 @@ } }, "description": "Establish a WebSocket connection streaming PTY output and accepting terminal input.", - "summary": "Connect to PTY session" + "summary": "Connect to PTY session", + "x-websocket": true } }, "/api/shell": { @@ -8169,7 +8508,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } } }, @@ -8266,7 +8605,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -8326,7 +8665,8 @@ } }, "required": [ - "command" + "command", + "timeout" ], "additionalProperties": false } @@ -8410,7 +8750,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -8556,6 +8896,151 @@ "summary": "Remove shell command" } }, + "/api/shell/{id}/timeout": { + "patch": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.timeout", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Replace a running shell command's timeout from now, or clear it with zero.", + "summary": "Update shell timeout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "timeout" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/shell/{id}/output": { "get": { "tags": [ @@ -8737,7 +9222,7 @@ "/api/question/request": { "get": { "tags": [ - "session questions" + "question" ], "operationId": "v2.question.request.list", "parameters": [ @@ -8838,7 +9323,7 @@ "/api/session/{sessionID}/question": { "get": { "tags": [ - "session questions" + "question" ], "operationId": "v2.session.question.list", "parameters": [ @@ -8925,7 +9410,7 @@ "/api/session/{sessionID}/question/{requestID}/reply": { "post": { "tags": [ - "session questions" + "question" ], "operationId": "v2.session.question.reply", "parameters": [ @@ -9019,7 +9504,7 @@ "/api/session/{sessionID}/question/{requestID}/reject": { "post": { "tags": [ - "session questions" + "question" ], "operationId": "v2.session.question.reject", "parameters": [ @@ -9752,6 +10237,129 @@ "description": "Diff the working copy against HEAD (mode git) or the default-branch merge base (mode branch) for the requested location.", "summary": "VCS diff" } + }, + "/api/debug/location": { + "get": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location.list", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Location.Ref" + } + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List locations currently loaded by the server.", + "summary": "List loaded locations" + }, + "delete": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location.evict", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Dispose the requested location's cached services so its next use boots them fresh.", + "summary": "Evict a loaded location" + } } }, "components": { @@ -10133,6 +10741,31 @@ } ] }, + "fork": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + }, "projectID": { "type": "string" }, @@ -10225,20 +10858,6 @@ ], "additionalProperties": false }, - "SessionWatermarks": { - "type": "object", - "patternProperties": { - "^ses": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "description": "Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent." - }, "SessionsResponse": { "type": "object", "properties": { @@ -10248,9 +10867,6 @@ "$ref": "#/components/schemas/SessionV2.Info" } }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" - }, "cursor": { "type": "object", "properties": { @@ -10280,7 +10896,6 @@ }, "required": [ "data", - "watermarks", "cursor" ], "additionalProperties": false @@ -10408,7 +11023,7 @@ ], "additionalProperties": false }, - "Prompt.Source": { + "Prompt.Mention": { "type": "object", "properties": { "start": { @@ -10440,8 +11055,8 @@ "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10455,8 +11070,8 @@ "name": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10488,28 +11103,78 @@ ], "additionalProperties": false }, + "Prompt.Base64": { + "type": "string", + "allOf": [ + { + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + } + ] + }, + "Prompt.FileSource": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inline" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "uri" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "additionalProperties": false + } + ] + }, "Prompt.FileAttachment": { "type": "object", "properties": { - "uri": { - "type": "string" + "data": { + "$ref": "#/components/schemas/Prompt.Base64" }, "mime": { "type": "string" }, + "source": { + "$ref": "#/components/schemas/Prompt.FileSource" + }, "name": { "type": "string" }, "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ - "uri", - "mime" + "data", + "mime", + "source" ], "additionalProperties": false }, @@ -10694,26 +11359,57 @@ ], "additionalProperties": false }, - "SessionBusyError": { + "SessionInput.Compaction": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", "enum": [ - "SessionBusyError" + "compaction" + ] + }, + "admittedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } ] }, "sessionID": { - "type": "string" + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, - "message": { - "type": "string" + "timeCreated": { + "type": "number" + }, + "handledSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ - "_tag", + "type", + "admittedSeq", + "id", "sessionID", - "message" + "timeCreated" ], "additionalProperties": false }, @@ -10746,6 +11442,29 @@ ], "additionalProperties": false }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionBusyError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, "UnknownError": { "type": "object", "properties": { @@ -10775,7 +11494,7 @@ ], "additionalProperties": false }, - "Session.Message.AgentSwitched": { + "Session.Message.AgentSelected": { "type": "object", "properties": { "id": { @@ -10819,7 +11538,7 @@ ], "additionalProperties": false }, - "Session.Message.ModelSwitched": { + "Session.Message.ModelSelected": { "type": "object", "properties": { "id": { @@ -10853,6 +11572,9 @@ }, "model": { "$ref": "#/components/schemas/Model.Ref" + }, + "previous": { + "$ref": "#/components/schemas/Model.Ref" } }, "required": [ @@ -11067,6 +11789,182 @@ ], "additionalProperties": false }, + "Shell": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "completed": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" + ], + "additionalProperties": false + }, "Session.Message.Shell": { "type": "object", "properties": { @@ -11102,23 +12000,49 @@ "shell" ] }, - "callID": { - "type": "string" - }, - "command": { - "type": "string" + "shell": { + "$ref": "#/components/schemas/Shell" }, "output": { - "type": "string" + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false } }, "required": [ "id", "time", "type", - "callID", - "command", - "output" + "shell" ], "additionalProperties": false }, @@ -11131,25 +12055,18 @@ "text" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" } }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "LLM.ProviderMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState": { + "type": "object" }, "Session.Message.Assistant.Reasoning": { "type": "object", @@ -11160,14 +12077,11 @@ "reasoning" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "time": { "type": "object", @@ -11187,7 +12101,6 @@ }, "required": [ "type", - "id", "text" ], "additionalProperties": false @@ -11339,14 +12252,11 @@ ], "additionalProperties": false }, - "Session.Error.Unknown": { + "Session.StructuredError": { "type": "object", "properties": { "type": { - "type": "string", - "enum": [ - "unknown" - ] + "type": "string" }, "message": { "type": "string" @@ -11380,7 +12290,7 @@ "type": "object" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {} }, @@ -11408,23 +12318,14 @@ "name": { "type": "string" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - }, - "resultMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" + }, + "providerResultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "state": { "anyOf": [ @@ -11473,6 +12374,31 @@ ], "additionalProperties": false }, + "Session.Message.Assistant.Retry": { + "type": "object", + "properties": { + "attempt": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "attempt", + "at", + "error" + ], + "additionalProperties": false + }, "Session.Message.Assistant": { "type": "object", "properties": { @@ -11549,7 +12475,15 @@ "additionalProperties": false }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { "type": "number" @@ -11592,7 +12526,10 @@ "additionalProperties": false }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "retry": { + "$ref": "#/components/schemas/Session.Message.Assistant.Retry" } }, "required": [ @@ -11614,6 +12551,15 @@ "compaction" ] }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "completed", + "failed" + ] + }, "reason": { "type": "string", "enum": [ @@ -11653,6 +12599,7 @@ }, "required": [ "type", + "status", "reason", "summary", "recent", @@ -11664,10 +12611,10 @@ "Session.Message": { "anyOf": [ { - "$ref": "#/components/schemas/Session.Message.AgentSwitched" + "$ref": "#/components/schemas/Session.Message.AgentSelected" }, { - "$ref": "#/components/schemas/Session.Message.ModelSwitched" + "$ref": "#/components/schemas/Session.Message.ModelSelected" }, { "$ref": "#/components/schemas/Session.Message.User" @@ -11697,7 +12644,7 @@ "allOf": [ { "pattern": "^[a-z0-9][a-z0-9._-]*$", - "description": "Context entry key (lowercase alphanumerics plus . _ -)" + "description": "Instruction entry key (lowercase alphanumerics plus . _ -)" } ] }, @@ -11715,7 +12662,7 @@ ], "additionalProperties": false }, - "session.next.agent.switched": { + "session.agent.selected": { "type": "object", "properties": { "id": { @@ -11726,13 +12673,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.agent.switched" + "session.agent.selected" ] }, "durable": { @@ -11771,9 +12721,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -11782,22 +12729,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "agent": { "type": "string" } }, "required": [ - "timestamp", "sessionID", - "messageID", "agent" ], "additionalProperties": false @@ -11805,12 +12742,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.model.switched": { + "session.model.selected": { "type": "object", "properties": { "id": { @@ -11821,13 +12760,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.model.switched" + "session.model.selected" ] }, "durable": { @@ -11866,9 +12808,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -11877,22 +12816,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "model": { "$ref": "#/components/schemas/Model.Ref" } }, "required": [ - "timestamp", "sessionID", - "messageID", "model" ], "additionalProperties": false @@ -11900,12 +12829,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.moved": { + "session.moved": { "type": "object", "properties": { "id": { @@ -11916,13 +12847,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.moved" + "session.moved" ] }, "durable": { @@ -11961,9 +12895,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -11975,12 +12906,11 @@ "location": { "$ref": "#/components/schemas/Location.Ref" }, - "subdirectory": { + "subpath": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "location" ], @@ -11989,12 +12919,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.renamed": { + "session.renamed": { "type": "object", "properties": { "id": { @@ -12005,13 +12937,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.renamed" + "session.renamed" ] }, "durable": { @@ -12050,9 +12985,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12066,7 +12998,6 @@ } }, "required": [ - "timestamp", "sessionID", "title" ], @@ -12075,12 +13006,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.forked": { + "session.deleted": { "type": "object", "properties": { "id": { @@ -12091,13 +13024,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.forked" + "session.deleted" ] }, "durable": { @@ -12136,9 +13072,89 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.forked": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.forked" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -12155,7 +13171,7 @@ } ] }, - "messageID": { + "from": { "type": "string", "allOf": [ { @@ -12165,7 +13181,6 @@ } }, "required": [ - "timestamp", "sessionID", "parentID" ], @@ -12174,12 +13189,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.prompted": { + "session.prompt.promoted": { "type": "object", "properties": { "id": { @@ -12190,13 +13207,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.prompted" + "session.prompt.promoted" ] }, "durable": { @@ -12235,9 +13255,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12246,7 +13263,99 @@ } ] }, - "messageID": { + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.prompt.admitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.prompt.admitted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "inputID": { "type": "string", "allOf": [ { @@ -12266,9 +13375,8 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", + "inputID", "prompt", "delivery" ], @@ -12277,12 +13385,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.prompt.admitted": { + "session.execution.started": { "type": "object", "properties": { "id": { @@ -12293,13 +13403,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.prompt.admitted" + "session.execution.started" ] }, "durable": { @@ -12338,9 +13451,172 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.succeeded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.succeeded" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -12349,43 +13625,119 @@ } ] }, - "messageID": { - "type": "string", + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "sessionID", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.interrupted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.interrupted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", "allOf": [ { - "pattern": "^msg_" + "minimum": 0 } ] }, - "prompt": { - "$ref": "#/components/schemas/Prompt" + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, - "delivery": { + "reason": { "type": "string", "enum": [ - "steer", - "queue" + "user", + "shutdown", + "superseded" ] } }, "required": [ - "timestamp", "sessionID", - "messageID", - "prompt", - "delivery" + "reason" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.instructions.updated": { + "session.instructions.updated": { "type": "object", "properties": { "id": { @@ -12396,13 +13748,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.instructions.updated" + "session.instructions.updated" ] }, "durable": { @@ -12441,9 +13796,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12452,22 +13804,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "text": { "type": "string" } }, "required": [ - "timestamp", "sessionID", - "messageID", "text" ], "additionalProperties": false @@ -12475,12 +13817,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.synthetic": { + "session.synthetic": { "type": "object", "properties": { "id": { @@ -12491,13 +13835,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.synthetic" + "session.synthetic" ] }, "durable": { @@ -12536,9 +13883,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12547,14 +13891,6 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "text": { "type": "string" }, @@ -12566,9 +13902,7 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", "text" ], "additionalProperties": false @@ -12576,12 +13910,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.skill.activated": { + "session.skill.activated": { "type": "object", "properties": { "id": { @@ -12592,13 +13928,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.skill.activated" + "session.skill.activated" ] }, "durable": { @@ -12637,9 +13976,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12648,14 +13984,6 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "name": { "type": "string" }, @@ -12664,9 +13992,7 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", "name", "text" ], @@ -12675,111 +14001,154 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.shell.started": { + "Shell1": { "type": "object", "properties": { "id": { "type": "string", "allOf": [ { - "pattern": "^evt_" + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] } ] }, "metadata": { "type": "object" }, - "type": { - "type": "string", - "enum": [ - "session.next.shell.started" - ] - }, - "durable": { + "time": { "type": "object", "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ + "started": { + "anyOf": [ { - "minimum": 0 + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] } ] }, - "version": { - "type": "integer", - "allOf": [ + "completed": { + "anyOf": [ { - "minimum": 1 + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] } ] } }, "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - "callID": { - "type": "string" - }, - "command": { - "type": "string" - } - }, - "required": [ - "timestamp", - "sessionID", - "messageID", - "callID", - "command" + "started" ], "additionalProperties": false } }, "required": [ "id", - "type", - "data" + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" ], "additionalProperties": false }, - "session.next.shell.ended": { + "session.shell.started": { "type": "object", "properties": { "id": { @@ -12790,13 +14159,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.shell.ended" + "session.shell.started" ] }, "durable": { @@ -12835,9 +14207,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12846,17 +14215,134 @@ } ] }, - "callID": { - "type": "string" - }, - "output": { - "type": "string" + "shell": { + "$ref": "#/components/schemas/Shell1" } }, "required": [ - "timestamp", "sessionID", - "callID", + "shell" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.shell.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.shell.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "shell": { + "$ref": "#/components/schemas/Shell1" + }, + "output": { + "type": "object", + "properties": { + "output": { + "type": "string" + }, + "cursor": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "size": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "output", + "cursor", + "size", + "truncated" + ], + "additionalProperties": false + } + }, + "required": [ + "sessionID", + "shell", "output" ], "additionalProperties": false @@ -12864,12 +14350,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.step.started": { + "session.step.started": { "type": "object", "properties": { "id": { @@ -12880,13 +14368,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.step.started" + "session.step.started" ] }, "durable": { @@ -12925,9 +14416,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -12955,7 +14443,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "agent", @@ -12966,12 +14453,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.step.ended": { + "session.step.ended": { "type": "object", "properties": { "id": { @@ -12982,13 +14471,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.step.ended" + "session.step.ended" ] }, "durable": { @@ -13027,9 +14519,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13047,7 +14536,15 @@ ] }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { "type": "number" @@ -13100,7 +14597,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "finish", @@ -13112,12 +14608,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.step.failed": { + "session.step.failed": { "type": "object", "properties": { "id": { @@ -13128,13 +14626,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.step.failed" + "session.step.failed" ] }, "durable": { @@ -13173,9 +14674,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13193,11 +14691,50 @@ ] }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "error" @@ -13207,12 +14744,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.text.started": { + "session.text.started": { "type": "object", "properties": { "id": { @@ -13223,13 +14762,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.text.started" + "session.text.started" ] }, "durable": { @@ -13268,9 +14810,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13287,27 +14826,33 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "textID" + "ordinal" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.text.ended": { + "session.text.ended": { "type": "object", "properties": { "id": { @@ -13318,13 +14863,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.text.ended" + "session.text.ended" ] }, "durable": { @@ -13363,9 +14911,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13382,18 +14927,22 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "text": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "textID", + "ordinal", "text" ], "additionalProperties": false @@ -13401,12 +14950,17 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.tool.input.started": { + "Session.Message.ProviderState3": { + "type": "object" + }, + "session.reasoning.started": { "type": "object", "properties": { "id": { @@ -13417,13 +14971,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.input.started" + "session.reasoning.started" ] }, "durable": { @@ -13462,9 +15019,221 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState3" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "ordinal" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "Session.Message.ProviderState4": { + "type": "object" + }, + "session.reasoning.ended": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.reasoning.ended" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "text": { + "type": "string" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState4" + } + }, + "required": [ + "sessionID", + "assistantMessageID", + "ordinal", + "text" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.tool.input.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.tool.input.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -13489,7 +15258,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -13500,12 +15268,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.tool.input.ended": { + "session.tool.input.ended": { "type": "object", "properties": { "id": { @@ -13516,13 +15286,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.input.ended" + "session.tool.input.ended" ] }, "durable": { @@ -13561,9 +15334,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13588,7 +15358,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -13599,18 +15368,17 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata3": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState5": { + "type": "object" }, - "session.next.tool.called": { + "session.tool.called": { "type": "object", "properties": { "id": { @@ -13621,13 +15389,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.called" + "session.tool.called" ] }, "durable": { @@ -13666,9 +15437,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13688,48 +15456,36 @@ "callID": { "type": "string" }, - "tool": { - "type": "string" - }, "input": { "type": "object" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata3" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState5" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", - "tool", "input", - "provider" + "executed" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.tool.progress": { + "session.tool.progress": { "type": "object", "properties": { "id": { @@ -13740,13 +15496,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.progress" + "session.tool.progress" ] }, "durable": { @@ -13785,9 +15544,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13818,7 +15574,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -13830,18 +15585,17 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata4": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState6": { + "type": "object" }, - "session.next.tool.success": { + "session.tool.success": { "type": "object", "properties": { "id": { @@ -13852,13 +15606,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.success" + "session.tool.success" ] }, "durable": { @@ -13897,9 +15654,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -13935,48 +15689,37 @@ } }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata4" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState6" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", - "provider" + "executed" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata5": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState7": { + "type": "object" }, - "session.next.tool.failed": { + "session.tool.failed": { "type": "object", "properties": { "id": { @@ -13987,13 +15730,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.failed" + "session.tool.failed" ] }, "durable": { @@ -14032,9 +15778,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14055,50 +15798,36 @@ "type": "string" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata5" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState7" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", "error", - "provider" + "executed" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "LLM.ProviderMetadata6": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "session.next.reasoning.started": { + "session.retry.scheduled": { "type": "object", "properties": { "id": { @@ -14109,253 +15838,16 @@ } ] }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.reasoning.started" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "assistantMessageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - "reasoningID": { - "type": "string" - }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata6" - } - }, - "required": [ - "timestamp", - "sessionID", - "assistantMessageID", - "reasoningID" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "LLM.ProviderMetadata7": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "session.next.reasoning.ended": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "session.next.reasoning.ended" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, - "assistantMessageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, - "reasoningID": { - "type": "string" - }, - "text": { - "type": "string" - }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata7" - } - }, - "required": [ - "timestamp", - "sessionID", - "assistantMessageID", - "reasoningID", - "text" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "type", - "data" - ], - "additionalProperties": false - }, - "session.next.retry_error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { + "created": { "type": "number" }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "message", - "isRetryable" - ], - "additionalProperties": false - }, - "session.next.retried": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.retried" + "session.retry.scheduled" ] }, "durable": { @@ -14394,9 +15886,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14405,17 +15894,39 @@ } ] }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, "attempt": { - "type": "number" + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "error": { - "$ref": "#/components/schemas/session.next.retry_error" + "$ref": "#/components/schemas/Session.StructuredError" } }, "required": [ - "timestamp", "sessionID", + "assistantMessageID", "attempt", + "at", "error" ], "additionalProperties": false @@ -14423,12 +15934,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.compaction.started": { + "session.compaction.admitted": { "type": "object", "properties": { "id": { @@ -14439,13 +15952,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.compaction.started" + "session.compaction.admitted" ] }, "durable": { @@ -14484,9 +16000,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14495,13 +16008,97 @@ } ] }, - "messageID": { + "inputID": { "type": "string", "allOf": [ { "pattern": "^msg_" } ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.compaction.started": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, "reason": { "type": "string", @@ -14512,9 +16109,7 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", "reason" ], "additionalProperties": false @@ -14522,12 +16117,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.compaction.ended": { + "session.compaction.ended": { "type": "object", "properties": { "id": { @@ -14538,13 +16135,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.compaction.ended" + "session.compaction.ended" ] }, "durable": { @@ -14583,9 +16183,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14594,14 +16191,6 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "reason": { "type": "string", "enum": [ @@ -14617,9 +16206,7 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID", "reason", "text", "recent" @@ -14629,12 +16216,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.revert.staged": { + "session.compaction.failed": { "type": "object", "properties": { "id": { @@ -14645,13 +16234,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.revert.staged" + "session.compaction.failed" ] }, "durable": { @@ -14690,9 +16282,89 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.revert.staged": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.revert.staged" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { "sessionID": { "type": "string", "allOf": [ @@ -14706,7 +16378,6 @@ } }, "required": [ - "timestamp", "sessionID", "revert" ], @@ -14715,12 +16386,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.revert.cleared": { + "session.revert.cleared": { "type": "object", "properties": { "id": { @@ -14731,13 +16404,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.revert.cleared" + "session.revert.cleared" ] }, "durable": { @@ -14776,9 +16452,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14789,7 +16462,6 @@ } }, "required": [ - "timestamp", "sessionID" ], "additionalProperties": false @@ -14797,12 +16469,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.revert.committed": { + "session.revert.committed": { "type": "object", "properties": { "id": { @@ -14813,13 +16487,16 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.revert.committed" + "session.revert.committed" ] }, "durable": { @@ -14858,9 +16535,6 @@ "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -14869,7 +16543,7 @@ } ] }, - "messageID": { + "to": { "type": "string", "allOf": [ { @@ -14879,16 +16553,17 @@ } }, "required": [ - "timestamp", "sessionID", - "messageID" + "to" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -14896,97 +16571,118 @@ "SessionDurableEvent": { "oneOf": [ { - "$ref": "#/components/schemas/session.next.agent.switched" + "$ref": "#/components/schemas/session.agent.selected" }, { - "$ref": "#/components/schemas/session.next.model.switched" + "$ref": "#/components/schemas/session.model.selected" }, { - "$ref": "#/components/schemas/session.next.moved" + "$ref": "#/components/schemas/session.moved" }, { - "$ref": "#/components/schemas/session.next.renamed" + "$ref": "#/components/schemas/session.renamed" }, { - "$ref": "#/components/schemas/session.next.forked" + "$ref": "#/components/schemas/session.deleted" }, { - "$ref": "#/components/schemas/session.next.prompted" + "$ref": "#/components/schemas/session.forked" }, { - "$ref": "#/components/schemas/session.next.prompt.admitted" + "$ref": "#/components/schemas/session.prompt.promoted" }, { - "$ref": "#/components/schemas/session.next.instructions.updated" + "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.next.synthetic" + "$ref": "#/components/schemas/session.execution.started" }, { - "$ref": "#/components/schemas/session.next.skill.activated" + "$ref": "#/components/schemas/session.execution.succeeded" }, { - "$ref": "#/components/schemas/session.next.shell.started" + "$ref": "#/components/schemas/session.execution.failed" }, { - "$ref": "#/components/schemas/session.next.shell.ended" + "$ref": "#/components/schemas/session.execution.interrupted" }, { - "$ref": "#/components/schemas/session.next.step.started" + "$ref": "#/components/schemas/session.instructions.updated" }, { - "$ref": "#/components/schemas/session.next.step.ended" + "$ref": "#/components/schemas/session.synthetic" }, { - "$ref": "#/components/schemas/session.next.step.failed" + "$ref": "#/components/schemas/session.skill.activated" }, { - "$ref": "#/components/schemas/session.next.text.started" + "$ref": "#/components/schemas/session.shell.started" }, { - "$ref": "#/components/schemas/session.next.text.ended" + "$ref": "#/components/schemas/session.shell.ended" }, { - "$ref": "#/components/schemas/session.next.tool.input.started" + "$ref": "#/components/schemas/session.step.started" }, { - "$ref": "#/components/schemas/session.next.tool.input.ended" + "$ref": "#/components/schemas/session.step.ended" }, { - "$ref": "#/components/schemas/session.next.tool.called" + "$ref": "#/components/schemas/session.step.failed" }, { - "$ref": "#/components/schemas/session.next.tool.progress" + "$ref": "#/components/schemas/session.text.started" }, { - "$ref": "#/components/schemas/session.next.tool.success" + "$ref": "#/components/schemas/session.text.ended" }, { - "$ref": "#/components/schemas/session.next.tool.failed" + "$ref": "#/components/schemas/session.reasoning.started" }, { - "$ref": "#/components/schemas/session.next.reasoning.started" + "$ref": "#/components/schemas/session.reasoning.ended" }, { - "$ref": "#/components/schemas/session.next.reasoning.ended" + "$ref": "#/components/schemas/session.tool.input.started" }, { - "$ref": "#/components/schemas/session.next.retried" + "$ref": "#/components/schemas/session.tool.input.ended" }, { - "$ref": "#/components/schemas/session.next.compaction.started" + "$ref": "#/components/schemas/session.tool.called" }, { - "$ref": "#/components/schemas/session.next.compaction.ended" + "$ref": "#/components/schemas/session.tool.progress" }, { - "$ref": "#/components/schemas/session.next.revert.staged" + "$ref": "#/components/schemas/session.tool.success" }, { - "$ref": "#/components/schemas/session.next.revert.cleared" + "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.next.revert.committed" + "$ref": "#/components/schemas/session.retry.scheduled" + }, + { + "$ref": "#/components/schemas/session.compaction.admitted" + }, + { + "$ref": "#/components/schemas/session.compaction.started" + }, + { + "$ref": "#/components/schemas/session.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, + { + "$ref": "#/components/schemas/session.revert.staged" + }, + { + "$ref": "#/components/schemas/session.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.revert.committed" } ] }, @@ -15044,14 +16740,6 @@ "$ref": "#/components/schemas/Session.Message" } }, - "watermark": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "cursor": { "type": "object", "properties": { @@ -15085,65 +16773,6 @@ ], "additionalProperties": false }, - "Model.Api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "package" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "settings" - ], - "additionalProperties": false - } - ] - }, "Model.Capabilities": { "type": "object", "properties": { @@ -15170,6 +16799,30 @@ ], "additionalProperties": false }, + "Model.Variant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, "Model.Cost": { "type": "object", "properties": { @@ -15228,6 +16881,9 @@ "id": { "type": "string" }, + "modelID": { + "type": "string" + }, "providerID": { "type": "string" }, @@ -15237,66 +16893,28 @@ "name": { "type": "string" }, - "api": { - "$ref": "#/components/schemas/Model.Api" + "package": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" }, "capabilities": { "$ref": "#/components/schemas/Model.Capabilities" }, - "request": { - "type": "object", - "properties": { - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "settings", - "headers", - "body" - ], - "additionalProperties": false - }, "variants": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": [ - "id", - "settings", - "headers", - "body" - ], - "additionalProperties": false + "$ref": "#/components/schemas/Model.Variant" } }, "time": { @@ -15351,11 +16969,10 @@ }, "required": [ "id", + "modelID", "providerID", "name", - "api", "capabilities", - "request", "variants", "time", "cost", @@ -15386,63 +17003,6 @@ ], "additionalProperties": false }, - "Provider.AISDK": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "package" - ], - "additionalProperties": false - }, - "Provider.Native": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "settings" - ], - "additionalProperties": false - }, - "Provider.Api": { - "anyOf": [ - { - "$ref": "#/components/schemas/Provider.AISDK" - }, - { - "$ref": "#/components/schemas/Provider.Native" - } - ] - }, "ProviderV2.Info": { "type": "object", "properties": { @@ -15458,18 +17018,26 @@ "disabled": { "type": "boolean" }, - "api": { - "$ref": "#/components/schemas/Provider.Api" + "package": { + "type": "string" }, - "request": { - "$ref": "#/components/schemas/Provider.Request" + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" } }, "required": [ "id", "name", - "api", - "request" + "package" ], "additionalProperties": false }, @@ -16305,13 +17873,13 @@ ], "additionalProperties": false }, - "Mcp.Status.Disconnected": { + "Mcp.Status.Pending": { "type": "object", "properties": { "status": { "type": "string", "enum": [ - "disconnected" + "pending" ] } }, @@ -16400,7 +17968,7 @@ "$ref": "#/components/schemas/Mcp.Status.Connected" }, { - "$ref": "#/components/schemas/Mcp.Status.Disconnected" + "$ref": "#/components/schemas/Mcp.Status.Pending" }, { "$ref": "#/components/schemas/Mcp.Status.Disabled" @@ -16426,6 +17994,185 @@ ], "additionalProperties": false }, + "Mcp.Resource": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uri" + ], + "additionalProperties": false + }, + "Mcp.ResourceTemplate": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uriTemplate": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uriTemplate" + ], + "additionalProperties": false + }, + "Mcp.ResourceCatalog": { + "type": "object", + "properties": { + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.Resource" + } + }, + "templates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.ResourceTemplate" + } + } + }, + "required": [ + "resources", + "templates" + ], + "additionalProperties": false + }, + "Project.Vcs": { + "type": "string", + "enum": [ + "git", + "hg" + ] + }, + "Project.Icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false + }, + "Project.Commands": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" + } + }, + "additionalProperties": false + }, + "Project.Time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "updated": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "initialized": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "created", + "updated" + ], + "additionalProperties": false + }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "$ref": "#/components/schemas/Project.Vcs" + }, + "name": { + "type": "string" + }, + "icon": { + "$ref": "#/components/schemas/Project.Icon" + }, + "commands": { + "$ref": "#/components/schemas/Project.Commands" + }, + "time": { + "$ref": "#/components/schemas/Project.Time" + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "worktree", + "time", + "sandboxes" + ], + "additionalProperties": false + }, "Project.Current": { "type": "object", "properties": { @@ -17606,6 +19353,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17615,36 +19365,6 @@ "models-dev.refreshed" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17661,6 +19381,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17677,6 +19398,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17686,36 +19410,6 @@ "integration.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17732,6 +19426,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17748,6 +19443,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17757,36 +19455,6 @@ "integration.connection.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17805,6 +19473,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17821,6 +19490,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17830,36 +19502,6 @@ "catalog.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17876,6 +19518,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -17892,6 +19535,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -17901,36 +19547,6 @@ "agent.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -17947,6 +19563,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -18258,6 +19875,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -18324,7 +19944,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -18340,6 +19962,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -18406,12 +20031,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.deleted": { + "session.deleted1": { "type": "object", "properties": { "id": { @@ -18422,6 +20049,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -18488,7 +20118,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -19277,6 +20909,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -19343,7 +20978,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -19359,6 +20996,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -19430,7 +21070,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -20824,6 +22466,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -20894,7 +22539,9 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false @@ -20910,6 +22557,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -20990,12 +22640,14 @@ }, "required": [ "id", + "created", "type", + "durable", "data" ], "additionalProperties": false }, - "session.next.execution.settled": { + "session.usage.updated": { "type": "object", "properties": { "id": { @@ -21006,54 +22658,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.execution.settled" + "session.usage.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21062,34 +22684,64 @@ } ] }, - "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "interrupted" - ] + "cost": { + "type": "number" }, - "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false } }, "required": [ - "timestamp", "sessionID", - "outcome" + "cost", + "tokens" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.text.delta": { + "session.text.delta": { "type": "object", "properties": { "id": { @@ -21100,54 +22752,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.text.delta" + "session.text.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21164,18 +22786,22 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "textID", + "ordinal", "delta" ], "additionalProperties": false @@ -21183,12 +22809,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.reasoning.delta": { + "session.reasoning.delta": { "type": "object", "properties": { "id": { @@ -21199,54 +22826,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.reasoning.delta" + "session.reasoning.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21263,18 +22860,22 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", - "reasoningID", + "ordinal", "delta" ], "additionalProperties": false @@ -21282,12 +22883,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.tool.input.delta": { + "session.tool.input.delta": { "type": "object", "properties": { "id": { @@ -21298,54 +22900,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.tool.input.delta" + "session.tool.input.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21370,7 +22942,6 @@ } }, "required": [ - "timestamp", "sessionID", "assistantMessageID", "callID", @@ -21381,12 +22952,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "session.next.compaction.delta": { + "session.compaction.delta": { "type": "object", "properties": { "id": { @@ -21397,54 +22969,24 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "session.next.compaction.delta" + "session.compaction.delta" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, "data": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, "sessionID": { "type": "string", "allOf": [ @@ -21453,22 +22995,12 @@ } ] }, - "messageID": { - "type": "string", - "allOf": [ - { - "pattern": "^msg_" - } - ] - }, "text": { "type": "string" } }, "required": [ - "timestamp", "sessionID", - "messageID", "text" ], "additionalProperties": false @@ -21476,12 +23008,13 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "file.edited": { + "filesystem.changed": { "type": "object", "properties": { "id": { @@ -21492,45 +23025,18 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, "type": { "type": "string", "enum": [ - "file.edited" + "filesystem.changed" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21539,16 +23045,26 @@ "properties": { "file": { "type": "string" + }, + "event": { + "type": "string", + "enum": [ + "add", + "change", + "unlink" + ] } }, "required": [ - "file" + "file", + "event" ], "additionalProperties": false } }, "required": [ "id", + "created", "type", "data" ], @@ -21565,6 +23081,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21574,36 +23093,6 @@ "reference.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21620,6 +23109,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21636,6 +23126,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21645,36 +23138,6 @@ "permission.v2.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21730,6 +23193,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21746,6 +23210,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21755,36 +23222,6 @@ "permission.v2.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21821,6 +23258,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21837,6 +23275,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21846,36 +23287,6 @@ "plugin.added" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21894,6 +23305,52 @@ }, "required": [ "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "plugin.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "plugin.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", "type", "data" ], @@ -21910,6 +23367,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21919,36 +23379,6 @@ "project.directories.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -21967,6 +23397,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -21983,6 +23414,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -21992,36 +23426,6 @@ "command.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22038,6 +23442,52 @@ }, "required": [ "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "config.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "config.updated" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + } + }, + "required": [ + "id", + "created", "type", "data" ], @@ -22054,6 +23504,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22063,36 +23516,6 @@ "skill.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22109,88 +23532,7 @@ }, "required": [ "id", - "type", - "data" - ], - "additionalProperties": false - }, - "file.watcher.updated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^evt_" - } - ] - }, - "metadata": { - "type": "object" - }, - "type": { - "type": "string", - "enum": [ - "file.watcher.updated" - ] - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/Location.Ref" - }, - "data": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": [ - "add", - "change", - "unlink" - ] - } - }, - "required": [ - "file", - "event" - ], - "additionalProperties": false - } - }, - "required": [ - "id", + "created", "type", "data" ], @@ -22268,6 +23610,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22277,36 +23622,6 @@ "pty.created" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22325,6 +23640,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22341,6 +23657,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22350,36 +23669,6 @@ "pty.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22398,6 +23687,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22414,6 +23704,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22423,36 +23716,6 @@ "pty.exited" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22485,6 +23748,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22501,6 +23765,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22510,36 +23777,6 @@ "pty.deleted" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22563,151 +23800,12 @@ }, "required": [ "id", + "created", "type", "data" ], "additionalProperties": false }, - "Shell": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^sh_" - } - ] - }, - "status": { - "type": "string", - "enum": [ - "running", - "exited", - "timeout", - "killed" - ] - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string" - }, - "shell": { - "type": "string" - }, - "file": { - "type": "string" - }, - "pid": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "exit": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "started": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - "completed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - } - }, - "required": [ - "started" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "status", - "command", - "cwd", - "shell", - "file", - "metadata", - "time" - ], - "additionalProperties": false - }, "shell.created": { "type": "object", "properties": { @@ -22719,6 +23817,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22728,36 +23829,6 @@ "shell.created" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22765,7 +23836,7 @@ "type": "object", "properties": { "info": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -22776,6 +23847,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22792,6 +23864,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22801,36 +23876,6 @@ "shell.exited" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22889,6 +23934,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -22905,6 +23951,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -22914,36 +23963,6 @@ "shell.deleted" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -22967,6 +23986,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23049,6 +24069,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23058,36 +24081,6 @@ "question.v2.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23131,6 +24124,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23153,6 +24147,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23162,36 +24159,6 @@ "question.v2.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23231,6 +24198,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23247,6 +24215,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23256,36 +24227,6 @@ "question.v2.rejected" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23318,6 +24259,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -23886,6 +24828,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -23895,36 +24840,6 @@ "form.created" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -23950,6 +24865,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24013,6 +24929,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24022,36 +24941,6 @@ "form.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24083,6 +24972,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24099,6 +24989,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24108,36 +25001,6 @@ "form.cancelled" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24165,6 +25028,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24204,6 +25068,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24213,36 +25080,6 @@ "todo.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24273,6 +25110,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24391,6 +25229,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24400,36 +25241,6 @@ "session.status" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24457,6 +25268,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24473,6 +25285,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24482,36 +25297,6 @@ "session.idle" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24535,6 +25320,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24551,6 +25337,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24560,36 +25349,6 @@ "tui.prompt.append" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24608,6 +25367,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24624,6 +25384,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24633,36 +25396,6 @@ "tui.command.execute" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24707,6 +25440,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24723,6 +25457,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24732,36 +25469,6 @@ "tui.toast.show" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24808,6 +25515,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24824,6 +25532,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24833,36 +25544,6 @@ "tui.session.select" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24887,6 +25568,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24903,6 +25585,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24912,36 +25597,6 @@ "installation.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -24960,6 +25615,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -24976,6 +25632,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -24985,36 +25644,6 @@ "installation.update-available" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25033,6 +25662,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25049,6 +25679,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25058,36 +25691,6 @@ "vcs.branch.updated" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25103,6 +25706,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25119,6 +25723,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25128,36 +25735,6 @@ "mcp.status.changed" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25176,6 +25753,54 @@ }, "required": [ "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, + "mcp.resources.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "mcp.resources.changed" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", "type", "data" ], @@ -25192,6 +25817,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25201,36 +25829,6 @@ "permission.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25308,6 +25906,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25324,6 +25923,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25333,36 +25935,6 @@ "permission.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25404,6 +25976,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25507,6 +26080,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25516,36 +26092,6 @@ "question.asked" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25596,6 +26142,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25618,6 +26165,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25627,36 +26177,6 @@ "question.replied" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25696,6 +26216,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25712,6 +26233,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25721,36 +26245,6 @@ "question.rejected" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25783,6 +26277,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25799,6 +26294,9 @@ } ] }, + "created": { + "type": "number" + }, "metadata": { "type": "object" }, @@ -25808,36 +26306,6 @@ "session.error" ] }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, "location": { "$ref": "#/components/schemas/Location.Ref" }, @@ -25900,6 +26368,7 @@ }, "required": [ "id", + "created", "type", "data" ], @@ -25926,43 +26395,6 @@ } ] }, - "durable": { - "anyOf": [ - { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } - ] - } - }, - "required": [ - "aggregateID", - "seq", - "version" - ], - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, "location": { "anyOf": [ { @@ -26021,7 +26453,7 @@ "$ref": "#/components/schemas/session.updated" }, { - "$ref": "#/components/schemas/session.deleted" + "$ref": "#/components/schemas/session.deleted1" }, { "$ref": "#/components/schemas/message.updated" @@ -26036,115 +26468,136 @@ "$ref": "#/components/schemas/message.part.removed" }, { - "$ref": "#/components/schemas/session.next.agent.switched" + "$ref": "#/components/schemas/session.agent.selected" }, { - "$ref": "#/components/schemas/session.next.model.switched" + "$ref": "#/components/schemas/session.model.selected" }, { - "$ref": "#/components/schemas/session.next.moved" + "$ref": "#/components/schemas/session.moved" }, { - "$ref": "#/components/schemas/session.next.renamed" + "$ref": "#/components/schemas/session.renamed" }, { - "$ref": "#/components/schemas/session.next.forked" + "$ref": "#/components/schemas/session.usage.updated" }, { - "$ref": "#/components/schemas/session.next.prompted" + "$ref": "#/components/schemas/session.deleted" }, { - "$ref": "#/components/schemas/session.next.prompt.admitted" + "$ref": "#/components/schemas/session.forked" }, { - "$ref": "#/components/schemas/session.next.execution.settled" + "$ref": "#/components/schemas/session.prompt.promoted" }, { - "$ref": "#/components/schemas/session.next.instructions.updated" + "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.next.synthetic" + "$ref": "#/components/schemas/session.execution.started" }, { - "$ref": "#/components/schemas/session.next.skill.activated" + "$ref": "#/components/schemas/session.execution.succeeded" }, { - "$ref": "#/components/schemas/session.next.shell.started" + "$ref": "#/components/schemas/session.execution.failed" }, { - "$ref": "#/components/schemas/session.next.shell.ended" + "$ref": "#/components/schemas/session.execution.interrupted" }, { - "$ref": "#/components/schemas/session.next.step.started" + "$ref": "#/components/schemas/session.instructions.updated" }, { - "$ref": "#/components/schemas/session.next.step.ended" + "$ref": "#/components/schemas/session.synthetic" }, { - "$ref": "#/components/schemas/session.next.step.failed" + "$ref": "#/components/schemas/session.skill.activated" }, { - "$ref": "#/components/schemas/session.next.text.started" + "$ref": "#/components/schemas/session.shell.started" }, { - "$ref": "#/components/schemas/session.next.text.delta" + "$ref": "#/components/schemas/session.shell.ended" }, { - "$ref": "#/components/schemas/session.next.text.ended" + "$ref": "#/components/schemas/session.step.started" }, { - "$ref": "#/components/schemas/session.next.reasoning.started" + "$ref": "#/components/schemas/session.step.ended" }, { - "$ref": "#/components/schemas/session.next.reasoning.delta" + "$ref": "#/components/schemas/session.step.failed" }, { - "$ref": "#/components/schemas/session.next.reasoning.ended" + "$ref": "#/components/schemas/session.text.started" }, { - "$ref": "#/components/schemas/session.next.tool.input.started" + "$ref": "#/components/schemas/session.text.delta" }, { - "$ref": "#/components/schemas/session.next.tool.input.delta" + "$ref": "#/components/schemas/session.text.ended" }, { - "$ref": "#/components/schemas/session.next.tool.input.ended" + "$ref": "#/components/schemas/session.reasoning.started" }, { - "$ref": "#/components/schemas/session.next.tool.called" + "$ref": "#/components/schemas/session.reasoning.delta" }, { - "$ref": "#/components/schemas/session.next.tool.progress" + "$ref": "#/components/schemas/session.reasoning.ended" }, { - "$ref": "#/components/schemas/session.next.tool.success" + "$ref": "#/components/schemas/session.tool.input.started" }, { - "$ref": "#/components/schemas/session.next.tool.failed" + "$ref": "#/components/schemas/session.tool.input.delta" }, { - "$ref": "#/components/schemas/session.next.retried" + "$ref": "#/components/schemas/session.tool.input.ended" }, { - "$ref": "#/components/schemas/session.next.compaction.started" + "$ref": "#/components/schemas/session.tool.called" }, { - "$ref": "#/components/schemas/session.next.compaction.delta" + "$ref": "#/components/schemas/session.tool.progress" }, { - "$ref": "#/components/schemas/session.next.compaction.ended" + "$ref": "#/components/schemas/session.tool.success" }, { - "$ref": "#/components/schemas/session.next.revert.staged" + "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.next.revert.cleared" + "$ref": "#/components/schemas/session.retry.scheduled" }, { - "$ref": "#/components/schemas/session.next.revert.committed" + "$ref": "#/components/schemas/session.compaction.admitted" }, { - "$ref": "#/components/schemas/file.edited" + "$ref": "#/components/schemas/session.compaction.started" + }, + { + "$ref": "#/components/schemas/session.compaction.delta" + }, + { + "$ref": "#/components/schemas/session.compaction.ended" + }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, + { + "$ref": "#/components/schemas/session.revert.staged" + }, + { + "$ref": "#/components/schemas/session.revert.cleared" + }, + { + "$ref": "#/components/schemas/session.revert.committed" + }, + { + "$ref": "#/components/schemas/filesystem.changed" }, { "$ref": "#/components/schemas/reference.updated" @@ -26158,6 +26611,9 @@ { "$ref": "#/components/schemas/plugin.added" }, + { + "$ref": "#/components/schemas/plugin.updated" + }, { "$ref": "#/components/schemas/project.directories.updated" }, @@ -26165,10 +26621,10 @@ "$ref": "#/components/schemas/command.updated" }, { - "$ref": "#/components/schemas/skill.updated" + "$ref": "#/components/schemas/config.updated" }, { - "$ref": "#/components/schemas/file.watcher.updated" + "$ref": "#/components/schemas/skill.updated" }, { "$ref": "#/components/schemas/pty.created" @@ -26242,6 +26698,9 @@ { "$ref": "#/components/schemas/mcp.status.changed" }, + { + "$ref": "#/components/schemas/mcp.resources.changed" + }, { "$ref": "#/components/schemas/permission.asked" }, @@ -26272,68 +26731,6 @@ }, "contentMediaType": "application/json" }, - "EventLog.Hint": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.hint" - ] - }, - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "type", - "aggregateID", - "seq" - ], - "additionalProperties": false, - "description": "Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee." - }, - "EventLog.SweepRequired": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.sweep_required" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false, - "description": "Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe." - }, - "EventLog.Change": { - "anyOf": [ - { - "$ref": "#/components/schemas/EventLog.Hint" - }, - { - "$ref": "#/components/schemas/EventLog.SweepRequired" - } - ] - }, - "EventLog.ChangeStream": { - "type": "string", - "contentSchema": { - "$ref": "#/components/schemas/EventLog.Change" - }, - "contentMediaType": "application/json" - }, "PtyNotFoundError": { "type": "object", "properties": { @@ -26397,182 +26794,6 @@ ], "additionalProperties": false }, - "Shell1": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^sh_" - } - ] - }, - "status": { - "type": "string", - "enum": [ - "running", - "exited", - "timeout", - "killed" - ] - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string" - }, - "shell": { - "type": "string" - }, - "file": { - "type": "string" - }, - "pid": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "exit": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "started": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "completed": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - } - }, - "required": [ - "started" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "status", - "command", - "cwd", - "shell", - "file", - "metadata", - "time" - ], - "additionalProperties": false - }, "ShellNotFoundError": { "type": "object", "properties": { @@ -26863,28 +27084,28 @@ "security": [], "tags": [ { - "name": "server.health" + "name": "health" }, { - "name": "server.location" + "name": "location" }, { - "name": "server.agent" + "name": "agent" }, { - "name": "plugins", + "name": "plugin", "description": "Experimental plugin routes." }, { - "name": "sessions", + "name": "session", "description": "Experimental session routes." }, { - "name": "messages", + "name": "session", "description": "Experimental message routes." }, { - "name": "models", + "name": "model", "description": "Experimental model routes." }, { @@ -26892,30 +27113,30 @@ "description": "Experimental one-shot generation routes." }, { - "name": "providers", + "name": "provider", "description": "Experimental provider routes." }, { - "name": "integrations", + "name": "integration", "description": "Integration discovery and authentication routes." }, { "name": "mcp", - "description": "MCP server status routes." + "description": "MCP server and resource routes." }, { - "name": "server.credential" + "name": "credential" }, { - "name": "projects", + "name": "project", "description": "Location-scoped project routes." }, { - "name": "forms", + "name": "form", "description": "Session form routes." }, { - "name": "permissions", + "name": "permission", "description": "Experimental permission routes." }, { @@ -26923,15 +27144,15 @@ "description": "Experimental location-scoped filesystem routes." }, { - "name": "commands", + "name": "command", "description": "Experimental command routes." }, { - "name": "skills", + "name": "skill", "description": "Experimental skill routes." }, { - "name": "events", + "name": "event", "description": "Experimental event stream routes." }, { @@ -26943,7 +27164,7 @@ "description": "Experimental location-scoped shell command routes." }, { - "name": "session questions", + "name": "question", "description": "Experimental session question routes." }, { @@ -26957,6 +27178,9 @@ { "name": "vcs", "description": "Location-scoped version control routes." + }, + { + "name": "debug" } ] } diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index db799e5894..608019da46 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -177,13 +177,13 @@ describe("OpenAPI.fromSpec", () => { const spec = await opencodeSpec() const result = OpenAPI.fromSpec({ spec, baseUrl }) - expect(result.skipped).toHaveLength(5) + expect(result.skipped).toHaveLength(4) expect(result.skipped).toContainEqual({ method: "GET", path: "/api/pty/{ptyID}/connect", reason: "WebSocket operations are not supported", }) - expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3) + expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2) expect(result.skipped).toContainEqual({ method: "GET", path: "/api/fs/read/*", @@ -210,11 +210,11 @@ describe("OpenAPI.fromSpec", () => { if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated") expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }") expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined() - expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined() + expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false) expect(toolAt(result.tools, "v2.session.log")).toBeUndefined() expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined() expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined() - expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined() + expect(toolAt(result.tools, "v2.pty.connect.token")).not.toBeUndefined() }) test("preserves operation path sanitization and collision handling", () => { diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 907920c10e..4e0858fd16 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,6 +1,6 @@ export * as PluginV2 from "./plugin" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event, ID, type Info } from "@opencode-ai/schema/plugin" import { makeLocationNode } from "./effect/app-node" import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect" @@ -18,6 +18,7 @@ import { SkillV2 } from "./skill" import { State } from "./state" import { ToolRegistry } from "./tool/registry" import { ToolHooks } from "./tool/hooks" +import { PluginHooks } from "./plugin/hooks" export interface Interface { readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect @@ -57,12 +58,13 @@ const layer = Layer.effect( generation.length === definitions.length && generation.every( (plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version, - ) + ) && + definitions.every((definition) => active.has(definition.id)) ) { return } generation = undefined - const exit = yield* State.batch( + yield* State.batch( Effect.gen(function* () { const scopes = Array.from(active.values()).toReversed() active.clear() @@ -81,13 +83,17 @@ const layer = Layer.effect( Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)), Effect.exit, ) - if (Exit.isFailure(loaded)) return loaded + if (Exit.isFailure(loaded)) { + yield* Effect.logWarning("failed to load plugin", { + "plugin.id": definition.id, + cause: loaded.cause, + }) + continue + } active.set(definition.id, child) } - return Exit.void }), ) - if (Exit.isFailure(exit)) return yield* exit generation = definitions.map((definition) => ({ id: definition.id, ...(definition.version === undefined ? {} : { version: definition.version }), @@ -131,6 +137,7 @@ export const node = makeLocationNode({ SkillV2.node, ToolRegistry.toolsNode, ToolHooks.node, + PluginHooks.node, PluginRuntime.node, ], }) diff --git a/packages/core/src/plugin/hooks.ts b/packages/core/src/plugin/hooks.ts new file mode 100644 index 0000000000..4ac437d392 --- /dev/null +++ b/packages/core/src/plugin/hooks.ts @@ -0,0 +1,67 @@ +export * as PluginHooks from "./hooks" + +import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" +import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool" +import { Context, Effect, Layer, Scope } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { State } from "../state" + +export interface Domains { + readonly aisdk: AISDKHooks + readonly session: SessionHooks + readonly tool: ToolHooks +} + +type Callback = (event: Event) => Effect.Effect + +export interface Interface { + readonly register: ( + domain: Domain, + name: Name, + callback: Callback, + ) => Effect.Effect + readonly trigger: ( + domain: Domain, + name: Name, + event: Domains[Domain][Name], + ) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/PluginHooks") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const callbacks = new Map() + const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}` + + const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) { + const scope = yield* Scope.Scope + const id = key(domain, name) + let active = true + callbacks.set(id, [...(callbacks.get(id) ?? []), callback]) + const dispose = Effect.sync(() => { + if (!active) return + active = false + const next = (callbacks.get(id) ?? []).filter((item) => item !== callback) + if (next.length === 0) callbacks.delete(id) + else callbacks.set(id, next) + }) + yield* Scope.addFinalizer(scope, dispose) + return { dispose } + }) + + const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) { + for (const callback of callbacks.get(key(domain, name)) ?? []) { + const result: Effect.Effect = Reflect.apply(callback, undefined, [event]) + yield* result + } + return event + }) + + return Service.of({ register, trigger }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 01ea0da0d3..a109dd02e7 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -1,6 +1,6 @@ export * as PluginHost from "./host" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect" import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Effect, Schema, Stream } from "effect" import { AgentV2 } from "../agent" @@ -22,6 +22,7 @@ import { Tool } from "../tool/tool" import { Tools } from "../tool/tools" import { ToolHooks } from "../tool/hooks" import { WorkspaceV2 } from "../workspace" +import { PluginHooks } from "./hooks" const mutable = (value: T) => value as DeepMutable export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) { @@ -36,6 +37,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int const skill = yield* SkillV2.Service const tools = yield* Tools.Service const toolHooks = yield* ToolHooks.Service + const hooks = yield* PluginHooks.Service const runtime = yield* PluginRuntime.Service const locationInfo = () => new Location.Info({ @@ -43,7 +45,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int workspaceID: location.workspaceID, project: location.project, }) - const locationRef = (input?: Parameters[0]) => + const locationRef = (input?: Parameters[0]) => input?.location === undefined ? undefined : Location.Ref.make({ @@ -79,32 +81,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), }, aisdk: { - sdk: (callback) => - aisdk.hook.sdk((event) => { + hook: (name, callback) => { + if (name === "sdk") { + return aisdk.hook.sdk((event) => { + const output = { + model: mutable(event.model), + package: event.package, + options: event.options, + sdk: event.sdk, + } + return Reflect.apply(callback, undefined, [output]).pipe( + Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), + ) + }) + } + return aisdk.hook.language((event) => { const output = { model: mutable(event.model), - package: event.package, options: event.options, sdk: event.sdk, - } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))), - ) - }), - language: (callback) => - aisdk.hook.language((event) => { - const output = { - model: mutable(event.model), - sdk: event.sdk, - options: event.options, language: event.language, } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + return Reflect.apply(callback, undefined, [output]).pipe( Effect.tap(() => Effect.sync(() => (event.language = output.language))), ) - }), + }) + }, }, catalog: { provider: { @@ -164,25 +166,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int integration: { list: () => response(integration.list()), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), - connectKey: (input) => - integration.connection.key({ - integrationID: Integration.ID.make(input.integrationID), - key: input.key, - label: input.label, - }), - connectOauth: (input) => - response( - integration.connection.oauth({ + connect: { + key: (input) => + integration.connection.key({ integrationID: Integration.ID.make(input.integrationID), - methodID: Integration.MethodID.make(input.methodID), - inputs: input.inputs, + key: input.key, label: input.label, }), - ), - attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), - attemptComplete: (input) => - integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), - attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), + oauth: (input) => + response( + integration.connection.oauth({ + integrationID: Integration.ID.make(input.integrationID), + methodID: Integration.MethodID.make(input.methodID), + inputs: input.inputs, + label: input.label, + }), + ), + }, + attempt: { + status: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))), + complete: (input) => + integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }), + cancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)), + }, reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), @@ -317,11 +323,12 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int registrations, (registration) => tools.register({ [registration.name]: registration.tool }, registration.options), { discard: true }, - ) + ).pipe(Effect.orDie) + return { dispose: Effect.void } }), - execute: { - before: (callback) => - toolHooks.hook.before((event) => { + hook: (name, callback) => { + if (name === "execute.before") { + return toolHooks.hook.before((event) => { const output = { tool: event.tool, sessionID: event.sessionID, @@ -330,38 +337,37 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int toolCallID: event.toolCallID, input: event.input, } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( + return Reflect.apply(callback, undefined, [output]).pipe( Effect.tap(() => Effect.sync(() => (event.input = output.input))), ) - }), - after: (callback) => - toolHooks.hook.after((event) => { - const output = { - tool: event.tool, - sessionID: event.sessionID, - agent: event.agent, - assistantMessageID: event.assistantMessageID, - toolCallID: event.toolCallID, - input: event.input, - result: event.result, - output: event.output, - outputPaths: event.outputPaths, - } - const result = callback(output) - return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe( - Effect.tap(() => - Effect.sync(() => { - event.result = output.result - event.output = output.output - event.outputPaths = output.outputPaths - }), - ), - ) - }), + }) + } + return toolHooks.hook.after((event) => { + const output = { + tool: event.tool, + sessionID: event.sessionID, + agent: event.agent, + assistantMessageID: event.assistantMessageID, + toolCallID: event.toolCallID, + input: event.input, + result: event.result, + output: event.output, + outputPaths: event.outputPaths, + } + return Reflect.apply(callback, undefined, [output]).pipe( + Effect.tap(() => + Effect.sync(() => { + event.result = output.result + event.output = output.output + event.outputPaths = output.outputPaths + }), + ), + ) + }) }, }, session: { + hook: (name, callback) => hooks.register("session", name, callback), create: (input) => runtime.session.create({ id: input?.id, @@ -375,5 +381,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int command: runtime.session.command, interrupt: (input) => runtime.session.interrupt(input.sessionID), }, - } satisfies PluginContext + } satisfies Plugin.Context }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 2789cc3c2e..40715378e1 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,6 +1,6 @@ export * as PluginInternal from "./internal" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Context, Effect, Scope } from "effect" import { HttpClient } from "effect/unstable/http" import { AgentV2 } from "../agent" diff --git a/packages/core/src/plugin/promise.ts b/packages/core/src/plugin/promise.ts index bcd3db1323..8eafe52734 100644 --- a/packages/core/src/plugin/promise.ts +++ b/packages/core/src/plugin/promise.ts @@ -1,11 +1,12 @@ export * as PluginPromise from "./promise" -import { define } from "@opencode-ai/plugin/v2/effect" -import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect, Scope, Stream } from "effect" type HostRegistration = { readonly dispose: Effect.Effect } type Registration = { readonly dispose: () => Promise } +type PromisePlugin = import("@opencode-ai/plugin/v2/plugin").Plugin +type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context /** * Adapts a Promise plugin into an Effect plugin so the existing Effect-only @@ -16,8 +17,8 @@ type Registration = { readonly dispose: () => Promise } * preserves boot-time batching, so Promise-plugin transforms still coalesce * into one reload per domain. */ -export function fromPromise(plugin: Plugin) { - return define({ +export function fromPromise(plugin: PromisePlugin) { + return Plugin.define({ id: plugin.id, effect: (host) => Effect.gen(function* () { @@ -43,7 +44,7 @@ export function fromPromise(plugin: Plugin) { }), ) - const context2: PluginContext = { + const context2: PromisePluginContext = { options: host.options, agent: { list: (input) => run(host.agent.list(input)), @@ -51,10 +52,8 @@ export function fromPromise(plugin: Plugin) { reload: () => run(host.agent.reload()), }, aisdk: { - sdk: (callback) => - register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))), - language: (callback) => - register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))), + hook: (name, callback) => + register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, catalog: { provider: { @@ -79,11 +78,15 @@ export function fromPromise(plugin: Plugin) { integration: { list: (input) => run(host.integration.list(input)), get: (input) => run(host.integration.get(input)), - connectKey: (input) => run(host.integration.connectKey(input)), - connectOauth: (input) => run(host.integration.connectOauth(input)), - attemptStatus: (input) => run(host.integration.attemptStatus(input)), - attemptComplete: (input) => run(host.integration.attemptComplete(input)), - attemptCancel: (input) => run(host.integration.attemptCancel(input)), + connect: { + key: (input) => run(host.integration.connect.key(input)), + oauth: (input) => run(host.integration.connect.oauth(input)), + }, + attempt: { + status: (input) => run(host.integration.attempt.status(input)), + complete: (input) => run(host.integration.attempt.complete(input)), + cancel: (input) => run(host.integration.attempt.cancel(input)), + }, transform: transform(host.integration), reload: () => run(host.integration.reload()), connection: { @@ -104,12 +107,19 @@ export function fromPromise(plugin: Plugin) { transform: transform(host.skill), reload: () => run(host.skill.reload()), }, + tool: { + transform: transform(host.tool), + hook: (name, callback) => + register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), + }, session: { create: (input) => run(host.session.create(input)), get: (input) => run(host.session.get(input)), prompt: (input) => run(host.session.prompt(input)), command: (input) => run(host.session.command(input)), interrupt: (input) => run(host.session.interrupt(input)), + hook: (name, callback) => + register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))), }, } diff --git a/packages/core/src/plugin/provider/alibaba.ts b/packages/core/src/plugin/provider/alibaba.ts index 607e565d4a..ea37b00453 100644 --- a/packages/core/src/plugin/provider/alibaba.ts +++ b/packages/core/src/plugin/provider/alibaba.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const AlibabaPlugin = define({ id: "opencode.provider.alibaba", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/alibaba") return const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba")) diff --git a/packages/core/src/plugin/provider/amazon-bedrock.ts b/packages/core/src/plugin/provider/amazon-bedrock.ts index 5bbe953f80..ecc137bbde 100644 --- a/packages/core/src/plugin/provider/amazon-bedrock.ts +++ b/packages/core/src/plugin/provider/amazon-bedrock.ts @@ -75,7 +75,8 @@ export const AmazonBedrockPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return const options = { ...evt.options } @@ -108,7 +109,8 @@ export const AmazonBedrockPlugin = define({ evt.sdk = mod.createAmazonBedrock(options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return if ( diff --git a/packages/core/src/plugin/provider/anthropic.ts b/packages/core/src/plugin/provider/anthropic.ts index 4c5938e51f..bbf0d56dfb 100644 --- a/packages/core/src/plugin/provider/anthropic.ts +++ b/packages/core/src/plugin/provider/anthropic.ts @@ -17,7 +17,8 @@ export const AnthropicPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic")) diff --git a/packages/core/src/plugin/provider/azure.ts b/packages/core/src/plugin/provider/azure.ts index 65a40c023b..f50bbdb1ba 100644 --- a/packages/core/src/plugin/provider/azure.ts +++ b/packages/core/src/plugin/provider/azure.ts @@ -26,7 +26,8 @@ export const AzurePlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/azure") return if (evt.model.providerID === ProviderV2.ID.azure) { @@ -44,7 +45,8 @@ export const AzurePlugin = define({ evt.sdk = mod.createAzure(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.azure) return evt.language = selectLanguage( @@ -75,7 +77,8 @@ export const AzureCognitiveServicesPlugin = define({ }) } }) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return evt.language = selectLanguage( diff --git a/packages/core/src/plugin/provider/cerebras.ts b/packages/core/src/plugin/provider/cerebras.ts index 42fea46550..13614d6be0 100644 --- a/packages/core/src/plugin/provider/cerebras.ts +++ b/packages/core/src/plugin/provider/cerebras.ts @@ -14,7 +14,8 @@ export const CerebrasPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cerebras") return const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras")) diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index afab0c36df..0602549614 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -6,7 +6,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CloudflareAIGatewayPlugin = define({ id: "opencode.provider.cloudflare-ai-gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "ai-gateway-provider") return if (evt.options.baseURL) return diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index 9b4558b5d1..c3aa50fbb4 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -19,7 +19,8 @@ export const CloudflareWorkersAIPlugin = define({ if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) } }) }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return @@ -35,7 +36,8 @@ export const CloudflareWorkersAIPlugin = define({ ) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/provider/cohere.ts b/packages/core/src/plugin/provider/cohere.ts index 8b0831604a..9284defcb1 100644 --- a/packages/core/src/plugin/provider/cohere.ts +++ b/packages/core/src/plugin/provider/cohere.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const CoherePlugin = define({ id: "opencode.provider.cohere", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/cohere") return const mod = yield* Effect.promise(() => import("@ai-sdk/cohere")) diff --git a/packages/core/src/plugin/provider/deepinfra.ts b/packages/core/src/plugin/provider/deepinfra.ts index e8316012ad..f4ddf97859 100644 --- a/packages/core/src/plugin/provider/deepinfra.ts +++ b/packages/core/src/plugin/provider/deepinfra.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const DeepInfraPlugin = define({ id: "opencode.provider.deepinfra", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/deepinfra") return const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra")) diff --git a/packages/core/src/plugin/provider/dynamic.ts b/packages/core/src/plugin/provider/dynamic.ts index 2e51674ba0..ae20d0d020 100644 --- a/packages/core/src/plugin/provider/dynamic.ts +++ b/packages/core/src/plugin/provider/dynamic.ts @@ -7,7 +7,8 @@ export const DynamicProviderPlugin = define({ id: "opencode.provider.dynamic", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.sdk) return diff --git a/packages/core/src/plugin/provider/gateway.ts b/packages/core/src/plugin/provider/gateway.ts index 07249391a0..b67c0ef179 100644 --- a/packages/core/src/plugin/provider/gateway.ts +++ b/packages/core/src/plugin/provider/gateway.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GatewayPlugin = define({ id: "opencode.provider.gateway", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/gateway") return const mod = yield* Effect.promise(() => import("@ai-sdk/gateway")) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index ab9364260c..139e6efd2d 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/packages/core/src/plugin/provider/github-copilot.ts @@ -23,14 +23,16 @@ export const GithubCopilotPlugin = define({ model.enabled = false }) }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/github-copilot") return const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider")) evt.sdk = mod.createOpenaiCompatible(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) { diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index 1a8a4a1231..a413c85d7d 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -7,7 +7,8 @@ import { ProviderV2 } from "../../provider" export const GitLabPlugin = define({ id: "opencode.provider.gitlab", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "gitlab-ai-provider") return const mod = yield* Effect.promise(() => import("gitlab-ai-provider")) @@ -31,7 +32,8 @@ export const GitLabPlugin = define({ }) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.gitlab) return const featureFlags = diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index 2c307a8725..ffc29f4bf3 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -85,7 +85,8 @@ export const GoogleVertexPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) { evt.options.fetch = authFetch(evt.options.fetch) @@ -104,7 +105,8 @@ export const GoogleVertexPlugin = define({ }) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.googleVertex) return evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) @@ -135,7 +137,8 @@ export const GoogleVertexAnthropicPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google-vertex/anthropic") return const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic")) @@ -161,7 +164,8 @@ export const GoogleVertexAnthropicPlugin = define({ }) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim()) diff --git a/packages/core/src/plugin/provider/google.ts b/packages/core/src/plugin/provider/google.ts index 3d2013a523..c62d962eb5 100644 --- a/packages/core/src/plugin/provider/google.ts +++ b/packages/core/src/plugin/provider/google.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GooglePlugin = define({ id: "opencode.provider.google", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/google") return const mod = yield* Effect.promise(() => import("@ai-sdk/google")) diff --git a/packages/core/src/plugin/provider/groq.ts b/packages/core/src/plugin/provider/groq.ts index d84ece2151..51b8e4c42f 100644 --- a/packages/core/src/plugin/provider/groq.ts +++ b/packages/core/src/plugin/provider/groq.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const GroqPlugin = define({ id: "opencode.provider.groq", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/groq") return const mod = yield* Effect.promise(() => import("@ai-sdk/groq")) diff --git a/packages/core/src/plugin/provider/mistral.ts b/packages/core/src/plugin/provider/mistral.ts index 92bc09abec..f5de664432 100644 --- a/packages/core/src/plugin/provider/mistral.ts +++ b/packages/core/src/plugin/provider/mistral.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const MistralPlugin = define({ id: "opencode.provider.mistral", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/mistral") return const mod = yield* Effect.promise(() => import("@ai-sdk/mistral")) diff --git a/packages/core/src/plugin/provider/openai-compatible.ts b/packages/core/src/plugin/provider/openai-compatible.ts index 3854bdcde2..76646b4e45 100644 --- a/packages/core/src/plugin/provider/openai-compatible.ts +++ b/packages/core/src/plugin/provider/openai-compatible.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const OpenAICompatiblePlugin = define({ id: "opencode.provider.openai-compatible", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.sdk) return if (!evt.package.includes("@ai-sdk/openai-compatible")) return diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 72fe8ed646..5e4a1b08ef 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -210,14 +210,16 @@ export const OpenAIPlugin = define({ Effect.forkScoped({ startImmediately: true }), ) yield* refresh().pipe(Effect.forkScoped) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return const mod = yield* Effect.promise(() => import("@ai-sdk/openai")) evt.sdk = mod.createOpenAI(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.openai) return evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/provider/openrouter.ts b/packages/core/src/plugin/provider/openrouter.ts index c27cc30207..f00386a94a 100644 --- a/packages/core/src/plugin/provider/openrouter.ts +++ b/packages/core/src/plugin/provider/openrouter.ts @@ -23,7 +23,8 @@ export const OpenRouterPlugin = define({ } } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@openrouter/ai-sdk-provider") return const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/perplexity.ts b/packages/core/src/plugin/provider/perplexity.ts index 9eb5b1e246..36cafdb2d1 100644 --- a/packages/core/src/plugin/provider/perplexity.ts +++ b/packages/core/src/plugin/provider/perplexity.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const PerplexityPlugin = define({ id: "opencode.provider.perplexity", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/perplexity") return const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity")) diff --git a/packages/core/src/plugin/provider/sap-ai-core.ts b/packages/core/src/plugin/provider/sap-ai-core.ts index 4a8c26e82a..559493787d 100644 --- a/packages/core/src/plugin/provider/sap-ai-core.ts +++ b/packages/core/src/plugin/provider/sap-ai-core.ts @@ -8,7 +8,8 @@ export const SapAICorePlugin = define({ id: "opencode.provider.sap-ai-core", effect: Effect.fn(function* (ctx) { const npm = yield* Npm.Service - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return const serviceKey = @@ -37,7 +38,8 @@ export const SapAICorePlugin = define({ ) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return evt.language = evt.sdk(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 2e6cc9f9b4..45c5f095ee 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -67,7 +67,8 @@ export function cortexFetch(upstream: FetchLike = fetch) { export const SnowflakeCortexPlugin = define({ id: "opencode.provider.snowflake-cortex", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return const token = diff --git a/packages/core/src/plugin/provider/togetherai.ts b/packages/core/src/plugin/provider/togetherai.ts index d9454cfd24..ea43db9e4a 100644 --- a/packages/core/src/plugin/provider/togetherai.ts +++ b/packages/core/src/plugin/provider/togetherai.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const TogetherAIPlugin = define({ id: "opencode.provider.togetherai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/togetherai") return const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai")) diff --git a/packages/core/src/plugin/provider/venice.ts b/packages/core/src/plugin/provider/venice.ts index c9d5b163ae..9930ce831d 100644 --- a/packages/core/src/plugin/provider/venice.ts +++ b/packages/core/src/plugin/provider/venice.ts @@ -4,7 +4,8 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" export const VenicePlugin = define({ id: "opencode.provider.venice", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "venice-ai-sdk-provider") return const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider")) diff --git a/packages/core/src/plugin/provider/vercel.ts b/packages/core/src/plugin/provider/vercel.ts index 70a27c8f67..fc392fe5e1 100644 --- a/packages/core/src/plugin/provider/vercel.ts +++ b/packages/core/src/plugin/provider/vercel.ts @@ -14,7 +14,8 @@ export const VercelPlugin = define({ }) } }) - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/vercel") return const mod = yield* Effect.promise(() => import("@ai-sdk/vercel")) diff --git a/packages/core/src/plugin/provider/xai.ts b/packages/core/src/plugin/provider/xai.ts index fb1d33e86e..724a3d2567 100644 --- a/packages/core/src/plugin/provider/xai.ts +++ b/packages/core/src/plugin/provider/xai.ts @@ -5,14 +5,16 @@ import { ProviderV2 } from "../../provider" export const XAIPlugin = define({ id: "opencode.provider.xai", effect: Effect.fn(function* (ctx) { - yield* ctx.aisdk.sdk( + yield* ctx.aisdk.hook( + "sdk", Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) evt.sdk = mod.createXai(evt.options) }), ) - yield* ctx.aisdk.language( + yield* ctx.aisdk.hook( + "language", Effect.fn(function* (evt) { if (evt.model.providerID !== ProviderV2.ID.make("xai")) return evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id) diff --git a/packages/core/src/plugin/sdk.ts b/packages/core/src/plugin/sdk.ts index da14b4a61f..3dd42a8037 100644 --- a/packages/core/src/plugin/sdk.ts +++ b/packages/core/src/plugin/sdk.ts @@ -1,6 +1,6 @@ export * as SdkPlugins from "./sdk" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Context, Effect, Layer } from "effect" import { makeGlobalNode } from "../effect/app-node" import { EventV2 } from "../event" diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 4c4e71be17..6bd4f6bfac 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -1,6 +1,6 @@ export * as PluginSupervisor from "./supervisor" -import type { Plugin } from "@opencode-ai/plugin/v2/effect" +import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin" import { Event } from "@opencode-ai/schema/config" import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect" import path from "path" diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 2d5220310c..23cbfa049d 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -50,6 +50,8 @@ import { llmClient } from "../../effect/app-node-platform" import { StepFailedError, UserInterruptedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" +import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session" +import { PluginHooks } from "../../plugin/hooks" type StepTokens = { readonly input: number @@ -132,6 +134,7 @@ const layer = Layer.effect( const llm = yield* LLMClient.Service const agents = yield* AgentV2.Service const tools = yield* ToolRegistry.Service + const hooks = yield* PluginHooks.Service const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service const location = yield* Location.Service @@ -252,10 +255,34 @@ const layer = Layer.effect( tools: toolMaterialization?.definitions ?? [], toolChoice: isLastStep ? "none" : undefined, }) + const availableTools = new Map(request.tools.map((tool) => [tool.name, tool])) + const requestEvent: SessionHooks["request"] = { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + system: [...request.system], + messages: [...request.messages], + tools: Object.fromEntries( + request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]), + ), + } + // Plugins may reshape the draft, but cannot advertise tools excluded earlier + // by permissions or registration state. + yield* hooks.trigger("session", "request", requestEvent) + const hookedRequest = LLM.updateRequest(request, { + system: requestEvent.system, + messages: requestEvent.messages, + tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => { + const registered = availableTools.get(name) + if (!registered) return [] + return [{ ...registered, description: tool.description, inputSchema: tool.input }] + }), + }) + const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name)) // Automatic compaction completed; rebuild the request from compacted history. if ( !(yield* SessionInput.pendingCompaction(db, session.id)) && - (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request })) + (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest })) ) return { _tag: "RestartAfterCompaction", step: currentStep } as const const startSnapshot = yield* snapshots.capture() @@ -276,7 +303,7 @@ const layer = Layer.effect( const publish = (event: LLMEvent, outputPaths: ReadonlyArray = [], error?: SessionError.Error) => serialized(publisher.publish(event, outputPaths, error)) let overflowFailure: ProviderErrorEvent | undefined - const providerStream = llm.stream(request).pipe( + const providerStream = llm.stream(hookedRequest).pipe( Stream.runForEach((event) => Effect.gen(function* () { if (overflowFailure || publisher.hasProviderError()) return @@ -288,11 +315,11 @@ const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return - if (!toolMaterialization) { + if (!toolMaterialization || !advertisedTools.has(event.name)) { yield* serialized( publisher.failUnsettledTools({ type: "tool.execution", - message: "Tools are disabled after the maximum agent steps", + message: `Tool is not available for this request: ${event.name}`, }), ) return @@ -625,6 +652,7 @@ export const node = makeLocationNode({ llmClient, AgentV2.node, ToolRegistry.node, + PluginHooks.node, SessionRunnerModel.node, SessionStore.node, Location.node, diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index e359787f2c..a1ae7dc808 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -1,6 +1,6 @@ export * as ApplyPatchTool from "./apply-patch" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" @@ -194,6 +194,19 @@ export const Plugin = { ), ) .pipe(Effect.orDie) + + yield* ctx.session.hook("request", (event) => + Effect.sync(() => { + const usePatch = + event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt") + if (usePatch) { + delete event.tools.edit + delete event.tools.write + return + } + delete event.tools.patch + }), + ) }), } diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 1128243b70..927cd3098d 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -6,7 +6,7 @@ */ export * as EditTool from "./edit" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index 4c0938c3d2..b047101c39 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -1,7 +1,7 @@ export * as GlobTool from "./glob" import { ToolFailure } from "@opencode-ai/llm" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema } from "effect" import path from "path" import { FileSystem } from "../filesystem" diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index 64f2a3a9d6..937963f4ca 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -1,6 +1,6 @@ export * as GrepTool from "./grep" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import path from "path" diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index a2ac9828c6..d5217f8505 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -1,6 +1,6 @@ export * as QuestionTool from "./question" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { Form } from "../form" diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index f6a7c474b5..001a29ba55 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -1,6 +1,6 @@ export * as ReadTool from "./read" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { dirname } from "path" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index ae5cd3d352..cef6f3c071 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -191,12 +191,8 @@ const registryLayer = Layer.effect( const registration = entries.at(-1)?.registration if (registration) registrations.set(name, registration) } - // OpenAI/GPT models use patch; every other model uses edit and write. - const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt") for (const [name, registration] of registrations) { - const wrongEditTool = name === "patch" ? !usePatch : (name === "edit" || name === "write") && usePatch if ( - wrongEditTool || (registration.deferred && !Flag.CODEMODE_ENABLED) || whollyDisabled(permission(registration.tool, name), input.permissions ?? []) ) diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index ca6b9f1969..d2b8bb4744 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -2,7 +2,7 @@ export * as ShellTool from "./shell" import path from "path" import { ToolFailure } from "@opencode-ai/llm" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 95c68cb257..8e9133b029 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -1,6 +1,6 @@ export * as SkillTool from "./skill" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import path from "path" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 0f781c5e6c..e6c994c216 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -1,10 +1,11 @@ export * as SubagentTool from "./subagent" import { ToolFailure } from "@opencode-ai/llm" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, Schema, Scope } from "effect" import { AgentV2 } from "../agent" import { PluginRuntime } from "../plugin/runtime" +import { PermissionV2 } from "../permission" import { SessionSchema } from "../session/schema" import { Tool } from "./tool" @@ -42,6 +43,7 @@ export const Plugin = { effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) { const runtime = yield* PluginRuntime.Service const agents = yield* AgentV2.Service + const permission = yield* PermissionV2.Service const scope = yield* Scope.Scope // Concatenate the child's final completed assistant text. Distinguishes "completed with no @@ -114,6 +116,20 @@ export const Plugin = { if (agent === undefined) return yield* new ToolFailure({ message: `Unknown agent: ${input.agent}` }) if (agent.mode === "primary") return yield* new ToolFailure({ message: `Agent ${input.agent} cannot run as a subagent` }) + yield* permission + .assert({ + action: name, + resources: [agent.id], + save: [agent.id], + sessionID: context.sessionID, + agent: context.agent, + source: { + type: "tool", + messageID: context.assistantMessageID, + callID: context.toolCallID, + }, + }) + .pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error }))) // Model selection is policy/config/session state, not an LLM-facing tool argument. const model = agent.model ?? parent.model @@ -176,5 +192,32 @@ export const Plugin = { ), ) .pipe(Effect.orDie) + + yield* ctx.session.hook("request", (event) => + Effect.gen(function* () { + const tool = event.tools[name] + if (!tool) return + const selected = yield* agents.resolve(event.agent) + if (!selected) return + const available = (yield* agents.list()) + .filter( + (agent) => + agent.mode !== "primary" && + !agent.hidden && + PermissionV2.evaluate(name, agent.id, selected.permissions).effect !== "deny", + ) + .toSorted((a, b) => a.id.localeCompare(b.id)) + if (available.length === 0) return + tool.description = [ + tool.description, + "", + "Available subagents:", + ...available.map( + (agent) => + `- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`, + ), + ].join("\n") + }), + ) }), } diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index 189ee846b9..96c0f10f1a 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -1,6 +1,6 @@ export * as TodoWriteTool from "./todowrite" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { PermissionV2 } from "../permission" diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index d2efc61c73..d761645939 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -1,6 +1,6 @@ export * as WebFetchTool from "./webfetch" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 4064dc0f85..9c91d84f92 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -1,6 +1,6 @@ export * as WebSearchTool from "./websearch" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index c50a3a1e6d..a88063f4e2 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -6,7 +6,7 @@ */ export * as WriteTool from "./write" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { FileMutation } from "../file-mutation" diff --git a/packages/core/test/config/fixtures/plugin/directory-plugin.ts b/packages/core/test/config/fixtures/plugin/directory-plugin.ts index e26e12bdac..f5e15c2c00 100644 --- a/packages/core/test/config/fixtures/plugin/directory-plugin.ts +++ b/packages/core/test/config/fixtures/plugin/directory-plugin.ts @@ -1,6 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export default define({ +export default Plugin.define({ id: "directory-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts index afa7f5c37e..365d5566d2 100644 --- a/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts +++ b/packages/core/test/config/fixtures/plugins/folder-plugin/index.ts @@ -1,6 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export default define({ +export default Plugin.define({ id: "folder-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index 34a69ffbc5..dc1fbc72e7 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -2,7 +2,7 @@ import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" import { describe, expect } from "bun:test" -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "@opencode-ai/core/agent" @@ -178,7 +178,7 @@ describe("PluginSupervisor config", () => { it.live("loads user plugins before internal post plugins", () => Effect.gen(function* () { const sdk = yield* SdkPlugins.Service - yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void })) + yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void })) yield* withLocation( { plugins: [ @@ -275,7 +275,7 @@ function mutablePlugin(description: string) { return ` import { define } from ${JSON.stringify(plugin)} -export default define({ +export default EffectPlugin.define({ id: "mutable-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 5677e288ef..3692e5476e 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -4,7 +4,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { Tool } from "@opencode-ai/core/tool/tool" import { Tools } from "@opencode-ai/core/tool/tools" -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, type Scope } from "effect" export const toolIdentity = { @@ -66,12 +66,10 @@ export const registerToolPlugin = (plugin: { registrations, (registration) => tools.register({ [registration.name]: registration.tool }, registration.options), { discard: true }, - ) + ).pipe(Effect.orDie) + return { dispose: Effect.void } }), - execute: { - before: () => Effect.die("registerToolPlugin does not support tool hooks"), - after: () => Effect.die("registerToolPlugin does not support tool hooks"), - }, + hook: () => Effect.die("registerToolPlugin does not support tool hooks"), }, } yield* plugin.effect(context as PluginContext) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index f2b8d004a1..6b7ba5654c 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -4,7 +4,7 @@ import { describe, expect } from "bun:test" import { Config } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -44,7 +44,7 @@ describe("LocationServiceMap", () => { const sdk = yield* SdkPlugins.Service const locations = yield* LocationServiceMap.Service const id = AgentV2.ID.make("persistent-sdk-agent") - const plugin = define({ + const plugin = EffectPlugin.define({ id: "persistent-sdk-plugin", effect: (ctx) => ctx.agent.transform((agents) => agents.update(id, () => {})), }) @@ -432,7 +432,7 @@ describe("LocationServiceMap", () => { Effect.flatMap((dir) => Effect.gen(function* () { const plugins = yield* PluginV2.Service - const reviewer = define({ + const reviewer = EffectPlugin.define({ id: "reviewer", effect: (ctx) => ctx.agent diff --git a/packages/core/test/plugin-hooks.test.ts b/packages/core/test/plugin-hooks.test.ts new file mode 100644 index 0000000000..8927df9a05 --- /dev/null +++ b/packages/core/test/plugin-hooks.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test" +import { Message, SystemPart } from "@opencode-ai/llm" +import { Agent } from "@opencode-ai/schema/agent" +import { Model } from "@opencode-ai/schema/model" +import { Provider } from "@opencode-ai/schema/provider" +import { Session } from "@opencode-ai/schema/session" +import { Effect, Layer } from "effect" +import { PluginHooks } from "../src/plugin/hooks" + +describe("PluginHooks", () => { + it("registers scoped domain hooks and triggers them sequentially", async () => { + const seen: string[] = [] + const program = Effect.gen(function* () { + const hooks = yield* PluginHooks.Service + yield* hooks.register("session", "request", (event) => + Effect.sync(() => { + seen.push("first") + event.system.push(SystemPart.make("second")) + }), + ) + yield* hooks.register("session", "request", (event) => + Effect.sync(() => { + seen.push(event.system[1]?.text ?? "missing") + event.messages = [Message.user("changed")] + }), + ) + const event = { + sessionID: Session.ID.make("ses_hooks"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }), + system: [SystemPart.make("first")], + messages: [Message.user("original")], + tools: {}, + } + + expect(yield* hooks.trigger("session", "request", event)).toBe(event) + expect(seen).toEqual(["first", "second"]) + expect(event.messages).toEqual([Message.user("changed")]) + }) + + await Effect.runPromise( + Effect.scoped(program).pipe( + Effect.provide(PluginHooks.node.implementation as Layer.Layer), + ), + ) + }) +}) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 5d87102b7d..79fcc5d0f6 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect" -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" import { AgentV2 } from "@opencode-ai/core/agent" @@ -49,7 +49,7 @@ describe("PluginV2", () => { .pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) const managed = () => - define({ + EffectPlugin.define({ id: "managed", effect: (ctx) => ctx.agent @@ -97,25 +97,40 @@ describe("PluginV2", () => { }), ) - it.effect("retries the same generation after materialization fails", () => + it.effect("skips failed plugins and loads the rest", () => Effect.gen(function* () { const plugins = yield* PluginV2.Service + const agents = yield* AgentV2.Service let fail = true - const plugin = define({ - id: "retry", + const good = EffectPlugin.define({ + id: "good", effect: (ctx) => ctx.agent - .transform(() => { - if (fail) throw new Error("materialization failed") - }) + .transform((agents) => + agents.update("configured", (agent) => { + agent.description = "loaded" + }), + ) .pipe(Effect.asVoid), }) + const bad = EffectPlugin.define({ + id: "bad", + effect: () => { + if (fail) return Effect.die(new Error("materialization failed")) + return Effect.void + }, + }) + + yield* plugins.activate([{ plugin: good }, { plugin: bad }]) + expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("good") }]) + expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("loaded") - expect(Exit.isFailure(yield* plugins.activate([{ plugin }]).pipe(Effect.exit))).toBe(true) fail = false - yield* plugins.activate([{ plugin }]) - - expect(yield* plugins.list()).toEqual([{ id: Plugin.ID.make("retry") }]) + yield* plugins.activate([{ plugin: good }, { plugin: bad }]) + expect(yield* plugins.list()).toEqual([ + { id: Plugin.ID.make("good") }, + { id: Plugin.ID.make("bad") }, + ]) }), ) @@ -142,7 +157,7 @@ describe("PluginV2", () => { Effect.gen(function* () { const plugins = yield* PluginV2.Service let visible = true - const plugin = define({ + const plugin = EffectPlugin.define({ id: "isolated", effect: () => Effect.serviceOption(Secret).pipe( @@ -161,7 +176,7 @@ describe("PluginV2", () => { Effect.gen(function* () { const plugins = yield* PluginV2.Service const registry = yield* ToolRegistry.Service - const plugin = define({ + const plugin = EffectPlugin.define({ id: "tool-plugin", effect: (ctx) => ctx.tool @@ -202,7 +217,7 @@ describe("PluginV2", () => { output: Schema.Struct({ ok: Schema.Boolean }), execute: () => Effect.succeed({ ok: true }), }) - const plugin = define({ + const plugin = EffectPlugin.define({ id: "grouped-tools", effect: (ctx) => ctx.tool @@ -234,7 +249,7 @@ describe("PluginV2", () => { after?: { input: unknown; result: unknown; output: unknown } } = {} - const plugin = define({ + const plugin = EffectPlugin.define({ id: "tool-hooks", effect: (ctx) => Effect.gen(function* () { @@ -252,19 +267,23 @@ describe("PluginV2", () => { ) .pipe(Effect.orDie) - yield* ctx.tool.execute - .before((event) => { - seen.before = event.input - event.input = { text: "before-mutated" } - }) + yield* ctx.tool + .hook("execute.before", (event) => + Effect.sync(() => { + seen.before = event.input + event.input = { text: "before-mutated" } + }), + ) .pipe(Effect.asVoid) - yield* ctx.tool.execute - .after((event) => { - seen.after = { input: event.input, result: event.result, output: event.output } - event.result = { type: "text", value: "after-mutated" } - event.output = { structured: { rewritten: true }, content: [] } - }) + yield* ctx.tool + .hook("execute.after", (event) => + Effect.sync(() => { + seen.after = { input: event.input, result: event.result, output: event.output } + event.result = { type: "text", value: "after-mutated" } + event.output = { structured: { rewritten: true }, content: [] } + }), + ) .pipe(Effect.asVoid) }), }) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index ea6e65bb64..ab5c64ae3e 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -13,6 +13,7 @@ import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { Npm } from "@opencode-ai/core/npm" import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { Reference } from "@opencode-ai/core/reference" import { SkillV2 } from "@opencode-ai/core/skill" diff --git a/packages/core/test/plugin/fixtures/config-effect-plugin.ts b/packages/core/test/plugin/fixtures/config-effect-plugin.ts index a5f12a113d..e607b658e3 100644 --- a/packages/core/test/plugin/fixtures/config-effect-plugin.ts +++ b/packages/core/test/plugin/fixtures/config-effect-plugin.ts @@ -1,7 +1,7 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export default define({ +export default Plugin.define({ id: "config-effect-plugin", effect: (ctx) => ctx.agent diff --git a/packages/core/test/plugin/fixtures/config-promise-plugin.ts b/packages/core/test/plugin/fixtures/config-promise-plugin.ts index ed53e4b947..0fef8fbc65 100644 --- a/packages/core/test/plugin/fixtures/config-promise-plugin.ts +++ b/packages/core/test/plugin/fixtures/config-promise-plugin.ts @@ -1,6 +1,6 @@ -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export default define({ +export default Plugin.define({ id: "config-promise-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { diff --git a/packages/core/test/plugin/fixtures/failing-plugin.ts b/packages/core/test/plugin/fixtures/failing-plugin.ts index 4daac0acd0..dac49b410d 100644 --- a/packages/core/test/plugin/fixtures/failing-plugin.ts +++ b/packages/core/test/plugin/fixtures/failing-plugin.ts @@ -1,7 +1,7 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export default define({ +export default Plugin.define({ id: "failing-plugin", effect: () => Effect.die("plugin failed"), }) diff --git a/packages/core/test/plugin/fixtures/variant-source-plugin.ts b/packages/core/test/plugin/fixtures/variant-source-plugin.ts index f799c96888..5dae699f6e 100644 --- a/packages/core/test/plugin/fixtures/variant-source-plugin.ts +++ b/packages/core/test/plugin/fixtures/variant-source-plugin.ts @@ -1,8 +1,8 @@ -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { Effect } from "effect" -export default define({ +export default Plugin.define({ id: "variant-source", effect: (ctx) => ctx.catalog diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 2aa8789548..c5dc96c384 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -1,4 +1,4 @@ -import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" @@ -19,8 +19,7 @@ export function host(overrides: Overrides = {}): PluginContext { reload: () => Effect.die("unused agent.reload"), }, aisdk: overrides.aisdk ?? { - sdk: () => Effect.die("unused aisdk.sdk"), - language: () => Effect.die("unused aisdk.language"), + hook: () => Effect.die("unused aisdk.hook"), }, catalog: overrides.catalog ?? { provider: { @@ -45,11 +44,15 @@ export function host(overrides: Overrides = {}): PluginContext { integration: overrides.integration ?? { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), - connectKey: () => Effect.die("unused integration.connectKey"), - connectOauth: () => Effect.die("unused integration.connectOauth"), - attemptStatus: () => Effect.die("unused integration.attemptStatus"), - attemptComplete: () => Effect.die("unused integration.attemptComplete"), - attemptCancel: () => Effect.die("unused integration.attemptCancel"), + connect: { + key: () => Effect.die("unused integration.connect.key"), + oauth: () => Effect.die("unused integration.connect.oauth"), + }, + attempt: { + status: () => Effect.die("unused integration.attempt.status"), + complete: () => Effect.die("unused integration.attempt.complete"), + cancel: () => Effect.die("unused integration.attempt.cancel"), + }, transform: () => Effect.die("unused integration.transform"), reload: () => Effect.die("unused integration.reload"), connection: { @@ -72,10 +75,7 @@ export function host(overrides: Overrides = {}): PluginContext { }, tool: overrides.tool ?? { transform: () => Effect.die("unused tool.transform"), - execute: { - before: () => Effect.die("unused tool.execute.before"), - after: () => Effect.die("unused tool.execute.after"), - }, + hook: () => Effect.die("unused tool.hook"), }, session: overrides.session ?? { create: () => Effect.die("unused session.create"), @@ -83,6 +83,7 @@ export function host(overrides: Overrides = {}): PluginContext { prompt: () => Effect.die("unused session.prompt"), command: () => Effect.die("unused session.command"), interrupt: () => Effect.die("unused session.interrupt"), + hook: () => Effect.die("unused session.hook"), }, } } @@ -188,11 +189,15 @@ export function integrationHost(integration: Integration.Interface): PluginConte return { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), - connectKey: () => Effect.die("unused integration.connectKey"), - connectOauth: () => Effect.die("unused integration.connectOauth"), - attemptStatus: () => Effect.die("unused integration.attemptStatus"), - attemptComplete: () => Effect.die("unused integration.attemptComplete"), - attemptCancel: () => Effect.die("unused integration.attemptCancel"), + connect: { + key: () => Effect.die("unused integration.connect.key"), + oauth: () => Effect.die("unused integration.connect.oauth"), + }, + attempt: { + status: () => Effect.die("unused integration.attempt.status"), + complete: () => Effect.die("unused integration.attempt.complete"), + cancel: () => Effect.die("unused integration.attempt.cancel"), + }, reload: integration.reload, connection: { active: (id) => integration.connection.active(Integration.ID.make(id)), diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index fbe4029c3a..05a20693b5 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -4,7 +4,7 @@ import { AgentV2 } from "@opencode-ai/core/agent" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginPromise } from "@opencode-ai/core/plugin/promise" -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -16,7 +16,7 @@ describe("fromPromise", () => { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) const seen: string[] = [] - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-client-reads", setup: async (ctx) => { const results = await Promise.all([ @@ -46,7 +46,7 @@ describe("fromPromise", () => { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-example", setup: async (ctx) => { expect(ctx.options.mode).toBe("strict") @@ -75,7 +75,7 @@ describe("fromPromise", () => { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) - const promisePlugin = define({ + const promisePlugin = Plugin.define({ id: "promise-dispose", setup: async (ctx) => { const registration = await ctx.agent.transform((draft) => { diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 01e417ee2f..2944f07972 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -82,7 +82,7 @@ describe("ToolRegistry", () => { }), ) - it.effect("selects one edit tool family for each model", () => + it.effect("materializes all permission-eligible edit tools before request policy", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service yield* service.register({ @@ -96,10 +96,13 @@ describe("ToolRegistry", () => { .materialize({ model }) .pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name))) - expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "patch"]) - expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "patch"]) - expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "patch"]) - expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"]) + expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "edit", "write", "patch"]) + expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual([ + "read", + "edit", + "write", + "patch", + ]) }), ) diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index bcad31bd32..a37a3c5bd7 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -27,10 +27,23 @@ "enum": [ true ] + }, + "version": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] } }, "required": [ - "healthy" + "healthy", + "version", + "pid" ], "additionalProperties": false } @@ -699,14 +712,10 @@ "$ref": "#/components/schemas/SessionActive" } } - }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" } }, "required": [ - "data", - "watermarks" + "data" ], "additionalProperties": false } @@ -734,7 +743,7 @@ } } }, - "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive. Watermarks are the durable log positions read alongside the activity snapshot; activity itself is process state, so the pairing is advisory rather than transactional.", + "description": "Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.", "summary": "List active sessions" } }, @@ -820,6 +829,72 @@ }, "description": "Retrieve a session by ID.", "summary": "Get session" + }, + "delete": { + "tags": [ + "session" + ], + "operationId": "v2.session.remove", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Delete a session and its child sessions.", + "summary": "Delete session" } }, "/api/session/{sessionID}/fork": { @@ -1198,6 +1273,119 @@ } } }, + "/api/session/{sessionID}/move": { + "post": { + "tags": [ + "session" + ], + "operationId": "v2.session.move", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "required": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "SessionNotFoundError", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionNotFoundError" + }, + { + "$ref": "#/components/schemas/SessionNotFoundError" + } + ] + } + } + } + } + }, + "description": "Move a session to another project directory, optionally transferring local changes.", + "summary": "Move session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "destination": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": [ + "directory" + ], + "additionalProperties": false + }, + "moveChanges": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "destination" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/session/{sessionID}/prompt": { "post": { "tags": [ @@ -1245,7 +1433,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1397,7 +1592,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvalidRequestError" + "anyOf": [ + { + "$ref": "#/components/schemas/InvalidRequestError1" + }, + { + "$ref": "#/components/schemas/InvalidRequestError" + } + ] } } } @@ -1759,6 +1961,16 @@ }, "metadata": { "type": "object" + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -1897,8 +2109,24 @@ ], "security": [], "responses": { - "204": { - "description": "" + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/components/schemas/SessionInput.Compaction" + } + }, + "required": [ + "data" + ], + "additionalProperties": false + } + } + } }, "400": { "description": "InvalidRequestError", @@ -1938,38 +2166,46 @@ } }, "409": { - "description": "SessionBusyError", + "description": "ConflictError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SessionBusyError" - } - } - } - }, - "500": { - "description": "UnknownError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnknownError" - } - } - } - }, - "503": { - "description": "ServiceUnavailableError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ServiceUnavailableError" + "$ref": "#/components/schemas/ConflictError" } } } } }, - "description": "Compact a session conversation.", - "summary": "Compact session" + "description": "Queue a durable session compaction request.", + "summary": "Compact session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": true + } } }, "/api/session/{sessionID}/wait": { @@ -2452,12 +2688,12 @@ "summary": "Get session context" } }, - "/api/session/{sessionID}/context-entry": { + "/api/session/{sessionID}/instructions/entries": { "get": { "tags": [ "session" ], - "operationId": "v2.session.context.entry.list", + "operationId": "v2.session.instructions.entry.list", "parameters": [ { "name": "sessionID", @@ -2485,7 +2721,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionContextEntry.Info" + "$ref": "#/components/schemas/InstructionEntry.Info" } } }, @@ -2535,16 +2771,16 @@ } } }, - "description": "List API-managed context entries attached to the session's system context.", - "summary": "List context entries" + "description": "List API-managed instruction entries attached to the session.", + "summary": "List instruction entries" } }, - "/api/session/{sessionID}/context-entry/{key}": { + "/api/session/{sessionID}/instructions/entries/{key}": { "put": { "tags": [ "session" ], - "operationId": "v2.session.context.entry.put", + "operationId": "v2.session.instructions.entry.put", "parameters": [ { "name": "sessionID", @@ -2563,7 +2799,7 @@ "name": "key", "in": "path", "schema": { - "$ref": "#/components/schemas/SessionContextEntry.Key" + "$ref": "#/components/schemas/InstructionEntry.Key" }, "required": true } @@ -2611,8 +2847,8 @@ } } }, - "description": "Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.", - "summary": "Put context entry", + "description": "Attach or replace one durable instruction entry. Changes announce as updates at the next step boundary.", + "summary": "Put instruction entry", "requestBody": { "content": { "application/json": { @@ -2635,7 +2871,7 @@ "tags": [ "session" ], - "operationId": "v2.session.context.entry.remove", + "operationId": "v2.session.instructions.entry.remove", "parameters": [ { "name": "sessionID", @@ -2654,7 +2890,7 @@ "name": "key", "in": "path", "schema": { - "$ref": "#/components/schemas/SessionContextEntry.Key" + "$ref": "#/components/schemas/InstructionEntry.Key" }, "required": true } @@ -2702,11 +2938,11 @@ } } }, - "description": "Remove one context entry; the removal is announced to the model at the next turn boundary.", - "summary": "Remove context entry" + "description": "Remove one instruction entry; the removal is announced to the model at the next step boundary.", + "summary": "Remove instruction entry" } }, - "/api/session/{sessionID}/log": { + "/api/experimental/session/{sessionID}/log": { "get": { "tags": [ "session" @@ -2911,7 +3147,7 @@ } } }, - "description": "Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.", + "description": "Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.", "summary": "Read the session log" } }, @@ -3156,7 +3392,7 @@ "tags": [ "session" ], - "operationId": "v2.session.messages", + "operationId": "v2.message.list", "parameters": [ { "name": "sessionID", @@ -4781,6 +5017,104 @@ "summary": "List MCP servers" } }, + "/api/mcp/resource": { + "get": { + "tags": [ + "mcp" + ], + "operationId": "v2.mcp.resource.catalog", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Mcp.ResourceCatalog" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Retrieve resources and resource templates from connected MCP servers.", + "summary": "List MCP resources" + } + }, "/api/credential/{credentialID}": { "patch": { "tags": [ @@ -7257,154 +7591,10 @@ } } }, - "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed. Consumers that need reliability should combine the changes feed with durable session log reads.", + "description": "Subscribe to native event payloads for the server. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.", "summary": "Subscribe to events" } }, - "/api/event/changes": { - "get": { - "tags": [ - "event" - ], - "operationId": "v2.event.changes", - "parameters": [], - "security": [], - "responses": { - "200": { - "description": "Success", - "content": { - "text/event-stream": { - "schema": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "event": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/EventLog.ChangeStream" - } - }, - "required": [ - "id", - "event", - "data" - ], - "additionalProperties": false - }, - "x-effect-stream": { - "encoding": "sse", - "causeSchema": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Fail" - ] - }, - "error": { - "not": {} - } - }, - "required": [ - "_tag", - "error" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Die" - ] - }, - "defect": {} - }, - "required": [ - "_tag", - "defect" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "_tag": { - "type": "string", - "enum": [ - "Interrupt" - ] - }, - "fiberId": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "_tag", - "fiberId" - ], - "additionalProperties": false - } - ] - } - }, - "errorSchema": { - "not": {} - }, - "failureEvent": "effect/httpapi/stream/failure" - } - } - } - }, - "400": { - "description": "InvalidRequestError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InvalidRequestError" - } - } - } - }, - "401": { - "description": "UnauthorizedError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedError" - } - } - } - } - }, - "description": "Payload-free hint channel: after an event commits, a subscriber eventually receives a hint for that aggregate with seq at or beyond the event, or a sweep-required marker. Hints coalesce to the latest seq per aggregate under backpressure and the stream never fails from overflow. No consumer may derive correctness from receiving a hint; correctness always comes from durable log reads plus the consumer's own checkpoint. A sweep-required marker is emitted first on every (re)subscribe and whenever hint retention is exceeded: treat every aggregate as potentially dirty and recover via bounded sweep plus log reads.", - "summary": "Subscribe to change hints" - } - }, "/api/pty": { "get": { "tags": [ @@ -8022,7 +8212,7 @@ "tags": [ "pty" ], - "operationId": "v2.pty.connectToken", + "operationId": "v2.pty.connect.token", "parameters": [ { "name": "ptyID", @@ -8475,7 +8665,8 @@ } }, "required": [ - "command" + "command", + "timeout" ], "additionalProperties": false } @@ -8705,6 +8896,151 @@ "summary": "Remove shell command" } }, + "/api/shell/{id}/timeout": { + "patch": { + "tags": [ + "shell" + ], + "operationId": "v2.shell.timeout", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/Location.Info" + }, + "data": { + "$ref": "#/components/schemas/Shell" + } + }, + "required": [ + "location", + "data" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "ShellNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShellNotFoundError" + } + } + } + } + }, + "description": "Replace a running shell command's timeout from now, or clear it with zero.", + "summary": "Update shell timeout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "timeout": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + } + }, + "required": [ + "timeout" + ], + "additionalProperties": false + } + } + }, + "required": true + } + } + }, "/api/shell/{id}/output": { "get": { "tags": [ @@ -9907,7 +10243,7 @@ "tags": [ "debug" ], - "operationId": "v2.debug.location", + "operationId": "v2.debug.location.list", "parameters": [], "security": [], "responses": { @@ -9947,6 +10283,82 @@ }, "description": "List locations currently loaded by the server.", "summary": "List loaded locations" + }, + "delete": { + "tags": [ + "debug" + ], + "operationId": "v2.debug.location.evict", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "object", + "properties": { + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspace": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Dispose the requested location's cached services so its next use boots them fresh.", + "summary": "Evict a loaded location" } } }, @@ -10329,6 +10741,31 @@ } ] }, + "fork": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "messageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + }, "projectID": { "type": "string" }, @@ -10421,20 +10858,6 @@ ], "additionalProperties": false }, - "SessionWatermarks": { - "type": "object", - "patternProperties": { - "^ses": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "description": "Durable log seq each session's snapshot was computed at. Attach a live log read after the watermark to compose fetch and stream gap-free; apply a snapshot only where its watermark is at or beyond already-applied events. Sessions without durable events are absent." - }, "SessionsResponse": { "type": "object", "properties": { @@ -10444,9 +10867,6 @@ "$ref": "#/components/schemas/SessionV2.Info" } }, - "watermarks": { - "$ref": "#/components/schemas/SessionWatermarks" - }, "cursor": { "type": "object", "properties": { @@ -10476,7 +10896,6 @@ }, "required": [ "data", - "watermarks", "cursor" ], "additionalProperties": false @@ -10604,7 +11023,7 @@ ], "additionalProperties": false }, - "Prompt.Source": { + "Prompt.Mention": { "type": "object", "properties": { "start": { @@ -10636,8 +11055,8 @@ "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10651,8 +11070,8 @@ "name": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ @@ -10684,28 +11103,78 @@ ], "additionalProperties": false }, + "Prompt.Base64": { + "type": "string", + "allOf": [ + { + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + } + ] + }, + "Prompt.FileSource": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "inline" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "uri" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "uri" + ], + "additionalProperties": false + } + ] + }, "Prompt.FileAttachment": { "type": "object", "properties": { - "uri": { - "type": "string" + "data": { + "$ref": "#/components/schemas/Prompt.Base64" }, "mime": { "type": "string" }, + "source": { + "$ref": "#/components/schemas/Prompt.FileSource" + }, "name": { "type": "string" }, "description": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" + "mention": { + "$ref": "#/components/schemas/Prompt.Mention" } }, "required": [ - "uri", - "mime" + "data", + "mime", + "source" ], "additionalProperties": false }, @@ -10890,26 +11359,57 @@ ], "additionalProperties": false }, - "SessionBusyError": { + "SessionInput.Compaction": { "type": "object", "properties": { - "_tag": { + "type": { "type": "string", "enum": [ - "SessionBusyError" + "compaction" + ] + }, + "admittedSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } ] }, "sessionID": { - "type": "string" + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] }, - "message": { - "type": "string" + "timeCreated": { + "type": "number" + }, + "handledSeq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ - "_tag", + "type", + "admittedSeq", + "id", "sessionID", - "message" + "timeCreated" ], "additionalProperties": false }, @@ -10942,6 +11442,29 @@ ], "additionalProperties": false }, + "SessionBusyError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SessionBusyError" + ] + }, + "sessionID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "sessionID", + "message" + ], + "additionalProperties": false + }, "UnknownError": { "type": "object", "properties": { @@ -11532,25 +12055,18 @@ "text" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" } }, "required": [ "type", - "id", "text" ], "additionalProperties": false }, - "LLM.ProviderMetadata": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState": { + "type": "object" }, "Session.Message.Assistant.Reasoning": { "type": "object", @@ -11561,14 +12077,11 @@ "reasoning" ] }, - "id": { - "type": "string" - }, "text": { "type": "string" }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "time": { "type": "object", @@ -11588,7 +12101,6 @@ }, "required": [ "type", - "id", "text" ], "additionalProperties": false @@ -11740,14 +12252,11 @@ ], "additionalProperties": false }, - "Session.Error.Unknown": { + "Session.StructuredError": { "type": "object", "properties": { "type": { - "type": "string", - "enum": [ - "unknown" - ] + "type": "string" }, "message": { "type": "string" @@ -11781,7 +12290,7 @@ "type": "object" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {} }, @@ -11809,23 +12318,14 @@ "name": { "type": "string" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - }, - "resultMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "providerState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" + }, + "providerResultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState" }, "state": { "anyOf": [ @@ -11874,6 +12374,31 @@ ], "additionalProperties": false }, + "Session.Message.Assistant.Retry": { + "type": "object", + "properties": { + "attempt": { + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "number" + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "attempt", + "at", + "error" + ], + "additionalProperties": false + }, "Session.Message.Assistant": { "type": "object", "properties": { @@ -11950,7 +12475,15 @@ "additionalProperties": false }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { "type": "number" @@ -11993,7 +12526,10 @@ "additionalProperties": false }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "retry": { + "$ref": "#/components/schemas/Session.Message.Assistant.Retry" } }, "required": [ @@ -12015,6 +12551,15 @@ "compaction" ] }, + "status": { + "type": "string", + "enum": [ + "queued", + "running", + "completed", + "failed" + ] + }, "reason": { "type": "string", "enum": [ @@ -12054,6 +12599,7 @@ }, "required": [ "type", + "status", "reason", "summary", "recent", @@ -12093,20 +12639,20 @@ } ] }, - "SessionContextEntry.Key": { + "InstructionEntry.Key": { "type": "string", "allOf": [ { "pattern": "^[a-z0-9][a-z0-9._-]*$", - "description": "Context entry key (lowercase alphanumerics plus . _ -)" + "description": "Instruction entry key (lowercase alphanumerics plus . _ -)" } ] }, - "SessionContextEntry.Info": { + "InstructionEntry.Info": { "type": "object", "properties": { "key": { - "$ref": "#/components/schemas/SessionContextEntry.Key" + "$ref": "#/components/schemas/InstructionEntry.Key" }, "value": {} }, @@ -12467,6 +13013,89 @@ ], "additionalProperties": false }, + "session.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.deleted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.forked": { "type": "object", "properties": { @@ -12763,7 +13392,7 @@ ], "additionalProperties": false }, - "session.context.updated": { + "session.execution.started": { "type": "object", "properties": { "id": { @@ -12783,7 +13412,352 @@ "type": { "type": "string", "enum": [ - "session.context.updated" + "session.execution.started" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.succeeded": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.succeeded" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "sessionID", + "error" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.execution.interrupted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.execution.interrupted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "user", + "shutdown", + "superseded" + ] + } + }, + "required": [ + "sessionID", + "reason" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, + "session.instructions.updated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.instructions.updated" ] }, "durable": { @@ -13562,7 +14536,15 @@ ] }, "finish": { - "type": "string" + "type": "string", + "enum": [ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "unknown" + ] }, "cost": { "type": "number" @@ -13709,7 +14691,47 @@ ] }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false } }, "required": [ @@ -13804,14 +14826,19 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] } }, "required": [ "sessionID", "assistantMessageID", - "textID" + "ordinal" ], "additionalProperties": false } @@ -13900,8 +14927,13 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "text": { "type": "string" @@ -13910,7 +14942,7 @@ "required": [ "sessionID", "assistantMessageID", - "textID", + "ordinal", "text" ], "additionalProperties": false @@ -13925,11 +14957,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata3": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState3": { + "type": "object" }, "session.reasoning.started": { "type": "object", @@ -14006,17 +15035,22 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata3" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState3" } }, "required": [ "sessionID", "assistantMessageID", - "reasoningID" + "ordinal" ], "additionalProperties": false } @@ -14030,11 +15064,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata4": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState4": { + "type": "object" }, "session.reasoning.ended": { "type": "object", @@ -14111,20 +15142,25 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "text": { "type": "string" }, - "providerMetadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata4" + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState4" } }, "required": [ "sessionID", "assistantMessageID", - "reasoningID", + "ordinal", "text" ], "additionalProperties": false @@ -14339,11 +15375,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata5": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState5": { + "type": "object" }, "session.tool.called": { "type": "object", @@ -14423,35 +15456,22 @@ "callID": { "type": "string" }, - "tool": { - "type": "string" - }, "input": { "type": "object" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata5" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "state": { + "$ref": "#/components/schemas/Session.Message.ProviderState5" } }, "required": [ "sessionID", "assistantMessageID", "callID", - "tool", "input", - "provider" + "executed" ], "additionalProperties": false } @@ -14572,11 +15592,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata6": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState6": { + "type": "object" }, "session.tool.success": { "type": "object", @@ -14672,20 +15689,11 @@ } }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata6" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState6" } }, "required": [ @@ -14694,7 +15702,7 @@ "callID", "structured", "content", - "provider" + "executed" ], "additionalProperties": false } @@ -14708,11 +15716,8 @@ ], "additionalProperties": false }, - "LLM.ProviderMetadata7": { - "type": "object", - "additionalProperties": { - "type": "object" - } + "Session.Message.ProviderState7": { + "type": "object" }, "session.tool.failed": { "type": "object", @@ -14793,23 +15798,14 @@ "type": "string" }, "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "$ref": "#/components/schemas/Session.StructuredError" }, "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLM.ProviderMetadata7" - } - }, - "required": [ - "executed" - ], - "additionalProperties": false + "executed": { + "type": "boolean" + }, + "resultState": { + "$ref": "#/components/schemas/Session.Message.ProviderState7" } }, "required": [ @@ -14817,7 +15813,7 @@ "assistantMessageID", "callID", "error", - "provider" + "executed" ], "additionalProperties": false } @@ -14831,41 +15827,7 @@ ], "additionalProperties": false }, - "session.retry.error": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "statusCode": { - "type": "number" - }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { - "type": "string" - }, - "metadata": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "required": [ - "message", - "isRetryable" - ], - "additionalProperties": false - }, - "session.retried": { + "session.retry.scheduled": { "type": "object", "properties": { "id": { @@ -14885,7 +15847,7 @@ "type": { "type": "string", "enum": [ - "session.retried" + "session.retry.scheduled" ] }, "durable": { @@ -14932,16 +15894,39 @@ } ] }, + "assistantMessageID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, "attempt": { - "type": "number" + "type": "integer", + "allOf": [ + { + "exclusiveMinimum": 0 + } + ] + }, + "at": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "error": { - "$ref": "#/components/schemas/session.retry.error" + "$ref": "#/components/schemas/Session.StructuredError" } }, "required": [ "sessionID", + "assistantMessageID", "attempt", + "at", "error" ], "additionalProperties": false @@ -14956,6 +15941,98 @@ ], "additionalProperties": false }, + "session.compaction.admitted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.admitted" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + } + }, + "required": [ + "sessionID", + "inputID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.compaction.started": { "type": "object", "properties": { @@ -15146,6 +16223,89 @@ ], "additionalProperties": false }, + "session.compaction.failed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "session.compaction.failed" + ] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "version": { + "type": "integer", + "allOf": [ + { + "minimum": 1 + } + ] + } + }, + "required": [ + "aggregateID", + "seq", + "version" + ], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "allOf": [ + { + "pattern": "^ses" + } + ] + } + }, + "required": [ + "sessionID" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "durable", + "data" + ], + "additionalProperties": false + }, "session.revert.staged": { "type": "object", "properties": { @@ -15383,7 +16543,7 @@ } ] }, - "messageID": { + "to": { "type": "string", "allOf": [ { @@ -15394,7 +16554,7 @@ }, "required": [ "sessionID", - "messageID" + "to" ], "additionalProperties": false } @@ -15422,6 +16582,9 @@ { "$ref": "#/components/schemas/session.renamed" }, + { + "$ref": "#/components/schemas/session.deleted" + }, { "$ref": "#/components/schemas/session.forked" }, @@ -15432,7 +16595,19 @@ "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.context.updated" + "$ref": "#/components/schemas/session.execution.started" + }, + { + "$ref": "#/components/schemas/session.execution.succeeded" + }, + { + "$ref": "#/components/schemas/session.execution.failed" + }, + { + "$ref": "#/components/schemas/session.execution.interrupted" + }, + { + "$ref": "#/components/schemas/session.instructions.updated" }, { "$ref": "#/components/schemas/session.synthetic" @@ -15486,7 +16661,10 @@ "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.retried" + "$ref": "#/components/schemas/session.retry.scheduled" + }, + { + "$ref": "#/components/schemas/session.compaction.admitted" }, { "$ref": "#/components/schemas/session.compaction.started" @@ -15494,6 +16672,9 @@ { "$ref": "#/components/schemas/session.compaction.ended" }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, { "$ref": "#/components/schemas/session.revert.staged" }, @@ -15559,14 +16740,6 @@ "$ref": "#/components/schemas/Session.Message" } }, - "watermark": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, "cursor": { "type": "object", "properties": { @@ -15600,65 +16773,6 @@ ], "additionalProperties": false }, - "Model.Api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "package" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "id", - "type", - "settings" - ], - "additionalProperties": false - } - ] - }, "Model.Capabilities": { "type": "object", "properties": { @@ -15685,6 +16799,30 @@ ], "additionalProperties": false }, + "Model.Variant": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + }, "Model.Cost": { "type": "object", "properties": { @@ -15743,6 +16881,9 @@ "id": { "type": "string" }, + "modelID": { + "type": "string" + }, "providerID": { "type": "string" }, @@ -15752,66 +16893,28 @@ "name": { "type": "string" }, - "api": { - "$ref": "#/components/schemas/Model.Api" + "package": { + "type": "string" + }, + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" }, "capabilities": { "$ref": "#/components/schemas/Model.Capabilities" }, - "request": { - "type": "object", - "properties": { - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "variant": { - "type": "string" - } - }, - "required": [ - "settings", - "headers", - "body" - ], - "additionalProperties": false - }, "variants": { "type": "array", "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "settings": { - "$ref": "#/components/schemas/Provider.Settings" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - } - }, - "required": [ - "id", - "settings", - "headers", - "body" - ], - "additionalProperties": false + "$ref": "#/components/schemas/Model.Variant" } }, "time": { @@ -15866,11 +16969,10 @@ }, "required": [ "id", + "modelID", "providerID", "name", - "api", "capabilities", - "request", "variants", "time", "cost", @@ -15901,63 +17003,6 @@ ], "additionalProperties": false }, - "Provider.AISDK": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "aisdk" - ] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "package" - ], - "additionalProperties": false - }, - "Provider.Native": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "native" - ] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": [ - "type", - "settings" - ], - "additionalProperties": false - }, - "Provider.Api": { - "anyOf": [ - { - "$ref": "#/components/schemas/Provider.AISDK" - }, - { - "$ref": "#/components/schemas/Provider.Native" - } - ] - }, "ProviderV2.Info": { "type": "object", "properties": { @@ -15973,18 +17018,26 @@ "disabled": { "type": "boolean" }, - "api": { - "$ref": "#/components/schemas/Provider.Api" + "package": { + "type": "string" }, - "request": { - "$ref": "#/components/schemas/Provider.Request" + "settings": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" } }, "required": [ "id", "name", - "api", - "request" + "package" ], "additionalProperties": false }, @@ -16941,6 +17994,80 @@ ], "additionalProperties": false }, + "Mcp.Resource": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uri" + ], + "additionalProperties": false + }, + "Mcp.ResourceTemplate": { + "type": "object", + "properties": { + "server": { + "type": "string" + }, + "name": { + "type": "string" + }, + "uriTemplate": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "server", + "name", + "uriTemplate" + ], + "additionalProperties": false + }, + "Mcp.ResourceCatalog": { + "type": "object", + "properties": { + "resources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.Resource" + } + }, + "templates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Mcp.ResourceTemplate" + } + } + }, + "required": [ + "resources", + "templates" + ], + "additionalProperties": false + }, "Project.Vcs": { "type": "string", "enum": [ @@ -18911,7 +20038,7 @@ ], "additionalProperties": false }, - "session.deleted": { + "session.deleted1": { "type": "object", "properties": { "id": { @@ -21520,7 +22647,7 @@ ], "additionalProperties": false }, - "session.execution.settled": { + "session.usage.updated": { "type": "object", "properties": { "id": { @@ -21540,7 +22667,7 @@ "type": { "type": "string", "enum": [ - "session.execution.settled" + "session.usage.updated" ] }, "location": { @@ -21557,21 +22684,51 @@ } ] }, - "outcome": { - "type": "string", - "enum": [ - "success", - "failure", - "interrupted" - ] + "cost": { + "type": "number" }, - "error": { - "$ref": "#/components/schemas/Session.Error.Unknown" + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false } }, "required": [ "sessionID", - "outcome" + "cost", + "tokens" ], "additionalProperties": false } @@ -21629,8 +22786,13 @@ } ] }, - "textID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" @@ -21639,7 +22801,7 @@ "required": [ "sessionID", "assistantMessageID", - "textID", + "ordinal", "delta" ], "additionalProperties": false @@ -21698,8 +22860,13 @@ } ] }, - "reasoningID": { - "type": "string" + "ordinal": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] }, "delta": { "type": "string" @@ -21708,7 +22875,7 @@ "required": [ "sessionID", "assistantMessageID", - "reasoningID", + "ordinal", "delta" ], "additionalProperties": false @@ -24592,6 +25759,53 @@ ], "additionalProperties": false }, + "mcp.resources.changed": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^evt_" + } + ] + }, + "created": { + "type": "number" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": [ + "mcp.resources.changed" + ] + }, + "location": { + "$ref": "#/components/schemas/Location.Ref" + }, + "data": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": [ + "server" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "created", + "type", + "data" + ], + "additionalProperties": false + }, "permission.asked": { "type": "object", "properties": { @@ -25239,7 +26453,7 @@ "$ref": "#/components/schemas/session.updated" }, { - "$ref": "#/components/schemas/session.deleted" + "$ref": "#/components/schemas/session.deleted1" }, { "$ref": "#/components/schemas/message.updated" @@ -25265,6 +26479,12 @@ { "$ref": "#/components/schemas/session.renamed" }, + { + "$ref": "#/components/schemas/session.usage.updated" + }, + { + "$ref": "#/components/schemas/session.deleted" + }, { "$ref": "#/components/schemas/session.forked" }, @@ -25275,10 +26495,19 @@ "$ref": "#/components/schemas/session.prompt.admitted" }, { - "$ref": "#/components/schemas/session.execution.settled" + "$ref": "#/components/schemas/session.execution.started" }, { - "$ref": "#/components/schemas/session.context.updated" + "$ref": "#/components/schemas/session.execution.succeeded" + }, + { + "$ref": "#/components/schemas/session.execution.failed" + }, + { + "$ref": "#/components/schemas/session.execution.interrupted" + }, + { + "$ref": "#/components/schemas/session.instructions.updated" }, { "$ref": "#/components/schemas/session.synthetic" @@ -25341,7 +26570,10 @@ "$ref": "#/components/schemas/session.tool.failed" }, { - "$ref": "#/components/schemas/session.retried" + "$ref": "#/components/schemas/session.retry.scheduled" + }, + { + "$ref": "#/components/schemas/session.compaction.admitted" }, { "$ref": "#/components/schemas/session.compaction.started" @@ -25352,6 +26584,9 @@ { "$ref": "#/components/schemas/session.compaction.ended" }, + { + "$ref": "#/components/schemas/session.compaction.failed" + }, { "$ref": "#/components/schemas/session.revert.staged" }, @@ -25463,6 +26698,9 @@ { "$ref": "#/components/schemas/mcp.status.changed" }, + { + "$ref": "#/components/schemas/mcp.resources.changed" + }, { "$ref": "#/components/schemas/permission.asked" }, @@ -25493,68 +26731,6 @@ }, "contentMediaType": "application/json" }, - "EventLog.Hint": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.hint" - ] - }, - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - } - }, - "required": [ - "type", - "aggregateID", - "seq" - ], - "additionalProperties": false, - "description": "Payload-free change hint: the aggregate's durable log advanced to at least seq. Hints coalesce under backpressure (latest per aggregate) and are never a delivery guarantee." - }, - "EventLog.SweepRequired": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "log.sweep_required" - ] - } - }, - "required": [ - "type" - ], - "additionalProperties": false, - "description": "Hints may have been lost; treat every aggregate as potentially dirty and recover via bounded sweep plus durable log reads. Emitted first on every (re)subscribe." - }, - "EventLog.Change": { - "anyOf": [ - { - "$ref": "#/components/schemas/EventLog.Hint" - }, - { - "$ref": "#/components/schemas/EventLog.SweepRequired" - } - ] - }, - "EventLog.ChangeStream": { - "type": "string", - "contentSchema": { - "$ref": "#/components/schemas/EventLog.Change" - }, - "contentMediaType": "application/json" - }, "PtyNotFoundError": { "type": "object", "properties": { @@ -25946,7 +27122,7 @@ }, { "name": "mcp", - "description": "MCP server status routes." + "description": "MCP server and resource routes." }, { "name": "credential" diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index f6ad6b02d7..30822e57bb 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -1,7 +1,7 @@ import { isAbsolute, join } from "node:path" -import { Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" +import { Context, Effect, FileSystem, PlatformError, Schema, SchemaAST, SchemaRepresentation } from "effect" import { HttpMethod, type HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { format } from "prettier" export type InputField = { @@ -35,8 +35,6 @@ export type Contract = { readonly groups: ReadonlyArray } -export type EndpointName = string | readonly [string, ...Array] - export class GenerationError extends Schema.TaggedErrorClass()("GenerationError", { reason: Schema.String, }) { @@ -89,7 +87,6 @@ export function compile( api: HttpApi.HttpApi, options?: { readonly groupNames?: Readonly> - readonly endpointNames?: Readonly> readonly omitEndpoints?: ReadonlySet }, ): Contract { @@ -151,8 +148,11 @@ export function compile( for (const [path, schema] of schemaPaths) assertPortable(schema, path, portable) } - const clientPath = normalizeClientPath( - options?.endpointNames?.[endpoint.name] ?? clientEndpointName(endpoint.name), + const clientPath = clientEndpointPath( + group.identifier, + Context.getOrElse(endpoint.annotations, OpenApi.Identifier, () => + group.topLevel ? endpoint.name : `${group.identifier}.${endpoint.name}`, + ), ) endpoints.push({ group: groupName, @@ -699,21 +699,17 @@ function clientOperationKey(group: Group, endpoint: Endpoint) { return [group.identifier, ...endpoint.clientPath].join(".") } -function clientEndpointName(name: string) { - return name.slice(name.lastIndexOf(".") + 1) -} - -function normalizeClientPath(path: EndpointName) { - const result = typeof path === "string" ? [path] : [...path] +function clientEndpointPath(group: string, name: string) { + const parts = name.split(".") + if (parts[0] === "v2") parts.shift() + const index = parts.lastIndexOf(group.slice(group.lastIndexOf(".") + 1)) + const result = index < 0 ? parts : parts.slice(index + 1) if (result.length === 0 || result.some((part) => part.length === 0)) { throw new GenerationError({ reason: "Client endpoint path must contain non-empty names" }) } if (result.some((part) => part === "__proto__")) { throw new GenerationError({ reason: "Client endpoint path cannot contain __proto__" }) } - if (typeof path !== "string" && result.some((part) => part.includes("."))) { - throw new GenerationError({ reason: "Nested client endpoint path segments cannot contain dots" }) - } return result as [string, ...Array] } diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index f6a55e73a0..f949f70d46 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { Effect, FileSystem, Schema, SchemaAST, SchemaGetter } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { format } from "prettier" import { compile as compileContract, @@ -139,48 +139,50 @@ describe("HttpApiCodegen.generate", () => { expect(contract.groups[0]?.endpoints[0]?.operation).toMatchObject({ group: "sessions", name: "get" }) }) - test("supports explicit public endpoint names", () => { + test("derives nested paths from OpenAPI operation IDs", () => { const source = HttpApi.make("test").add( - HttpApiGroup.make("server.permission") - .add(HttpApiEndpoint.get("permission.request.list", "/request", { success: Schema.String })) - .add(HttpApiEndpoint.get("session.permission.list", "/session", { success: Schema.String })), + HttpApiGroup.make("server.session").add( + HttpApiEndpoint.get("internal.stage", "/session/revert/stage", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.revert.stage" }), + ), + ), ) - const contract = compileContract(source, { - endpointNames: { "permission.request.list": "listRequests" }, - }) + const contract = compileContract(source, { groupNames: { "server.session": "session" } }) - expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual(["listRequests", "list"]) + expect(contract.groups[0]?.endpoints[0]?.clientPath).toEqual(["revert", "stage"]) + expect(OpenApi.fromApi(source).paths["/session/revert/stage"]?.get?.operationId).toBe("v2.session.revert.stage") }) - test("supports explicit nested endpoint paths while string aliases remain flat", () => { + test("uses nested OpenAPI operation IDs across emitters", () => { const source = HttpApi.make("test").add( HttpApiGroup.make("server.session") - .add(HttpApiEndpoint.get("session.instructions.list", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.put("session.instructions.put", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.delete("session.instructions.remove", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.get("session.messages", "/session/message", { success: Schema.String })), + .add( + HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.instructions.list" }), + ), + ) + .add( + HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.instructions.put" }), + ), + ) + .add( + HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "v2.session.instructions.remove" }), + ), + ), ) - const contract = compileContract(source, { - groupNames: { "server.session": "session" }, - endpointNames: { - "session.instructions.list": ["instructions", "list"], - "session.instructions.put": ["instructions", "put"], - "session.instructions.remove": ["instructions", "remove"], - "session.messages": "instructions.flat", - }, - }) + const contract = compileContract(source, { groupNames: { "server.session": "session" } }) expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.clientPath)).toEqual([ ["instructions", "list"], ["instructions", "put"], ["instructions", "remove"], - ["instructions.flat"], ]) expect(contract.groups[0]?.endpoints.map((endpoint) => endpoint.operation.name)).toEqual([ "instructions.list", "instructions.put", "instructions.remove", - "instructions.flat", ]) const promise = emitPromise(contract, { @@ -196,7 +198,6 @@ describe("HttpApiCodegen.generate", () => { expect(promiseClient).toContain('"session": { "instructions": { "list": (requestOptions?: RequestOptions)') expect(promiseClient).toContain('"put": (requestOptions?: RequestOptions)') expect(promiseClient).toContain('"remove": (requestOptions?: RequestOptions)') - expect(promiseClient).toContain('"instructions.flat": (requestOptions?: RequestOptions)') expect(promiseTypes).toContain('import type { InstructionListWire } from "./instruction-list-wire"') expect(promiseTypes).toContain("export type SessionInstructionsListOutput = InstructionListWire") expect(promiseTypes).toContain("export type SessionInstructionsPutOutput = string") @@ -204,12 +205,12 @@ describe("HttpApiCodegen.generate", () => { const effect = emitEffect(contract) expect(effect.files.find((file) => file.path === "session.ts")?.content).toContain( - '"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }, "instructions.flat": Endpoint3(raw)', + '"instructions": { "list": Endpoint0(raw), "put": Endpoint1(raw), "remove": Endpoint2(raw) }', ) const imported = emitEffectImported(contract, { module: "@example/api", api: "Api" }) expect(imported.files.find((file) => file.path === "client.ts")?.content).toContain( - '"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }, "instructions.flat": Endpoint0_3(raw)', + '"instructions": { "list": Endpoint0_0(raw), "put": Endpoint0_1(raw), "remove": Endpoint0_2(raw) }', ) const shape = emitEffectShape(contract, { module: "@example/api", api: "Api" }) @@ -217,25 +218,28 @@ describe("HttpApiCodegen.generate", () => { expect(apiShape).toContain('readonly "instructions": { readonly "list": SessionInstructionsListOperation') expect(apiShape).toContain('readonly "put": SessionInstructionsPutOperation') expect(apiShape).toContain('readonly "remove": SessionInstructionsRemoveOperation') - expect(apiShape).toContain('readonly "instructions.flat": SessionInstructionsFlatOperation') }) - test("executes nested Promise endpoint aliases", async () => { + test("executes nested Promise operation IDs", async () => { const source = HttpApi.make("test").add( HttpApiGroup.make("session") - .add(HttpApiEndpoint.get("instructions.list", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.put("instructions.put", "/session/instructions", { success: Schema.String })) - .add(HttpApiEndpoint.delete("instructions.remove", "/session/instructions", { success: Schema.String })), - ) - const output = emitPromise( - compileContract(source, { - endpointNames: { - "instructions.list": ["instructions", "list"], - "instructions.put": ["instructions", "put"], - "instructions.remove": ["instructions", "remove"], - }, - }), + .add( + HttpApiEndpoint.get("list", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.list" }), + ), + ) + .add( + HttpApiEndpoint.put("put", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.put" }), + ), + ) + .add( + HttpApiEndpoint.delete("remove", "/session/instructions", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.remove" }), + ), + ), ) + const output = emitPromise(compileContract(source)) const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) const methods: Array = [] @@ -262,59 +266,67 @@ describe("HttpApiCodegen.generate", () => { test("rejects duplicate and leaf-namespace endpoint paths", () => { const source = HttpApi.make("test").add( HttpApiGroup.make("session") - .add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })) - .add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })), + .add( + HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.list" }), + ), + ) + .add( + HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.instructions.list" }), + ), + ), ) - expect(() => - compileContract(source, { endpointNames: { first: ["instructions", "list"], second: ["instructions", "list"] } }), - ).toThrow("Client endpoint name collision: session.instructions.list") - expect(() => - compileContract(source, { endpointNames: { first: "instructions", second: ["instructions", "list"] } }), - ).toThrow("Client endpoint name collision: session.instructions.list") + expect(() => compileContract(source)).toThrow("Client endpoint name collision: session.instructions.list") }) test("rejects nested root collisions across top-level groups", () => { const source = HttpApi.make("test") .add( HttpApiGroup.make("first", { topLevel: true }).add( - HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }), + HttpApiEndpoint.get("first.list", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "instructions.list" }), + ), ), ) .add( HttpApiGroup.make("second", { topLevel: true }).add( - HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }), + HttpApiEndpoint.get("second.put", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "instructions.put" }), + ), ), ) - expect(() => - compileContract(source, { - endpointNames: { "first.list": ["instructions", "list"], "second.put": ["instructions", "put"] }, - }), - ).toThrow("Client name collision: instructions") + expect(() => compileContract(source)).toThrow("Client name collision: instructions") }) test("rejects nested paths that collide after type-name normalization", () => { const source = HttpApi.make("test").add( HttpApiGroup.make("session") - .add(HttpApiEndpoint.get("first", "/first", { success: Schema.String })) - .add(HttpApiEndpoint.get("second", "/second", { success: Schema.String })), + .add( + HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.foo.bar" }), + ), + ) + .add( + HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.foo-bar" }), + ), + ), ) - expect(() => compileContract(source, { endpointNames: { first: ["foo", "bar"], second: "foo-bar" } })).toThrow( - "Client endpoint type collision: SessionFooBar", - ) + expect(() => compileContract(source)).toThrow("Client endpoint type collision: SessionFooBar") }) test("rejects ambiguous and prototype-mutating nested path segments", () => { - const source = api(HttpApiEndpoint.get("get", "/session", { success: Schema.String })) + const source = api( + HttpApiEndpoint.get("get", "/session", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "session.__proto__.get" }), + ), + ) - expect(() => compileContract(source, { endpointNames: { get: ["a.b", "get"] } })).toThrow( - "Nested client endpoint path segments cannot contain dots", - ) - expect(() => compileContract(source, { endpointNames: { get: ["__proto__", "get"] } })).toThrow( - "Client endpoint path cannot contain __proto__", - ) + expect(() => compileContract(source)).toThrow("Client endpoint path cannot contain __proto__") }) test("rejects normalized group, operation-key, and group prototype collisions", () => { @@ -324,20 +336,38 @@ describe("HttpApiCodegen.generate", () => { expect(() => compileContract(normalized)).toThrow("Client group type collision: FooBar") const endpointType = HttpApi.make("test") - .add(HttpApiGroup.make("foo").add(HttpApiEndpoint.get("first", "/first", { success: Schema.String }))) - .add(HttpApiGroup.make("fooBar").add(HttpApiEndpoint.get("second", "/second", { success: Schema.String }))) - expect(() => - compileContract(endpointType, { - endpointNames: { first: ["bar", "baz"], second: ["baz"] }, - }), - ).toThrow("Client endpoint type collision: FooBarBaz") + .add( + HttpApiGroup.make("foo").add( + HttpApiEndpoint.get("first", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "foo.bar.baz" }), + ), + ), + ) + .add( + HttpApiGroup.make("fooBar").add( + HttpApiEndpoint.get("second", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "fooBar.baz" }), + ), + ), + ) + expect(() => compileContract(endpointType)).toThrow("Client endpoint type collision: FooBarBaz") const operationKey = HttpApi.make("test") - .add(HttpApiGroup.make("a.b").add(HttpApiEndpoint.get("get", "/first", { success: Schema.String }))) - .add(HttpApiGroup.make("a").add(HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }))) - expect(() => compileContract(operationKey, { endpointNames: { get: "c", "b.c": ["b", "c"] } })).toThrow( - "Client operation key collision: a.b.c", - ) + .add( + HttpApiGroup.make("a.b").add( + HttpApiEndpoint.get("get", "/first", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "a.b.c" }), + ), + ), + ) + .add( + HttpApiGroup.make("a").add( + HttpApiEndpoint.get("b.c", "/second", { success: Schema.String }).annotateMerge( + OpenApi.annotations({ identifier: "a.b.c" }), + ), + ), + ) + expect(() => compileContract(operationKey)).toThrow("Client operation key collision: a.b.c") const prototype = HttpApi.make("test").add( HttpApiGroup.make("session").add(HttpApiEndpoint.get("get", "/session", { success: Schema.String })), diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d93db3e5b0..e590557044 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -14,10 +14,9 @@ "./tool": "./src/tool.ts", "./tui": "./src/tui.ts", "./v2/effect": "./src/v2/effect/index.ts", - "./v2/effect/integration": "./src/v2/effect/integration.ts", - "./v2/effect/plugin": "./src/v2/effect/plugin.ts", - "./v2/effect/tool": "./src/v2/effect/tool.ts", - "./v2/promise": "./src/v2/promise/index.ts" + "./v2/effect/*": "./src/v2/effect/*.ts", + "./v2": "./src/v2/promise/index.ts", + "./v2/*": "./src/v2/promise/*.ts" }, "files": [ "dist" diff --git a/packages/plugin/src/v2/effect/PLAN.md b/packages/plugin/src/v2/effect/PLAN.md index 71fa07bd7b..284d1ffe09 100644 --- a/packages/plugin/src/v2/effect/PLAN.md +++ b/packages/plugin/src/v2/effect/PLAN.md @@ -176,7 +176,7 @@ Both use the same low-level scoped registration registry, but consumers invoke t ```ts ctx.tool.transform(...) // replayed to build effective tool registry state -ctx.tool.hook(...) // invoked at a live tool operation boundary +ctx.tool.hook(...) // invoked at a live tool operation boundary ``` The shared low-level machinery owns registration order, scope cleanup, disposal, and snapshots. Each domain owns when its transforms or runtime hooks execute. diff --git a/packages/plugin/src/v2/effect/README.md b/packages/plugin/src/v2/effect/README.md index 3da1d7b566..f2526ec860 100644 --- a/packages/plugin/src/v2/effect/README.md +++ b/packages/plugin/src/v2/effect/README.md @@ -5,15 +5,13 @@ The Effect plugin API grants plugins two in-process capabilities: - `hook` installs behavior at an OpenCode extension point. - `reload` reruns every transform hook for a stateful domain. -The public server client will be exposed separately. It is intentionally not part of `PluginContext` yet. - ## Defining A Plugin ```ts -import { define } from "@opencode-ai/plugin/v2/effect" +import { Plugin } from "@opencode-ai/plugin/v2/effect" import { Effect } from "effect" -export const Plugin = define({ +export default Plugin.define({ id: "example", effect: Effect.fn(function* (ctx) { yield* ctx.catalog.transform((catalog) => { @@ -25,7 +23,7 @@ export const Plugin = define({ }) ``` -Plugin setup registers hooks imperatively. It does not return a hook object. +Plugin setup registers hooks imperatively through each domain's `hook` method. Configuration supplied for the plugin is available as `ctx.options`. @@ -64,7 +62,8 @@ Runtime hooks intercept live operations rather than rebuilding domain state: ```ts yield * - ctx.aisdk.sdk( + ctx.aisdk.hook( + "sdk", Effect.fn(function* (event) { if (event.package !== "@ai-sdk/xai") return const mod = yield* Effect.promise(() => import("@ai-sdk/xai")) @@ -73,7 +72,7 @@ yield * ) yield * - ctx.aisdk.language((event) => { + ctx.aisdk.hook("language", (event) => { if (event.model.providerID !== "xai") return event.language = event.sdk.responses(event.model.api.id) }) @@ -81,6 +80,16 @@ yield * Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks. +Session request context is mutable immediately before provider dispatch: + +```ts +yield * + ctx.session.hook("request", (event) => { + event.tools.read.description = "Read a file using narrow line ranges." + delete event.tools.write + }) +``` + ## Reloading A Domain When data captured by a transform changes, reload the affected domain: diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index aadacf99d5..80932c5e9e 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -1,7 +1,7 @@ import type { AgentApi } from "@opencode-ai/client/effect/api" import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface AgentDraft { list(): readonly AgentV2Info[] @@ -11,7 +11,7 @@ export interface AgentDraft { remove(id: string): void } -export interface AgentHooks extends AgentApi { - readonly transform: TransformHook +export interface AgentDomain extends AgentApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/aisdk.ts b/packages/plugin/src/v2/effect/aisdk.ts index 2934edc27d..9539e7b987 100644 --- a/packages/plugin/src/v2/effect/aisdk.ts +++ b/packages/plugin/src/v2/effect/aisdk.ts @@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { Model } from "@opencode-ai/schema/model" import type { Hooks } from "./registration.js" -export type AISDKHooks = Hooks<{ +export interface AISDKHooks { sdk: { readonly model: Model.Info readonly package: string @@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{ readonly options: Record language?: LanguageModelV3 } -}> +} + +export interface AISDKDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 4cc5e55227..0792d5f39c 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,7 +1,7 @@ import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" import type { CatalogApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface CatalogProviderRecord { readonly provider: ProviderV2Info @@ -26,7 +26,7 @@ export interface CatalogDraft { } } -export interface CatalogHooks extends CatalogApi { - readonly transform: TransformHook +export interface CatalogDomain extends CatalogApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts index 2faaa91fa9..bd9ebfa919 100644 --- a/packages/plugin/src/v2/effect/command.ts +++ b/packages/plugin/src/v2/effect/command.ts @@ -1,7 +1,7 @@ import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" import type { CommandApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface CommandDraft { list(): readonly CommandV2Info[] @@ -10,7 +10,7 @@ export interface CommandDraft { remove(name: string): void } -export interface CommandHooks extends CommandApi { - readonly transform: TransformHook +export interface CommandDomain extends CommandApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/context.ts b/packages/plugin/src/v2/effect/context.ts deleted file mode 100644 index 219dc16581..0000000000 --- a/packages/plugin/src/v2/effect/context.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { PluginOptions } from "../options.js" -import type { AgentHooks } from "./agent.js" -import type { AISDKHooks } from "./aisdk.js" -import type { CatalogHooks } from "./catalog.js" -import type { CommandHooks } from "./command.js" -import type { EventHooks } from "./event.js" -import type { IntegrationHooks } from "./integration.js" -import type { PluginDomain } from "./plugin.js" -import type { ReferenceHooks } from "./reference.js" -import type { SkillHooks } from "./skill.js" -import type { ToolDomain } from "./tool.js" -import type { SessionHooks } from "./runtime.js" - -export interface PluginContext { - readonly options: PluginOptions - readonly agent: AgentHooks - readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks - readonly command: CommandHooks - readonly event: EventHooks - readonly integration: IntegrationHooks - readonly plugin: PluginDomain - readonly reference: ReferenceHooks - readonly skill: SkillHooks - readonly tool: ToolDomain - readonly session: SessionHooks -} diff --git a/packages/plugin/src/v2/effect/event.ts b/packages/plugin/src/v2/effect/event.ts index 49ad375d67..283d4109f0 100644 --- a/packages/plugin/src/v2/effect/event.ts +++ b/packages/plugin/src/v2/effect/event.ts @@ -1,3 +1,3 @@ import type { EventApi } from "@opencode-ai/client/effect/api" -export interface EventHooks extends Pick, "subscribe"> {} +export interface EventDomain extends Pick, "subscribe"> {} diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 1332ce2752..e00e302b7a 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -1,17 +1,4 @@ -export type { PluginContext } from "./context.js" -export { define } from "./plugin.js" -export type { Plugin, PluginDomain } from "./plugin.js" -export type { AgentDraft, AgentHooks } from "./agent.js" -export type { AISDKHooks } from "./aisdk.js" -export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" -export type { CommandDraft, CommandHooks } from "./command.js" -export type { EventHooks } from "./event.js" -export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" -export type { ReferenceDraft, ReferenceHooks } from "./reference.js" -export type { SkillDraft, SkillHooks } from "./skill.js" -export * as Tool from "./tool.js" -export type { ToolDomain, ToolDraft, ToolExecuteBeforeEvent, ToolExecuteAfterEvent } from "./tool.js" -export type { SessionHooks } from "./runtime.js" +export * as Plugin from "./plugin.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" diff --git a/packages/plugin/src/v2/effect/integration.ts b/packages/plugin/src/v2/effect/integration.ts index 1433786634..7af3542b6c 100644 --- a/packages/plugin/src/v2/effect/integration.ts +++ b/packages/plugin/src/v2/effect/integration.ts @@ -11,7 +11,7 @@ import type { } from "@opencode-ai/sdk/v2/types" import type { IntegrationApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type IntegrationOAuthAuthorization = { readonly url: string @@ -56,8 +56,8 @@ export interface IntegrationDraft { } } -export interface IntegrationHooks extends IntegrationApi { - readonly transform: TransformHook +export interface IntegrationDomain extends IntegrationApi { + readonly transform: Transform readonly reload: () => Effect.Effect readonly connection: { readonly active: (integrationID: string) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/plugin.ts b/packages/plugin/src/v2/effect/plugin.ts index 66f57caf60..3b0b6eb918 100644 --- a/packages/plugin/src/v2/effect/plugin.ts +++ b/packages/plugin/src/v2/effect/plugin.ts @@ -1,14 +1,37 @@ import type { PluginApi } from "@opencode-ai/client/effect/api" import type { Effect, Scope } from "effect" -import type { PluginContext } from "./context.js" +import type { PluginOptions } from "../options.js" +import type { AgentDomain } from "./agent.js" +import type { AISDKDomain } from "./aisdk.js" +import type { CatalogDomain } from "./catalog.js" +import type { CommandDomain } from "./command.js" +import type { EventDomain } from "./event.js" +import type { IntegrationDomain } from "./integration.js" +import type { ReferenceDomain } from "./reference.js" +import type { SessionDomain } from "./session.js" +import type { SkillDomain } from "./skill.js" +import type { ToolDomain } from "./tool.js" + +export interface Context { + readonly options: PluginOptions + readonly agent: AgentDomain + readonly aisdk: AISDKDomain + readonly catalog: CatalogDomain + readonly command: CommandDomain + readonly event: EventDomain + readonly integration: IntegrationDomain + readonly plugin: PluginApi + readonly reference: ReferenceDomain + readonly session: SessionDomain + readonly skill: SkillDomain + readonly tool: ToolDomain +} export interface Plugin { readonly id: string - readonly effect: (context: PluginContext) => Effect.Effect + readonly effect: (context: Context) => Effect.Effect } export function define(plugin: Plugin) { return plugin } - -export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/effect/reference.ts b/packages/plugin/src/v2/effect/reference.ts index 1216d3c0b8..085ae0de5e 100644 --- a/packages/plugin/src/v2/effect/reference.ts +++ b/packages/plugin/src/v2/effect/reference.ts @@ -1,7 +1,7 @@ import type { ReferenceGitSource, ReferenceLocalSource } from "@opencode-ai/sdk/v2/types" import type { ReferenceApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface ReferenceDraft { add(name: string, source: ReferenceLocalSource | ReferenceGitSource): void @@ -9,7 +9,7 @@ export interface ReferenceDraft { list(): readonly (readonly [string, ReferenceLocalSource | ReferenceGitSource])[] } -export interface ReferenceHooks extends ReferenceApi { - readonly transform: TransformHook +export interface ReferenceDomain extends ReferenceApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/registration.ts b/packages/plugin/src/v2/effect/registration.ts index 1ddb9e3489..916b034a73 100644 --- a/packages/plugin/src/v2/effect/registration.ts +++ b/packages/plugin/src/v2/effect/registration.ts @@ -4,10 +4,9 @@ export interface Registration { readonly dispose: Effect.Effect } -export type Hooks = { - readonly [Name in keyof Spec]: ( - callback: (input: Spec[Name]) => Effect.Effect | void, - ) => Effect.Effect -} +export type Hooks = ( + name: Name, + callback: (input: Spec[Name]) => Effect.Effect, +) => Effect.Effect -export type TransformHook = (callback: (input: Input) => void) => Effect.Effect +export type Transform = (callback: (input: Input) => void) => Effect.Effect diff --git a/packages/plugin/src/v2/effect/runtime.ts b/packages/plugin/src/v2/effect/runtime.ts deleted file mode 100644 index 0358330210..0000000000 --- a/packages/plugin/src/v2/effect/runtime.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { SessionApi } from "@opencode-ai/client/effect/api" - -export interface SessionHooks - extends Pick, "create" | "get" | "prompt" | "command" | "interrupt"> {} diff --git a/packages/plugin/src/v2/effect/session.ts b/packages/plugin/src/v2/effect/session.ts new file mode 100644 index 0000000000..00ee8b9125 --- /dev/null +++ b/packages/plugin/src/v2/effect/session.ts @@ -0,0 +1,25 @@ +import type { SessionApi } from "@opencode-ai/client/effect/api" +import type { Message, SystemPart } from "@opencode-ai/llm" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" + +export interface SessionRequestBeforeEvent { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly request: SessionRequestBeforeEvent +} + +export interface SessionDomain + extends Pick, "create" | "get" | "prompt" | "command" | "interrupt"> { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts index b01b81bc3b..4437e846e3 100644 --- a/packages/plugin/src/v2/effect/skill.ts +++ b/packages/plugin/src/v2/effect/skill.ts @@ -1,14 +1,14 @@ import type { SkillV2Source } from "@opencode-ai/sdk/v2/types" import type { SkillApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export interface SkillDraft { source(source: SkillV2Source): void list(): readonly SkillV2Source[] } -export interface SkillHooks extends SkillApi { - readonly transform: TransformHook +export interface SkillDomain extends SkillApi { + readonly transform: Transform readonly reload: () => Effect.Effect } diff --git a/packages/plugin/src/v2/effect/tool.ts b/packages/plugin/src/v2/effect/tool.ts index bb5931af16..db22b18b4f 100644 --- a/packages/plugin/src/v2/effect/tool.ts +++ b/packages/plugin/src/v2/effect/tool.ts @@ -5,7 +5,7 @@ import { Agent } from "@opencode-ai/schema/agent" import { Session } from "@opencode-ai/schema/session" import { SessionMessage } from "@opencode-ai/schema/session-message" import { Effect, JsonSchema, Schema, type Scope } from "effect" -import type { Hooks } from "./registration.js" +import type { Hooks, Transform } from "./registration.js" export interface Context { readonly sessionID: Session.ID @@ -253,7 +253,12 @@ export interface ToolDraft { add(name: string, tool: AnyTool, options?: RegisterOptions): void } -export interface ToolDomain { - readonly transform: (callback: (draft: ToolDraft) => void) => Effect.Effect - readonly execute: Hooks<{ before: ToolExecuteBeforeEvent; after: ToolExecuteAfterEvent }> +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks } diff --git a/packages/plugin/src/v2/promise/README.md b/packages/plugin/src/v2/promise/README.md index e91b93fbdf..3e96cefc27 100644 --- a/packages/plugin/src/v2/promise/README.md +++ b/packages/plugin/src/v2/promise/README.md @@ -1,6 +1,6 @@ # OpenCode V2 Promise Plugin API -The Promise plugin API is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities: +The Promise plugin API at `@opencode-ai/plugin/v2` is the async/await equivalent of `@opencode-ai/plugin/v2/effect`. It grants plugins the same two in-process capabilities: - `hook` installs behavior at an OpenCode extension point. - `reload` reruns every transform hook for a stateful domain. @@ -10,9 +10,9 @@ The only difference from the Effect API is the async boundary: hook callbacks, h ## Defining A Plugin ```ts -import { define } from "@opencode-ai/plugin/v2/promise" +import { Plugin } from "@opencode-ai/plugin/v2" -export const Plugin = define({ +export default Plugin.define({ id: "example", setup: async (ctx) => { await ctx.catalog.transform((catalog) => { @@ -24,7 +24,7 @@ export const Plugin = define({ }) ``` -Plugin setup registers hooks imperatively. It does not return a hook object. +Plugin setup registers hooks imperatively through each domain's `hook` method. Configuration supplied for the plugin is available as `ctx.options`. @@ -64,18 +64,43 @@ ctx.skill.transform Runtime hooks intercept live operations: ```ts -await ctx.aisdk.sdk(async (event) => { +await ctx.aisdk.hook("sdk", async (event) => { if (event.package !== "@ai-sdk/xai") return const mod = await import("@ai-sdk/xai") event.sdk = mod.createXai(event.options) }) -await ctx.aisdk.language((event) => { +await ctx.aisdk.hook("language", (event) => { if (event.model.providerID !== "xai") return event.language = event.sdk.responses(event.model.api.id) }) ``` +Session request context is mutable immediately before provider dispatch: + +```ts +await ctx.session.hook("request", (event) => { + event.tools.read.description = "Read a file using narrow line ranges." + delete event.tools.write +}) +``` + +Promise tools use the same schemas and registration model as Effect tools, with async executors: + +```ts +import { Schema } from "effect" +import { Tool } from "@opencode-ai/plugin/v2/tool" + +const echo = Tool.make({ + description: "Echo text", + input: Schema.Struct({ text: Schema.String }), + output: Schema.Struct({ text: Schema.String }), + execute: async ({ text }) => ({ text }), +}) + +await ctx.tool.transform((tools) => tools.add("echo", echo)) +``` + ## Reloading A Domain When data captured by a transform changes, reload the affected domain: diff --git a/packages/plugin/src/v2/promise/agent.ts b/packages/plugin/src/v2/promise/agent.ts index d9313838cb..bfffdebb4e 100644 --- a/packages/plugin/src/v2/promise/agent.ts +++ b/packages/plugin/src/v2/promise/agent.ts @@ -1,10 +1,10 @@ import type { AgentApi } from "@opencode-ai/client/promise/api" import type { AgentDraft } from "../effect/agent.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { AgentDraft } -export interface AgentHooks extends AgentApi { - readonly transform: TransformHook +export interface AgentDomain extends AgentApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/aisdk.ts b/packages/plugin/src/v2/promise/aisdk.ts index 2934edc27d..9539e7b987 100644 --- a/packages/plugin/src/v2/promise/aisdk.ts +++ b/packages/plugin/src/v2/promise/aisdk.ts @@ -2,7 +2,7 @@ import type { LanguageModelV3 } from "@ai-sdk/provider" import type { Model } from "@opencode-ai/schema/model" import type { Hooks } from "./registration.js" -export type AISDKHooks = Hooks<{ +export interface AISDKHooks { sdk: { readonly model: Model.Info readonly package: string @@ -15,4 +15,8 @@ export type AISDKHooks = Hooks<{ readonly options: Record language?: LanguageModelV3 } -}> +} + +export interface AISDKDomain { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/catalog.ts b/packages/plugin/src/v2/promise/catalog.ts index f6b0f649f8..e0a51f5e52 100644 --- a/packages/plugin/src/v2/promise/catalog.ts +++ b/packages/plugin/src/v2/promise/catalog.ts @@ -1,10 +1,10 @@ import type { CatalogApi } from "@opencode-ai/client/promise/api" import type { CatalogDraft, CatalogProviderRecord } from "../effect/catalog.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { CatalogDraft, CatalogProviderRecord } -export interface CatalogHooks extends CatalogApi { - readonly transform: TransformHook +export interface CatalogDomain extends CatalogApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/command.ts b/packages/plugin/src/v2/promise/command.ts index d042259c47..2c675e2856 100644 --- a/packages/plugin/src/v2/promise/command.ts +++ b/packages/plugin/src/v2/promise/command.ts @@ -1,10 +1,10 @@ import type { CommandApi } from "@opencode-ai/client/promise/api" import type { CommandDraft } from "../effect/command.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { CommandDraft } -export interface CommandHooks extends CommandApi { - readonly transform: TransformHook +export interface CommandDomain extends CommandApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/context.ts b/packages/plugin/src/v2/promise/context.ts deleted file mode 100644 index 5e67e44961..0000000000 --- a/packages/plugin/src/v2/promise/context.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { PluginOptions } from "../options.js" -import type { AgentHooks } from "./agent.js" -import type { AISDKHooks } from "./aisdk.js" -import type { CatalogHooks } from "./catalog.js" -import type { CommandHooks } from "./command.js" -import type { EventHooks } from "./event.js" -import type { IntegrationHooks } from "./integration.js" -import type { PluginDomain } from "./plugin.js" -import type { ReferenceHooks } from "./reference.js" -import type { SessionHooks } from "./runtime.js" -import type { SkillHooks } from "./skill.js" - -export interface PluginContext { - readonly options: PluginOptions - readonly agent: AgentHooks - readonly aisdk: AISDKHooks - readonly catalog: CatalogHooks - readonly command: CommandHooks - readonly event: EventHooks - readonly integration: IntegrationHooks - readonly plugin: PluginDomain - readonly reference: ReferenceHooks - readonly session: SessionHooks - readonly skill: SkillHooks -} diff --git a/packages/plugin/src/v2/promise/event.ts b/packages/plugin/src/v2/promise/event.ts index 5330f70c7c..344f5d6f30 100644 --- a/packages/plugin/src/v2/promise/event.ts +++ b/packages/plugin/src/v2/promise/event.ts @@ -1,3 +1,3 @@ import type { EventApi } from "@opencode-ai/client/promise/api" -export interface EventHooks extends Pick {} +export interface EventDomain extends Pick {} diff --git a/packages/plugin/src/v2/promise/index.ts b/packages/plugin/src/v2/promise/index.ts index 4f5d5754ea..ae8e6d18a1 100644 --- a/packages/plugin/src/v2/promise/index.ts +++ b/packages/plugin/src/v2/promise/index.ts @@ -1,16 +1,5 @@ -export type { PluginContext } from "./context.js" export type { PluginOptions } from "../options.js" -export { define } from "./plugin.js" -export type { Plugin, PluginDomain } from "./plugin.js" -export type { AgentDraft, AgentHooks } from "./agent.js" -export type { AISDKHooks } from "./aisdk.js" -export type { CatalogDraft, CatalogHooks, CatalogProviderRecord } from "./catalog.js" -export type { CommandDraft, CommandHooks } from "./command.js" -export type { EventHooks } from "./event.js" -export type { IntegrationDraft, IntegrationHooks, IntegrationMethodRegistration } from "./integration.js" -export type { ReferenceDraft, ReferenceHooks } from "./reference.js" -export type { SessionHooks } from "./runtime.js" -export type { SkillDraft, SkillHooks } from "./skill.js" +export * as Plugin from "./plugin.js" export { Agent } from "@opencode-ai/schema/agent" export { Command } from "@opencode-ai/schema/command" diff --git a/packages/plugin/src/v2/promise/integration.ts b/packages/plugin/src/v2/promise/integration.ts index bd133e889e..76e5a99eb5 100644 --- a/packages/plugin/src/v2/promise/integration.ts +++ b/packages/plugin/src/v2/promise/integration.ts @@ -1,12 +1,12 @@ import type { IntegrationApi } from "@opencode-ai/client/promise/api" import type { IntegrationDraft, IntegrationMethodRegistration } from "../effect/integration.js" import type { CredentialValue } from "@opencode-ai/sdk/v2/types" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { IntegrationDraft, IntegrationMethodRegistration } -export interface IntegrationHooks extends IntegrationApi { - readonly transform: TransformHook +export interface IntegrationDomain extends IntegrationApi { + readonly transform: Transform readonly reload: () => Promise readonly connection: { readonly active: (integrationID: string) => Promise diff --git a/packages/plugin/src/v2/promise/plugin.ts b/packages/plugin/src/v2/promise/plugin.ts index eb91b9df53..37bce3cc03 100644 --- a/packages/plugin/src/v2/promise/plugin.ts +++ b/packages/plugin/src/v2/promise/plugin.ts @@ -1,13 +1,36 @@ import type { PluginApi } from "@opencode-ai/client/promise/api" -import type { PluginContext } from "./context.js" +import type { PluginOptions } from "../options.js" +import type { AgentDomain } from "./agent.js" +import type { AISDKDomain } from "./aisdk.js" +import type { CatalogDomain } from "./catalog.js" +import type { CommandDomain } from "./command.js" +import type { EventDomain } from "./event.js" +import type { IntegrationDomain } from "./integration.js" +import type { ReferenceDomain } from "./reference.js" +import type { SessionDomain } from "./session.js" +import type { SkillDomain } from "./skill.js" +import type { ToolDomain } from "./tool.js" + +export interface Context { + readonly options: PluginOptions + readonly agent: AgentDomain + readonly aisdk: AISDKDomain + readonly catalog: CatalogDomain + readonly command: CommandDomain + readonly event: EventDomain + readonly integration: IntegrationDomain + readonly plugin: PluginApi + readonly reference: ReferenceDomain + readonly session: SessionDomain + readonly skill: SkillDomain + readonly tool: ToolDomain +} export interface Plugin { readonly id: string - readonly setup: (context: PluginContext) => Promise | void + readonly setup: (context: Context) => Promise | void } export function define(plugin: Plugin) { return plugin } - -export interface PluginDomain extends PluginApi {} diff --git a/packages/plugin/src/v2/promise/reference.ts b/packages/plugin/src/v2/promise/reference.ts index 66bd5b4874..05542b2baf 100644 --- a/packages/plugin/src/v2/promise/reference.ts +++ b/packages/plugin/src/v2/promise/reference.ts @@ -1,10 +1,10 @@ import type { ReferenceApi } from "@opencode-ai/client/promise/api" import type { ReferenceDraft } from "../effect/reference.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { ReferenceDraft } -export interface ReferenceHooks extends ReferenceApi { - readonly transform: TransformHook +export interface ReferenceDomain extends ReferenceApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/registration.ts b/packages/plugin/src/v2/promise/registration.ts index 76be0982b8..0537c1b98e 100644 --- a/packages/plugin/src/v2/promise/registration.ts +++ b/packages/plugin/src/v2/promise/registration.ts @@ -2,8 +2,9 @@ export interface Registration { readonly dispose: () => Promise } -export type Hooks = { - readonly [Name in keyof Spec]: (callback: (input: Spec[Name]) => Promise | void) => Promise -} +export type Hooks = ( + name: Name, + callback: (input: Spec[Name]) => Promise | void, +) => Promise -export type TransformHook = (callback: (input: Input) => void) => Promise +export type Transform = (callback: (input: Input) => void) => Promise diff --git a/packages/plugin/src/v2/promise/runtime.ts b/packages/plugin/src/v2/promise/runtime.ts deleted file mode 100644 index b89b6e8abf..0000000000 --- a/packages/plugin/src/v2/promise/runtime.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { SessionApi } from "@opencode-ai/client/promise/api" - -export interface SessionHooks extends Pick {} diff --git a/packages/plugin/src/v2/promise/session.ts b/packages/plugin/src/v2/promise/session.ts new file mode 100644 index 0000000000..41bf598f46 --- /dev/null +++ b/packages/plugin/src/v2/promise/session.ts @@ -0,0 +1,24 @@ +import type { SessionApi } from "@opencode-ai/client/promise/api" +import type { Message, SystemPart } from "@opencode-ai/llm" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Model } from "@opencode-ai/schema/model" +import type { Session } from "@opencode-ai/schema/session" +import type { JsonSchema } from "effect" +import type { Hooks } from "./registration.js" + +export interface SessionRequestBeforeEvent { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly model: Model.Ref + system: Array + messages: Array + tools: Record +} + +export interface SessionHooks { + readonly request: SessionRequestBeforeEvent +} + +export interface SessionDomain extends Pick { + readonly hook: Hooks +} diff --git a/packages/plugin/src/v2/promise/skill.ts b/packages/plugin/src/v2/promise/skill.ts index a1e62fc544..cbc6459a33 100644 --- a/packages/plugin/src/v2/promise/skill.ts +++ b/packages/plugin/src/v2/promise/skill.ts @@ -1,10 +1,10 @@ import type { SkillApi } from "@opencode-ai/client/promise/api" import type { SkillDraft } from "../effect/skill.js" -import type { TransformHook } from "./registration.js" +import type { Transform } from "./registration.js" export type { SkillDraft } -export interface SkillHooks extends SkillApi { - readonly transform: TransformHook +export interface SkillDomain extends SkillApi { + readonly transform: Transform readonly reload: () => Promise } diff --git a/packages/plugin/src/v2/promise/tool.ts b/packages/plugin/src/v2/promise/tool.ts new file mode 100644 index 0000000000..da4ee1b200 --- /dev/null +++ b/packages/plugin/src/v2/promise/tool.ts @@ -0,0 +1,110 @@ +export * as Tool from "./tool.js" + +import { Tool } from "../effect/tool.js" +import type { ToolOutput, ToolResultValue } from "@opencode-ai/llm" +import type { Agent } from "@opencode-ai/schema/agent" +import type { Session } from "@opencode-ai/schema/session" +import type { SessionMessage } from "@opencode-ai/schema/session-message" +import { Effect, type JsonSchema, type Schema } from "effect" +import type { Hooks, Transform } from "./registration.js" + +export type Context = Tool.Context +export type SchemaType = Tool.SchemaType +export type Definition, Output extends SchemaType> = Tool.Definition +export type AnyTool = Tool.AnyTool +export const Failure = Tool.Failure +export type Failure = Tool.Failure +export const RegistrationError = Tool.RegistrationError +export type RegistrationError = Tool.RegistrationError +export type Content = Tool.Content +export type DynamicOutput = Tool.DynamicOutput + +type Config< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +> = { + readonly description: string + readonly input: Input + readonly output: Output + readonly structured?: Structured + readonly toStructuredOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => Schema.Schema.Type + readonly execute: ( + input: Schema.Schema.Type, + context: Context, + ) => Promise> + readonly toModelOutput?: (input: { + readonly input: Schema.Schema.Type + readonly output: Output["Encoded"] + }) => ReadonlyArray +} + +type DynamicConfig = { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema + readonly execute: (input: unknown, context: Context) => Promise +} + +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition +export function make(config: DynamicConfig): AnyTool +export function make(config: Config | DynamicConfig): AnyTool { + if ("jsonSchema" in config) + return Tool.make({ + ...config, + execute: (input, context) => Effect.promise(() => config.execute(input, context)), + }) + return Tool.make({ + ...config, + execute: (input, context) => Effect.promise(() => config.execute(input, context)), + }) +} + +export const withPermission = Tool.withPermission + +export interface ToolExecuteBeforeEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + input: unknown +} + +export interface ToolExecuteAfterEvent { + readonly tool: string + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly assistantMessageID: SessionMessage.ID + readonly toolCallID: string + readonly input: unknown + result: ToolResultValue + output?: ToolOutput + outputPaths?: ReadonlyArray +} + +export interface RegisterOptions { + readonly group?: string + readonly deferred?: boolean +} + +export interface ToolDraft { + add(name: string, tool: AnyTool, options?: RegisterOptions): void +} + +export interface ToolHooks { + readonly "execute.before": ToolExecuteBeforeEvent + readonly "execute.after": ToolExecuteAfterEvent +} + +export interface ToolDomain { + readonly transform: Transform + readonly hook: Hooks +} diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 652beb6595..03f9a34015 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -60,26 +60,5 @@ export const groupNames = { "server.vcs": "vcs", } as const -export const endpointNames = { - "debug.location.evict": "evictLocation", - "session.messages": "list", - "integration.connect.key": "connectKey", - "integration.connect.oauth": "connectOauth", - "integration.attempt.status": "attemptStatus", - "integration.attempt.complete": "attemptComplete", - "integration.attempt.cancel": "attemptCancel", - "session.instructions.entry.list": ["instructions", "entry", "list"], - "session.instructions.entry.put": ["instructions", "entry", "put"], - "session.instructions.entry.remove": ["instructions", "entry", "remove"], - "session.revert.stage": "revertStage", - "session.revert.clear": "revertClear", - "session.revert.commit": "revertCommit", - "permission.request.list": "listRequests", - "permission.saved.list": "listSaved", - "permission.saved.remove": "removeSaved", - "form.request.list": "listRequests", - "question.request.list": "listRequests", -} as const - export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"]) export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) diff --git a/packages/protocol/src/groups/debug.ts b/packages/protocol/src/groups/debug.ts index a405044105..fd7cd9def5 100644 --- a/packages/protocol/src/groups/debug.ts +++ b/packages/protocol/src/groups/debug.ts @@ -9,7 +9,7 @@ export const DebugGroup = HttpApiGroup.make("server.debug") success: Schema.Array(Location.Ref), }).annotateMerge( OpenApi.annotations({ - identifier: "v2.debug.location", + identifier: "v2.debug.location.list", summary: "List loaded locations", description: "List locations currently loaded by the server.", }), diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts index b8c387576f..cab0d8bb0f 100644 --- a/packages/protocol/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -36,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("server.message") error: [InvalidCursorError, SessionNotFoundError, UnknownError], }).annotateMerge( OpenApi.annotations({ - identifier: "v2.session.messages", + identifier: "v2.message.list", summary: "Get session messages", description: "Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", diff --git a/packages/protocol/src/groups/pty.ts b/packages/protocol/src/groups/pty.ts index 309868189f..a8fd1a3bc9 100644 --- a/packages/protocol/src/groups/pty.ts +++ b/packages/protocol/src/groups/pty.ts @@ -107,7 +107,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty") .annotateMerge(locationQueryOpenApi) .annotateMerge( OpenApi.annotations({ - identifier: "v2.pty.connectToken", + identifier: "v2.pty.connect.token", summary: "Create PTY WebSocket token", description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", }), diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 0b3b639087..9dfe7e2a14 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -139,7 +139,7 @@ it.live( yield* opencode.plugin.list({ location: ref }) yield* Deferred.await(booted).pipe(Effect.timeout("5 seconds")) - yield* opencode.debug.evictLocation({ location: ref }) + yield* opencode.debug.location.evict({ location: ref }) yield* opencode.plugin.list({ location: ref }) yield* Deferred.await(recommitted).pipe(Effect.timeout("5 seconds")) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 4101e0751f..20a4fcd803 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -134,7 +134,7 @@ if (sessionListTypesPatched === logTypesPatched) { throw new Error("Session list numeric query patch did not apply") } const sessionMessagesTypesPatched = sessionListTypesPatched.replace( - /(export type V2SessionMessagesData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/, + /(export type V2MessageListData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/, "$1number$2", ) if (sessionMessagesTypesPatched === sessionListTypesPatched) { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 239783dc87..a88ad8e008 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -276,10 +276,10 @@ import type { V2CredentialRemoveResponses, V2CredentialUpdateErrors, V2CredentialUpdateResponses, - V2DebugLocationErrors, V2DebugLocationEvictErrors, V2DebugLocationEvictResponses, - V2DebugLocationResponses, + V2DebugLocationListErrors, + V2DebugLocationListResponses, V2EventSubscribeErrors, V2EventSubscribeResponses, V2FormRequestListErrors, @@ -312,6 +312,10 @@ import type { V2LocationGetResponses, V2McpListErrors, V2McpListResponses, + V2McpResourceCatalogErrors, + V2McpResourceCatalogResponses, + V2MessageListErrors, + V2MessageListResponses, V2ModelDefaultErrors, V2ModelDefaultResponses, V2ModelListErrors, @@ -400,8 +404,8 @@ import type { V2SessionLogResponses, V2SessionMessageErrors, V2SessionMessageResponses, - V2SessionMessagesErrors, - V2SessionMessagesResponses, + V2SessionMoveErrors, + V2SessionMoveResponses, V2SessionPermissionCreateErrors, V2SessionPermissionCreateResponses, V2SessionPermissionGetErrors, @@ -5181,6 +5185,86 @@ export class Plugin extends HeyApiClient { } } +export class Switch extends HeyApiClient { + /** + * Switch session agent + * + * Switch the agent used by subsequent provider turns. + */ + public agent( + parameters: { + sessionID: string + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchAgentResponses, + V2SessionSwitchAgentErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/agent", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Switch session model + * + * Switch the model used by subsequent provider turns. + */ + public model( + parameters: { + sessionID: string + model?: ModelRef + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchModelResponses, + V2SessionSwitchModelErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/model", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Revert extends HeyApiClient { /** * Stage session revert @@ -5984,84 +6068,6 @@ export class Session3 extends HeyApiClient { }) } - /** - * Switch session agent - * - * Switch the agent used by subsequent provider turns. - */ - public switchAgent( - parameters: { - sessionID: string - agent?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "body", key: "agent" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - V2SessionSwitchAgentResponses, - V2SessionSwitchAgentErrors, - ThrowOnError - >({ - url: "/api/session/{sessionID}/agent", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Switch session model - * - * Switch the model used by subsequent provider turns. - */ - public switchModel( - parameters: { - sessionID: string - model?: ModelRef - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "body", key: "model" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - V2SessionSwitchModelResponses, - V2SessionSwitchModelErrors, - ThrowOnError - >({ - url: "/api/session/{sessionID}/model", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - /** * Rename session * @@ -6097,6 +6103,45 @@ export class Session3 extends HeyApiClient { }) } + /** + * Move session + * + * Move a session to another project directory, optionally transferring local changes. + */ + public move( + parameters: { + sessionID: string + destination?: { + directory: string + } + moveChanges?: boolean | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "destination" }, + { in: "body", key: "moveChanges" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/move", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send message * @@ -6241,6 +6286,7 @@ export class Session3 extends HeyApiClient { metadata?: { [key: string]: unknown } + resume?: boolean | null }, options?: Options, ) { @@ -6253,6 +6299,7 @@ export class Session3 extends HeyApiClient { { in: "body", key: "text" }, { in: "body", key: "description" }, { in: "body", key: "metadata" }, + { in: "body", key: "resume" }, ], }, ], @@ -6481,38 +6528,9 @@ export class Session3 extends HeyApiClient { }) } - /** - * Get session messages - * - * Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. - */ - public messages( - parameters: { - sessionID: string - limit?: number | null - order?: "asc" | "desc" | null - cursor?: string | null - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "query", key: "limit" }, - { in: "query", key: "order" }, - { in: "query", key: "cursor" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/api/session/{sessionID}/message", - ...options, - ...params, - }) + private _switch?: Switch + get switch(): Switch { + return (this._switch ??= new Switch({ client: this.client })) } private _revert?: Revert @@ -6541,6 +6559,42 @@ export class Session3 extends HeyApiClient { } } +export class Message extends HeyApiClient { + /** + * Get session messages + * + * Retrieve projected messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. + */ + public list( + parameters: { + sessionID: string + limit?: number | null + order?: "asc" | "desc" | null + cursor?: string | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "order" }, + { in: "query", key: "cursor" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", + ...options, + ...params, + }) + } +} + export class Model extends HeyApiClient { /** * List models @@ -6971,6 +7025,34 @@ export class Integration extends HeyApiClient { } } +export class Resource2 extends HeyApiClient { + /** + * List MCP resources + * + * Retrieve resources and resource templates from connected MCP servers. + */ + public catalog( + parameters?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get< + V2McpResourceCatalogResponses, + V2McpResourceCatalogErrors, + ThrowOnError + >({ + url: "/api/mcp/resource", + ...options, + ...params, + }) + } +} + export class Mcp2 extends HeyApiClient { /** * List MCP servers @@ -6993,6 +7075,11 @@ export class Mcp2 extends HeyApiClient { ...params, }) } + + private _resource?: Resource2 + get resource(): Resource2 { + return (this._resource ??= new Resource2({ client: this.client })) + } } export class Credential extends HeyApiClient { @@ -7420,6 +7507,41 @@ export class Event2 extends HeyApiClient { } } +export class Connect2 extends HeyApiClient { + /** + * Create PTY WebSocket token + * + * Create a short-lived single-use ticket for opening a PTY WebSocket connection. + */ + public token( + parameters: { + ptyID: string + location?: { + directory?: string | null + workspace?: string | null + } | null + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } +} + export class Pty2 extends HeyApiClient { /** * List PTY sessions @@ -7602,39 +7724,6 @@ export class Pty2 extends HeyApiClient { }) } - /** - * Create PTY WebSocket token - * - * Create a short-lived single-use ticket for opening a PTY WebSocket connection. - */ - public connectToken( - parameters: { - ptyID: string - location?: { - directory?: string | null - workspace?: string | null - } | null - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "ptyID" }, - { in: "query", key: "location" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/api/pty/{ptyID}/connect-token", - ...options, - ...params, - }) - } - /** * Connect to PTY session * @@ -7670,6 +7759,11 @@ export class Pty2 extends HeyApiClient { ...params, }) } + + private _connect?: Connect2 + get connect2(): Connect2 { + return (this._connect ??= new Connect2({ client: this.client })) + } } export class Shell extends HeyApiClient { @@ -8145,23 +8239,23 @@ export class Location2 extends HeyApiClient { ...params, }) } -} -export class Debug extends HeyApiClient { /** * List loaded locations * * List locations currently loaded by the server. */ - public location(options?: Options) { - return (options?.client ?? this.client).get({ + public list(options?: Options) { + return (options?.client ?? this.client).get({ url: "/api/debug/location", ...options, }) } +} +export class Debug extends HeyApiClient { private _location?: Location2 - get location2(): Location2 { + get location(): Location2 { return (this._location ??= new Location2({ client: this.client })) } } @@ -8192,6 +8286,11 @@ export class V2 extends HeyApiClient { return (this._session ??= new Session3({ client: this.client })) } + private _message?: Message + get message(): Message { + return (this._message ??= new Message({ client: this.client })) + } + private _model?: Model get model(): Model { return (this._model ??= new Model({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 68ad7c2a0e..59afb28c06 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -96,6 +96,7 @@ export type Event = | EventTuiToastShow2 | EventTuiSessionSelect2 | EventMcpToolsChanged + | EventMcpResourcesChanged | EventMcpStatusChanged | EventCommandExecuted | EventFileEdited @@ -1631,6 +1632,13 @@ export type GlobalEvent = { server: string } } + | { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } + } | { id: string type: "mcp.status.changed" @@ -3019,6 +3027,14 @@ export type ProviderNotFoundError = { message: string } +export type McpResource2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + export type FormNotFoundError = { _tag: "FormNotFoundError" id: string @@ -3182,6 +3198,7 @@ export type V2Event = | TuiToastShow | TuiSessionSelect | McpToolsChanged + | McpResourcesChanged | McpStatusChanged | CommandExecuted | FileEdited @@ -5751,6 +5768,19 @@ export type McpServer = { integrationID?: string } +export type McpResourceTemplate = { + server: string + name: string + uriTemplate: string + description?: string + mimeType?: string +} + +export type McpResourceCatalog = { + resources: Array + templates: Array +} + export type ProjectCurrent = { id: string directory: string @@ -6680,6 +6710,19 @@ export type McpToolsChanged = { } } +export type McpResourcesChanged = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRef + data: { + server: string + } +} + export type McpStatusChanged = { id: string created: number @@ -7860,6 +7903,14 @@ export type EventMcpToolsChanged = { } } +export type EventMcpResourcesChanged = { + id: string + type: "mcp.resources.changed" + properties: { + server: string + } +} + export type EventMcpStatusChanged = { id: string type: "mcp.status.changed" @@ -8698,6 +8749,7 @@ export type V2EventV2 = | InstallationUpdateAvailableV2 | VcsBranchUpdatedV2 | McpStatusChangedV2 + | McpResourcesChangedV2 | PermissionAskedV2 | PermissionRepliedV2 | QuestionAskedV2 @@ -9797,6 +9849,19 @@ export type EventLogSyncedV2 = { seq?: number } +export type McpResourceV2 = { + server: string + name: string + uri: string + description?: string + mimeType?: string +} + +export type McpResourceCatalogV2 = { + resources: Array + templates: Array +} + export type ProjectTimeV2 = { created: number updated: number @@ -10749,6 +10814,19 @@ export type McpStatusChangedV2 = { } } +export type McpResourcesChangedV2 = { + id: string + created: number + metadata?: { + [key: string]: unknown + } + type: "mcp.resources.changed" + location?: LocationRefV2 + data: { + server: string + } +} + export type PermissionAskedV2 = { id: string created: number @@ -15505,6 +15583,46 @@ export type V2SessionRenameResponses = { export type V2SessionRenameResponse = V2SessionRenameResponses[keyof V2SessionRenameResponses] +export type V2SessionMoveData = { + body: { + destination: { + directory: string + } + moveChanges?: boolean | null + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/move" +} + +export type V2SessionMoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError1 | InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionMoveError = V2SessionMoveErrors[keyof V2SessionMoveErrors] + +export type V2SessionMoveResponses = { + /** + * + */ + 204: void +} + +export type V2SessionMoveResponse = V2SessionMoveResponses[keyof V2SessionMoveResponses] + export type V2SessionPromptData = { body: { id?: string | null @@ -15652,6 +15770,7 @@ export type V2SessionSyntheticData = { metadata?: { [key: string]: unknown } + resume?: boolean | null } path: { sessionID: string @@ -16244,7 +16363,7 @@ export type V2SessionMessageResponses = { export type V2SessionMessageResponse = V2SessionMessageResponses[keyof V2SessionMessageResponses] -export type V2SessionMessagesData = { +export type V2MessageListData = { body?: never path: { sessionID: string @@ -16263,7 +16382,7 @@ export type V2SessionMessagesData = { url: "/api/session/{sessionID}/message" } -export type V2SessionMessagesErrors = { +export type V2MessageListErrors = { /** * InvalidCursorError | InvalidRequestError */ @@ -16282,16 +16401,16 @@ export type V2SessionMessagesErrors = { 500: UnknownErrorV2 } -export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] +export type V2MessageListError = V2MessageListErrors[keyof V2MessageListErrors] -export type V2SessionMessagesResponses = { +export type V2MessageListResponses = { /** * SessionMessagesResponse */ 200: SessionMessagesResponseV2 } -export type V2SessionMessagesResponse = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] +export type V2MessageListResponse = V2MessageListResponses[keyof V2MessageListResponses] export type V2ModelListData = { body?: never @@ -16819,6 +16938,43 @@ export type V2McpListResponses = { export type V2McpListResponse = V2McpListResponses[keyof V2McpListResponses] +export type V2McpResourceCatalogData = { + body?: never + path?: never + query?: { + location?: { + directory?: string | null + workspace?: string | null + } | null + } + url: "/api/mcp/resource" +} + +export type V2McpResourceCatalogErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestErrorV2 + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2McpResourceCatalogError = V2McpResourceCatalogErrors[keyof V2McpResourceCatalogErrors] + +export type V2McpResourceCatalogResponses = { + /** + * Success + */ + 200: { + location: LocationInfoV2 + data: McpResourceCatalogV2 + } +} + +export type V2McpResourceCatalogResponse = V2McpResourceCatalogResponses[keyof V2McpResourceCatalogResponses] + export type V2CredentialRemoveData = { body?: never path: { @@ -18717,14 +18873,14 @@ export type V2DebugLocationEvictResponses = { export type V2DebugLocationEvictResponse = V2DebugLocationEvictResponses[keyof V2DebugLocationEvictResponses] -export type V2DebugLocationData = { +export type V2DebugLocationListData = { body?: never path?: never query?: never url: "/api/debug/location" } -export type V2DebugLocationErrors = { +export type V2DebugLocationListErrors = { /** * InvalidRequestError */ @@ -18735,16 +18891,16 @@ export type V2DebugLocationErrors = { 401: UnauthorizedError } -export type V2DebugLocationError = V2DebugLocationErrors[keyof V2DebugLocationErrors] +export type V2DebugLocationListError = V2DebugLocationListErrors[keyof V2DebugLocationListErrors] -export type V2DebugLocationResponses = { +export type V2DebugLocationListResponses = { /** * Success */ 200: Array } -export type V2DebugLocationResponse = V2DebugLocationResponses[keyof V2DebugLocationResponses] +export type V2DebugLocationListResponse = V2DebugLocationListResponses[keyof V2DebugLocationListResponses] export type PtyConnectData = { body?: never diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 52aba6d0e3..1b962142fc 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -180,7 +180,7 @@ function KeyMethod(props: { onConfirm={(key) => { if (!key) return void sdk.api.integration - .connectKey({ + .connect.key({ integrationID: props.integration.id, location: location(data), key, @@ -219,7 +219,7 @@ function OAuthStarting(props: { onMount(() => { void sdk.api.integration - .connectOauth({ + .connect.oauth({ integrationID: props.integration.id, location: location(data), methodID: props.method.id, @@ -288,7 +288,7 @@ function OAuthAuto(props: { const poll = () => { void sdk.api.integration - .attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) }) + .attempt.status({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data if (status.status === "pending") { @@ -314,7 +314,7 @@ function OAuthAuto(props: { onCleanup(() => { if (timer) clearTimeout(timer) if (settled) return - void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -344,7 +344,7 @@ function OAuthCode(props: { onCleanup(() => { if (settled) return - void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -354,7 +354,7 @@ function OAuthCode(props: { onConfirm={(code) => { if (!code) return void sdk.api.integration - .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) + .attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true return connected(props.integration, data, dialog, toast, props.onConnected) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 1e5a9132c9..14b34f93c2 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1127,7 +1127,7 @@ export function Prompt(props: PromptProps) { }) } if (session?.revert) { - const error = await sdk.api.session.revertCommit({ sessionID }).then( + const error = await sdk.api.session.revert.commit({ sessionID }).then( () => undefined, (error) => error, ) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 7b15781ffc..22f505682d 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -919,7 +919,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.project.permission[projectID] }, async refresh(projectID: string) { - setStore("project", "permission", projectID, mutable(await sdk.api.permission.listSaved({ projectID }))) + setStore("project", "permission", projectID, mutable(await sdk.api.permission.saved.list({ projectID }))) }, }, }, @@ -1064,7 +1064,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) for (const session of response.data) registerSession(session.id) }), - sdk.api.permission.listRequests({ location: locationQuery(defaultLocation()) }).then((response) => { + sdk.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { const permissions = mutable(response.data).reduce>( (result, request) => ({ ...result, @@ -1074,7 +1074,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) setStore("session", "permission", reconcile(permissions)) }), - sdk.api.form.listRequests({ location: locationQuery(defaultLocation()) }).then((response) => { + sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => { const forms = mutable(response.data).reduce>( (result, form) => ({ ...result, diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index f31b14868a..db64c7b5da 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -24,7 +24,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) { description: "undo messages and file changes", onSelect: async (dialog) => { await sdk.api.session - .revertStage({ sessionID: props.sessionID, messageID: props.messageID }) + .revert.stage({ sessionID: props.sessionID, messageID: props.messageID }) .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) dialog.clear() }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index f76838de46..3743abed05 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -437,7 +437,7 @@ export function Session() { dialog.clear() return } - const error = await sdk.api.session.revertStage({ sessionID: route.sessionID, messageID: target }).then( + const error = await sdk.api.session.revert.stage({ sessionID: route.sessionID, messageID: target }).then( () => undefined, (error) => error, ) @@ -454,7 +454,7 @@ export function Session() { slash: { name: "redo" }, run: () => { void (async () => { - const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) @@ -1383,7 +1383,7 @@ function RevertMessage(props: { onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return void (async () => { - const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) From e1abf59c596668c7824a2eb4fad6f148c1ea7318 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 7 Jul 2026 20:03:41 -0400 Subject: [PATCH 25/34] fix(core): provide PluginHooks in plugin test layer (#35811) --- packages/core/test/plugin/fixture.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index ab5c64ae3e..ffef32738d 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -47,6 +47,7 @@ export const PluginTestLayer = AppNodeBuilder.build( CommandV2.node, Integration.node, PluginRuntime.node, + PluginHooks.node, Reference.node, SkillV2.node, ToolHooks.node, From 3379e44abb29b5668e9d1791aff865f0fd21a029 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 7 Jul 2026 21:16:00 -0400 Subject: [PATCH 26/34] refactor(core): rename ApplyPatchTool to PatchTool --- packages/core/src/plugin/internal.ts | 4 ++-- .../core/src/tool/{apply-patch.ts => patch.ts} | 6 +++--- ...tool-apply-patch.test.ts => tool-patch.test.ts} | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) rename packages/core/src/tool/{apply-patch.ts => patch.ts} (98%) rename packages/core/test/{tool-apply-patch.test.ts => tool-patch.test.ts} (98%) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 40715378e1..ad5553928a 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -31,7 +31,7 @@ import { SessionInstructions } from "../session/instructions" import { SessionTodo } from "../session/todo" import { Shell } from "../shell" import { SkillV2 } from "../skill" -import { ApplyPatchTool } from "../tool/apply-patch" +import { PatchTool } from "../tool/patch" import { EditTool } from "../tool/edit" import { GlobTool } from "../tool/glob" import { GrepTool } from "../tool/grep" @@ -127,7 +127,7 @@ const pre = [ SkillPlugin.Plugin, ModelsDevPlugin, ...ProviderPlugins, - ApplyPatchTool.Plugin, + PatchTool.Plugin, EditTool.Plugin, GlobTool.Plugin, GrepTool.Plugin, diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/patch.ts similarity index 98% rename from packages/core/src/tool/apply-patch.ts rename to packages/core/src/tool/patch.ts index a1ae7dc808..e6a043a0dd 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/patch.ts @@ -1,4 +1,4 @@ -export * as ApplyPatchTool from "./apply-patch" +export * as PatchTool from "./patch" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/llm" @@ -55,8 +55,8 @@ type Prepared = }) export const Plugin = { - id: "opencode.tool.apply-patch", - effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) { + id: "opencode.tool.patch", + effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { const mutation = yield* LocationMutation.Service const files = yield* FileMutation.Service const fs = yield* FSUtil.Service diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-patch.test.ts similarity index 98% rename from packages/core/test/tool-apply-patch.test.ts rename to packages/core/test/tool-patch.test.ts index cb6745af32..43b20d0cbf 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -13,16 +13,16 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch" +import { PatchTool } from "@opencode-ai/core/tool/patch" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefinitions } from "./lib/tool" -const applyPatchToolNode = makeLocationNode({ - name: "test/apply-patch-tool-plugin", - layer: Layer.effectDiscard(registerToolPlugin(ApplyPatchTool.Plugin)), +const patchToolNode = makeLocationNode({ + name: "test/patch-tool-plugin", + layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), deps: [ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], }) @@ -116,7 +116,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, - applyPatchToolNode, + patchToolNode, ]), [ [FSUtil.node, filesystem], @@ -129,7 +129,7 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte ) } -const call = (patchText: string, id = "call-apply-patch") => ({ +const call = (patchText: string, id = "call-patch") => ({ sessionID, ...toolIdentity, call: { type: "tool-call" as const, id, name: "patch", input: { patchText } }, @@ -147,7 +147,7 @@ const exists = (target: string) => ) const it = testEffect(Layer.empty) -describe("ApplyPatchTool", () => { +describe("PatchTool", () => { it.live("registers and sequentially applies add, update, and delete hunks", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), From eb661e6e6dfb6a824e3758205b4b981332b7cf3e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 21:51:23 -0400 Subject: [PATCH 27/34] fix(core): settle unadvertised tool calls and repair session hook test contexts --- packages/core/src/session/runner/llm.ts | 17 ++++++++++++++++- packages/core/test/lib/tool.ts | 7 ++++--- packages/core/test/location-layer.test.ts | 2 ++ packages/core/test/plugin/host.ts | 4 +++- packages/core/test/tool-subagent.test.ts | 5 +++++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 23cbfa049d..3da43acd8c 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -315,10 +315,25 @@ const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return - if (!toolMaterialization || !advertisedTools.has(event.name)) { + if (!toolMaterialization) { yield* serialized( publisher.failUnsettledTools({ type: "tool.execution", + message: "Tools are disabled after the maximum agent steps", + }), + ) + return + } + // A request hook hid this registered tool from the current request. Fail only + // this call durably and continue so the model can react, instead of executing + // a tool that was not advertised. Unregistered tools flow through settle, which + // durably fails them as unknown. + if (!advertisedTools.has(event.name) && availableTools.has(event.name)) { + needsContinuation = true + yield* publish( + LLMEvent.toolError({ + id: event.id, + name: event.name, message: `Tool is not available for this request: ${event.name}`, }), ) diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index 3692e5476e..c09d1e6296 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -6,6 +6,7 @@ import { Tool } from "@opencode-ai/core/tool/tool" import { Tools } from "@opencode-ai/core/tool/tools" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { Effect, type Scope } from "effect" +import { host } from "../plugin/host" export const toolIdentity = { agent: AgentV2.ID.make("build"), @@ -48,7 +49,7 @@ export const registerToolPlugin = (plugin: { }): Effect.Effect => Effect.gen(function* () { const tools = yield* Tools.Service - const context: Pick = { + const context = host({ tool: { transform: (callback) => Effect.gen(function* () { @@ -71,8 +72,8 @@ export const registerToolPlugin = (plugin: { }), hook: () => Effect.die("registerToolPlugin does not support tool hooks"), }, - } - yield* plugin.effect(context as PluginContext) + }) + yield* plugin.effect(context) }) export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) => diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 6b7ba5654c..c538d1151f 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -290,6 +290,7 @@ describe("LocationServiceMap", () => { "edit", "glob", "grep", + "patch", "question", "read", "shell", @@ -307,6 +308,7 @@ describe("LocationServiceMap", () => { "edit", "glob", "grep", + "patch", "question", "read", "shell", diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index c5dc96c384..659ec40f86 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -83,7 +83,9 @@ export function host(overrides: Overrides = {}): PluginContext { prompt: () => Effect.die("unused session.prompt"), command: () => Effect.die("unused session.command"), interrupt: () => Effect.die("unused session.interrupt"), - hook: () => Effect.die("unused session.hook"), + // Plugins register session hooks during setup, so a bare host accepts the + // registration; the callback only runs when a test triggers the request pipeline. + hook: () => Effect.succeed({ dispose: Effect.void }), }, } } diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 9f7bf8c7b9..92dc0e833b 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -107,6 +107,11 @@ const withSubagent = (location: Location.Ref) => const locations = yield* LocationServiceMap.Service yield* AgentV2.Service.use((agents) => agents.transform((draft) => { + // The caller identity used by executeTool; subagent permission asserts against it. + draft.update(toolIdentity.agent, (agent) => { + agent.mode = "primary" + agent.permissions.push({ action: "*", resource: "*", effect: "allow" }) + }) draft.update(AgentV2.ID.make("reviewer"), (agent) => { agent.mode = "subagent" agent.model = childModel From d27746e5b37d055f650fc925e3c5ae1271a20f22 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 21:58:52 -0400 Subject: [PATCH 28/34] test(core): simplify runner test fixtures (#35832) --- packages/core/test/session-runner.test.ts | 1216 +++++---------------- 1 file changed, 291 insertions(+), 925 deletions(-) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index b98c5ef106..da8d9539c7 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -103,6 +103,20 @@ const client = Layer.succeed( generate: () => Effect.die("unused"), }), ) +const reply = { + stop: () => [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ], + text: (text: string, id: string) => fragmentFixture("text", id, [text]).completeEvents, + tool: (id: string, name: string, input: unknown) => [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id, name, input }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], +} const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }) const defaultSystem = SessionRunnerSystemPrompt.provider(model) const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route }) @@ -339,6 +353,8 @@ const it = testEffect( ) const sessionID = SessionV2.ID.make("ses_runner_test") const otherSessionID = SessionV2.ID.make("ses_runner_other") +const admit = (session: SessionV2.Interface, text: string) => + session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text }), resume: false }) const insertSession = (id: SessionV2.ID) => Effect.gen(function* () { @@ -360,6 +376,9 @@ const insertSession = (id: SessionV2.ID) => const setup = Effect.gen(function* () { const { db } = yield* Database.Service + requests.length = 0 + authorizations.length = 0 + executions.length = 0 response = [] systemBaseline = "Initial context" systemRemoved = false @@ -385,6 +404,7 @@ const setup = Effect.gen(function* () { .run() .pipe(Effect.orDie) yield* insertSession(sessionID) + return yield* SessionV2.Service }) const providerUnavailable = () => @@ -409,14 +429,9 @@ const rateLimited = (retryAfterMs?: number) => }) const setupOverflowRecovery = Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(700) }), - resume: false, - }) + const session = yield* setup + response = reply.text("Earlier answer", "text-earlier") + yield* admit(session, "Earlier question ".repeat(700)) yield* session.resume(sessionID) currentModel = recoveryModel requests.length = 0 @@ -467,6 +482,9 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess ) }) +const hostedCall = (id: string, query: string) => + LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true }) + const requireAssistant = (messages: readonly SessionMessage.Message[]) => { const assistant = messages.find((message) => message.type === "assistant") if (!assistant) throw new Error("Assistant message missing") @@ -577,14 +595,12 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string const verifyEphemeralDeltas = (kind: FragmentKind) => Effect.gen(function* () { - yield* setup - requests.length = 0 - const session = yield* SessionV2.Service + const session = yield* setup const prompt = `Stream ${kind}` const chunks = Array.from({ length: 32 }, (_, index) => `${index},`) const fixture = fragmentFixture(kind, fragmentID(kind, "many"), chunks) const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) + yield* admit(session, prompt) const events = yield* EventV2.Service const live = yield* events.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow @@ -610,13 +626,11 @@ const verifyEphemeralDeltas = (kind: FragmentKind) => const verifyPartialFlushOnFailure = (kind: FragmentKind) => Effect.gen(function* () { - yield* setup - requests.length = 0 - const session = yield* SessionV2.Service + const session = yield* setup const prompt = `Fail after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"]) const failure = providerUnavailable() - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) + yield* admit(session, prompt) responseStream = Stream.concat(Stream.fromIterable(fixture.partialEvents), Stream.fail(failure)) expect(yield* session.resume(sessionID).pipe(Effect.flip)).toBe(failure) @@ -634,12 +648,11 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => const verifyPartialFlushOnInterruption = (kind: FragmentKind) => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const prompt = `Interrupt after ${kind}` const fixture = fragmentFixture(kind, fragmentID(kind, "interrupted"), ["Partial"]) const streamed = yield* Deferred.make() - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: prompt }), resume: false }) + yield* admit(session, prompt) responseStream = Stream.concat( Stream.fromIterable(fixture.partialEvents), Stream.fromEffect(Deferred.succeed(streamed, undefined)).pipe(Stream.flatMap(() => Stream.never)), @@ -667,9 +680,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => describe("SessionRunnerLLM", () => { it.effect("advertises and executes a location registered tool", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const registry = yield* ToolRegistry.Service - const session = yield* SessionV2.Service const contexts: Tool.Context[] = [] yield* registry.register({ location_context: Tool.make({ @@ -683,20 +695,8 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Use application context" }), - resume: false, - }) - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-location", name: "location_context", input: { query: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] + yield* admit(session, "Use application context") + responses = [reply.tool("call-location", "location_context", { query: "hello" }), []] yield* session.resume(sessionID) @@ -727,13 +727,7 @@ describe("SessionRunnerLLM", () => { it.effect("starts a real runner turn after default prompt recording", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined - response = [] + const session = yield* setup const message = yield* session.prompt({ sessionID, @@ -750,16 +744,10 @@ describe("SessionRunnerLLM", () => { it.effect("streams one request with registry definitions from chronological V2 user history", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + const session = yield* setup + yield* admit(session, "First") + yield* admit(session, "Second") - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined - response = [] yield* session.resume(sessionID) expect(requests).toHaveLength(1) @@ -775,8 +763,7 @@ describe("SessionRunnerLLM", () => { it.effect("retries the first provider turn after system context becomes available", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service const messageID = SessionMessage.ID.create() systemUnavailable = true @@ -786,7 +773,6 @@ describe("SessionRunnerLLM", () => { prompt: PromptInput.Prompt.make({ text: "First" }), resume: false, }) - requests.length = 0 const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -813,13 +799,10 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts a source Location runner after a Session moves", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - requests.length = 0 - response = [] + yield* admit(session, "First") yield* session.resume(sessionID) yield* events.publish(SessionEvent.Moved, { @@ -834,7 +817,7 @@ describe("SessionRunnerLLM", () => { .get(), ).toBeUndefined() - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") const exit = yield* session.resume(sessionID).pipe(Effect.exit) expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true) @@ -845,11 +828,9 @@ describe("SessionRunnerLLM", () => { it.effect("copies the context checkpoint to a fork", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - response = [] + yield* admit(session, "First") yield* session.resume(sessionID) const forked = yield* session.fork({ sessionID }) @@ -874,11 +855,9 @@ describe("SessionRunnerLLM", () => { it.effect("heals an undecodable stored applied record by re-announcing context", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) - response = [] + yield* admit(session, "First") yield* session.resume(sessionID) yield* db .update(InstructionCheckpointTable) @@ -886,7 +865,7 @@ describe("SessionRunnerLLM", () => { .where(eq(InstructionCheckpointTable.session_id, sessionID)) .run() .pipe(Effect.orDie) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") requests.length = 0 yield* session.resume(sessionID) @@ -908,15 +887,12 @@ describe("SessionRunnerLLM", () => { it.effect("reuses one durable baseline after the context producer changes", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + const session = yield* setup + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -942,13 +918,11 @@ describe("SessionRunnerLLM", () => { it.effect("uses the selected model family prompt when the agent does not override it", () => Effect.gen(function* () { - yield* setup + const session = yield* setup currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-provider-prompt", ["Done"]).completeEvents + response = reply.text("Done", "text-provider-prompt") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ @@ -960,7 +934,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses the selected model family prompt when the agent system override is empty", () => Effect.gen(function* () { - yield* setup + const session = yield* setup currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route }) const agent = yield* AgentV2.Service yield* agent.transform((editor) => @@ -969,11 +943,9 @@ describe("SessionRunnerLLM", () => { agent.mode = "primary" }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-empty-agent-system", ["Done"]).completeEvents + response = reply.text("Done", "text-empty-agent-system") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([ @@ -985,7 +957,7 @@ describe("SessionRunnerLLM", () => { it.effect("includes the effective default agent system before durable context", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agent = yield* AgentV2.Service yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { @@ -993,11 +965,9 @@ describe("SessionRunnerLLM", () => { agent.mode = "primary" }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-build", ["Done"]).completeEvents + response = reply.text("Done", "text-build") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) @@ -1006,7 +976,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses the configured default agent system for omitted-agent sessions", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agent = yield* AgentV2.Service yield* agent.transform((editor) => { editor.update(AgentV2.ID.make("build"), (agent) => { @@ -1019,11 +989,9 @@ describe("SessionRunnerLLM", () => { }) editor.default(AgentV2.ID.make("reviewer")) }) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-reviewer", ["Done"]).completeEvents + response = reply.text("Done", "text-reviewer") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) @@ -1033,7 +1001,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses only the agent prompt and durable baseline as system parts", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agent = yield* AgentV2.Service yield* agent.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { @@ -1041,11 +1009,9 @@ describe("SessionRunnerLLM", () => { agent.mode = "primary" }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-no-system", ["Done"]).completeEvents + response = reply.text("Done", "text-no-system") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Build agent instructions", "Initial context"]) @@ -1054,7 +1020,7 @@ describe("SessionRunnerLLM", () => { it.effect("uses an explicitly selected non-build agent system", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const { db } = yield* Database.Service const agent = yield* AgentV2.Service yield* agent.transform((editor) => @@ -1069,11 +1035,9 @@ describe("SessionRunnerLLM", () => { .where(eq(SessionTable.id, sessionID)) .run() .pipe(Effect.orDie) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = fragmentFixture("text", "text-selected", ["Done"]).completeEvents + response = reply.text("Done", "text-selected") yield* session.resume(sessionID) expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Reviewer instructions", "Initial context"]) @@ -1083,21 +1047,18 @@ describe("SessionRunnerLLM", () => { it.effect("updates selected-agent skill guidance after an agent switch", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") yield* events.publish(SessionEvent.AgentSelected, { sessionID, agent: "reviewer", }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1110,8 +1071,7 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the sampled agent when selection changes during observation", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service skillBaselines.set(AgentV2.ID.make("build"), "Build skills") skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") @@ -1126,10 +1086,8 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1140,8 +1098,7 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the sampled model when selection changes during model resolution", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service let switched = false modelResolveHook = Effect.suspend(() => { @@ -1154,10 +1111,8 @@ describe("SessionRunnerLLM", () => { }) .pipe(Effect.asVoid) }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) expect(requests.map((request) => request.model)).toEqual([model]) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1168,15 +1123,12 @@ describe("SessionRunnerLLM", () => { it.effect("admits removed context as a chronological System message", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + const session = yield* setup + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemRemoved = true - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1189,14 +1141,11 @@ describe("SessionRunnerLLM", () => { it.effect("renders API context entries through the belief lifecycle", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const contextEntries = yield* InstructionEntry.Service yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) // String values render verbatim inside the tagged block at baseline. @@ -1207,7 +1156,7 @@ describe("SessionRunnerLLM", () => { // Non-string JSON pretty-prints; the change narrates as a System update. yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) @@ -1228,7 +1177,7 @@ describe("SessionRunnerLLM", () => { // Deleting the row announces removal through the stored removal text. yield* contextEntries.remove({ sessionID, key: "deploy-target" }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"]) @@ -1241,23 +1190,20 @@ describe("SessionRunnerLLM", () => { it.effect("keeps the baseline and chronological System updates after a model switch", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1277,31 +1223,28 @@ describe("SessionRunnerLLM", () => { ]) yield* replaySessionProjection(sessionID) expect(yield* session.messages({ sessionID })).toHaveLength(6) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fourth" }), resume: false }) + yield* admit(session, "Fourth") yield* session.resume(sessionID) }), ) it.effect("preserves the baseline while context is temporarily unavailable", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) yield* events.publish(SessionEvent.ModelSelected, { sessionID, model: { id: ModelV2.ID.make("replacement"), providerID: ProviderV2.ID.make("fake") }, }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) systemUnavailable = false systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1314,13 +1257,10 @@ describe("SessionRunnerLLM", () => { it.effect("rebuilds the baseline directly after completed compaction", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) yield* events.publish(SessionEvent.Compaction.Started, { sessionID, @@ -1333,7 +1273,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemBaseline = "Replacement context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([ @@ -1341,26 +1281,24 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Replacement context"], ]) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) }), ) it.effect("runs one durable compaction barrier before later steer and queued prompts", () => Effect.gen(function* () { - yield* setup - requests.length = 0 + const session = yield* setup currentModel = recoveryModel - const session = yield* SessionV2.Service streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() responses = [ - fragmentFixture("text", "text-active", ["Active complete"]).completeEvents, + reply.text("Active complete", "text-active"), [LLMEvent.textDelta({ id: "summary", text: "durable summary" })], - fragmentFixture("text", "text-steer", ["Steer complete"]).completeEvents, - fragmentFixture("text", "text-queue", ["Queue complete"]).completeEvents, + reply.text("Steer complete", "text-steer"), + reply.text("Queue complete", "text-queue"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Active work" }), resume: false }) + yield* admit(session, "Active work") const active = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) @@ -1375,11 +1313,7 @@ describe("SessionRunnerLLM", () => { status: "queued", }) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Steer after compaction" }), - resume: false, - }) + yield* admit(session, "Steer after compaction") yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue after compaction" }), @@ -1406,18 +1340,16 @@ describe("SessionRunnerLLM", () => { it.effect("releases queued prompts when durable compaction fails", () => Effect.gen(function* () { - yield* setup - requests.length = 0 + const session = yield* setup currentModel = recoveryModel - const session = yield* SessionV2.Service streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() responses = [ - fragmentFixture("text", "text-active-failure", ["Active complete"]).completeEvents, + reply.text("Active complete", "text-active-failure"), [], - fragmentFixture("text", "text-after-failure", ["Continued"]).completeEvents, + reply.text("Continued", "text-after-failure"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Active work" }), resume: false }) + yield* admit(session, "Active work") const active = yield* session.resume(sessionID).pipe(Effect.forkChild) yield* Deferred.await(streamStarted) @@ -1443,27 +1375,18 @@ describe("SessionRunnerLLM", () => { it.effect("automatically compacts into a completed summary and retained recent turn", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Earlier question ".repeat(180) }), - resume: false, - }) + const session = yield* setup + response = reply.text("Earlier answer", "text-first") + yield* admit(session, "Earlier question ".repeat(180)) yield* session.resume(sessionID) currentModel = compactModel requests.length = 0 responses = [ - fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents, - fragmentFixture("text", "text-final", ["Continued"]).completeEvents, + reply.text("## Objective\n- Preserve the task", "text-summary"), + reply.text("Continued", "text-final"), ] - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recent exact request ".repeat(180) }), - resume: false, - }) + yield* admit(session, "Recent exact request ".repeat(180)) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1482,14 +1405,10 @@ describe("SessionRunnerLLM", () => { requests.length = 0 executions.length = 0 responses = [ - fragmentFixture("text", "text-summary-2", ["## Objective\n- Preserve the updated task"]).completeEvents, - fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents, + reply.text("## Objective\n- Preserve the updated task", "text-summary-2"), + reply.text("Continued again", "text-final-2"), ] - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Newest exact request ".repeat(180) }), - resume: false, - }) + yield* admit(session, "Newest exact request ".repeat(180)) yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -1512,10 +1431,10 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ], - fragmentFixture("text", "text-summary", ["## Objective\n- Recover overflow"]).completeEvents, - fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, + reply.text("## Objective\n- Recover overflow", "text-summary"), + reply.text("Recovered", "text-final"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1540,12 +1459,8 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), ] - responses = [ - overflow(), - fragmentFixture("text", "text-summary", ["## Objective\n- Recover once"]).completeEvents, - overflow(), - ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + responses = [overflow(), reply.text("## Objective\n- Recover once", "text-summary"), overflow()] + yield* admit(session, "Continue") expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(3) @@ -1570,10 +1485,10 @@ describe("SessionRunnerLLM", () => { }), ) responses = [ - fragmentFixture("text", "text-summary", ["## Objective\n- Recover raw overflow"]).completeEvents, - fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, + reply.text("## Objective\n- Recover raw overflow", "text-summary"), + reply.text("Recovered", "text-final"), ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") yield* session.resume(sessionID) expect(requests).toHaveLength(3) @@ -1591,7 +1506,7 @@ describe("SessionRunnerLLM", () => { [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], [LLMEvent.providerError({ message: "summary unavailable" })], ] - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("prompt too long") expect(requests).toHaveLength(2) @@ -1609,12 +1524,12 @@ describe("SessionRunnerLLM", () => { const session = yield* setupOverflowRecovery responses = [ [LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })], - fragmentFixture("text", "text-summary", ["## Objective\n- Interrupted"]).completeEvents, + reply.text("## Objective\n- Interrupted", "text-summary"), ] const firstGate = yield* Deferred.make() const summaryGate = yield* Deferred.make() streamGate = firstGate - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow streamGate = summaryGate @@ -1631,16 +1546,13 @@ describe("SessionRunnerLLM", () => { it.effect("rebaselines after compaction from the last-applied belief while unobservable", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "First" }), resume: false }) + yield* admit(session, "First") - requests.length = 0 - response = [] yield* session.resume(sessionID) systemBaseline = "Changed context" - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Second" }), resume: false }) + yield* admit(session, "Second") yield* session.resume(sessionID) yield* events.publish(SessionEvent.Compaction.Started, { sessionID, @@ -1653,7 +1565,7 @@ describe("SessionRunnerLLM", () => { recent: "", }) systemUnavailable = true - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Third" }), resume: false }) + yield* admit(session, "Third") yield* session.resume(sessionID) // The rebaseline proceeds while the source is unobservable, restating the model's belief. @@ -1664,14 +1576,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects reasoning and tool events without executing or continuing tools", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Use tools" }), resume: false }) + const session = yield* setup + yield* admit(session, "Use tools") - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.reasoningStart({ id: "reasoning-1" }), @@ -1765,31 +1672,10 @@ describe("SessionRunnerLLM", () => { it.effect("continues with reloaded history after durably settling one local tool call", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) + const session = yield* setup + yield* admit(session, "Echo this") - requests.length = 0 - authorizations.length = 0 - executions.length = 0 - streamGate = undefined - streamStarted = undefined - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-final" }), - LLMEvent.textDelta({ id: "text-final", text: "Done" }), - LLMEvent.textEnd({ id: "text-final" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.text("Done", "text-final")] yield* session.resume(sessionID) @@ -1831,25 +1717,11 @@ describe("SessionRunnerLLM", () => { it.effect("reloads a model switch before a tool-driven continuation turn", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo this" }), resume: false }) + yield* admit(session, "Echo this") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop()] toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 @@ -1874,11 +1746,9 @@ describe("SessionRunnerLLM", () => { it.effect("restores durable reasoning provider metadata in a second-turn request", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Think first" }), resume: false }) + const session = yield* setup + yield* admit(session, "Think first") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.reasoningStart({ id: "reasoning-anthropic" }), @@ -1923,7 +1793,7 @@ describe("SessionRunnerLLM", () => { }, ]) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") response = [] yield* session.resume(sessionID) @@ -1940,11 +1810,9 @@ describe("SessionRunnerLLM", () => { it.effect("replays durable provider-executed tool results inline in a second-turn request", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Search first" }), resume: false }) + const session = yield* setup + yield* admit(session, "Search first") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ @@ -1967,7 +1835,7 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) yield* replaySessionProjection(sessionID) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Continue" }), resume: false }) + yield* admit(session, "Continue") response = [] yield* session.resume(sessionID) @@ -1995,17 +1863,12 @@ describe("SessionRunnerLLM", () => { it.effect("starts recorded local tools eagerly and awaits settlement before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo five times" }), resume: false }) + const session = yield* setup + yield* admit(session, "Echo five times") - requests.length = 0 - executions.length = 0 toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() const providerGate = yield* Deferred.make() - response = [] - responses = undefined const initial = Stream.fromIterable([ LLMEvent.stepStart({ index: 0 }), ...Array.from({ length: 5 }, (_, index) => @@ -2016,7 +1879,6 @@ describe("SessionRunnerLLM", () => { LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ]) - streamGate = undefined responseStream = Stream.concat( initial, Stream.fromEffect(Deferred.await(providerGate)).pipe(Stream.flatMap(() => final)), @@ -2056,25 +1918,12 @@ describe("SessionRunnerLLM", () => { it.effect("settles repeated provider-local tool call IDs against their owning assistant messages", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Echo twice" }), resume: false }) + const session = yield* setup + yield* admit(session, "Echo twice") - requests.length = 0 - executions.length = 0 responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "first" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "tool_0", name: "echo", input: { text: "second" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], + reply.tool("tool_0", "echo", { text: "first" }), + reply.tool("tool_0", "echo", { text: "second" }), [], ] @@ -2144,20 +1993,10 @@ describe("SessionRunnerLLM", () => { it.effect("joins concurrent resume calls into one active provider run", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run once" }), resume: false }) + const session = yield* setup + yield* admit(session, "Run once") - requests.length = 0 - responses = undefined - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-once" }), - LLMEvent.textDelta({ id: "text-once", text: "Once" }), - LLMEvent.textEnd({ id: "text-once" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ] + response = reply.text("Once", "text-once") streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2183,23 +2022,10 @@ describe("SessionRunnerLLM", () => { it.effect("steers an active provider turn with newly recorded prompts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2226,29 +2052,10 @@ describe("SessionRunnerLLM", () => { it.effect("promotes queued input after continuation ends", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-echo", name: "echo", input: { text: "hello" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-echo", "echo", { text: "hello" }), reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2273,24 +2080,11 @@ describe("SessionRunnerLLM", () => { it.effect("preserves durable queued input for a later wake after interruption", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), - resume: false, - }) + yield* admit(session, "Interrupt current work") - requests.length = 0 - responses = [ - [], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [[], reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2320,24 +2114,11 @@ describe("SessionRunnerLLM", () => { it.effect("preserves durable steering input for a later resume after interruption", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const { db } = yield* Database.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt current work" }), - resume: false, - }) + yield* admit(session, "Interrupt current work") - requests.length = 0 - responses = [ - [], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [[], reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2367,28 +2148,10 @@ describe("SessionRunnerLLM", () => { it.effect("promotes queued inputs one at a time in FIFO order", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2410,9 +2173,8 @@ describe("SessionRunnerLLM", () => { it.effect("promotes queued input after steering continuation ends", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start steering" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start steering") yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Queue for later" }), @@ -2420,19 +2182,7 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop()] yield* session.resume(sessionID) @@ -2444,33 +2194,10 @@ describe("SessionRunnerLLM", () => { it.effect("promotes steers before the next queued input", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop(), reply.stop(), reply.stop()] const firstGate = yield* Deferred.make() const secondGate = yield* Deferred.make() streamGate = firstGate @@ -2512,23 +2239,10 @@ describe("SessionRunnerLLM", () => { it.effect("coalesces multiple active steering prompts into one continuation turn", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.stop(), reply.stop()] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2552,13 +2266,9 @@ describe("SessionRunnerLLM", () => { it.effect("runs steering input accepted while the active provider turn fails", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start working" }), resume: false }) + const session = yield* setup + yield* admit(session, "Start working") - requests.length = 0 - responses = undefined - response = [] streamFailure = invalidRequest() streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2581,14 +2291,9 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails local tools left running by a prior process before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool" }), - resume: false, - }) + yield* admit(session, "Recover interrupted tool") yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { @@ -2643,14 +2348,9 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails hosted tools left running by a prior process before continuing inline", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover interrupted hosted tool" }), - resume: false, - }) + yield* admit(session, "Recover interrupted hosted tool") yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { @@ -2699,14 +2399,9 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails pending tool input left by a prior process before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover interrupted tool input" }), - resume: false, - }) + yield* admit(session, "Recover interrupted tool input") yield* SessionInput.promoteSteers((yield* Database.Service).db, events, sessionID) const assistantMessageID = SessionMessage.ID.create() yield* events.publish(SessionEvent.Step.Started, { @@ -2736,8 +2431,7 @@ describe("SessionRunnerLLM", () => { it.effect("promotes the first queued input when woken while idle", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Wait in queue" }), @@ -2745,7 +2439,6 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 yield* (yield* SessionExecution.Service).wake(sessionID) yield* Effect.yieldNow @@ -2756,26 +2449,17 @@ describe("SessionRunnerLLM", () => { it.effect("retries inbox input after prompt projection rolls back", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service const defect = new Error("fail after prompt promotion") let fail = true yield* events.project(SessionEvent.PromptPromoted, () => (fail ? Effect.die(defect) : Effect.void)) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Recover promoted input" }), - resume: false, - }) + yield* admit(session, "Recover promoted input") expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect) fail = false requests.length = 0 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ] + response = reply.stop() yield* (yield* SessionExecution.Service).wake(sessionID) while (requests.length === 0) yield* Effect.yieldNow @@ -2786,21 +2470,15 @@ describe("SessionRunnerLLM", () => { it.effect("does not strand a committed promotion when a post-commit listener defects", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const events = yield* EventV2.Service yield* events.listen((event) => event.type === SessionEvent.PromptPromoted.type ? Effect.die("fail after prompt promotion commits") : Effect.void, ) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Run committed promotion" }), - resume: false, - }) + yield* admit(session, "Run committed promotion") - requests.length = 0 yield* session.resume(sessionID) expect(requests).toHaveLength(1) @@ -2810,19 +2488,15 @@ describe("SessionRunnerLLM", () => { it.effect("runs different sessions concurrently", () => Effect.gen(function* () { - yield* setup + const session = yield* setup yield* insertSession(otherSessionID) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Run first" }), resume: false }) + yield* admit(session, "Run first") yield* session.prompt({ sessionID: otherSessionID, prompt: PromptInput.Prompt.make({ text: "Run second" }), resume: false, }) - requests.length = 0 - responses = undefined - response = [] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2847,12 +2521,11 @@ describe("SessionRunnerLLM", () => { it.effect("bounds 64-character session prompt cache keys", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const longSessionID = SessionV2.ID.make(`ses_${"a".repeat(64)}`) const otherLongSessionID = SessionV2.ID.make(`ses_${"b".repeat(64)}`) yield* insertSession(longSessionID) yield* insertSession(otherLongSessionID) - const session = yield* SessionV2.Service yield* session.prompt({ sessionID: longSessionID, prompt: PromptInput.Prompt.make({ text: "Run long session" }), @@ -2864,7 +2537,6 @@ describe("SessionRunnerLLM", () => { resume: false, }) - requests.length = 0 yield* session.resume(longSessionID) yield* session.resume(otherLongSessionID) @@ -2877,17 +2549,9 @@ describe("SessionRunnerLLM", () => { it.effect("fans out one failed run and allows a later retry", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Retry after failure" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Retry after failure") - requests.length = 0 - responses = undefined - response = [] streamFailure = invalidRequest() streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -2912,30 +2576,10 @@ describe("SessionRunnerLLM", () => { it.effect("durably settles local tool failures before continuing", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call missing" }), resume: false }) - - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-missing", name: "missing", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-after-error" }), - LLMEvent.textDelta({ id: "text-after-error", text: "Recovered" }), - LLMEvent.textEnd({ id: "text-after-error" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] - streamGate = undefined - streamStarted = undefined + const session = yield* setup + yield* admit(session, "Call missing") + responses = [reply.tool("call-missing", "missing", {}), reply.text("Recovered", "text-after-error")] yield* session.resume(sessionID) expect(requests).toHaveLength(2) @@ -2958,27 +2602,10 @@ describe("SessionRunnerLLM", () => { it.effect("returns unexpected local tool defects to the model and continues", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call defect" }), resume: false }) + const session = yield* setup + yield* admit(session, "Call defect") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-defect", name: "defect", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.textStart({ id: "text-after-defect" }), - LLMEvent.textDelta({ id: "text-after-defect", text: "Recovered" }), - LLMEvent.textEnd({ id: "text-after-defect" }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-defect", "defect", {}), reply.text("Recovered", "text-after-defect")] yield* session.resume(sessionID) @@ -3014,8 +2641,7 @@ describe("SessionRunnerLLM", () => { it.effect("returns policy-blocked tools to the model and continues", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ blocked: Tool.make({ @@ -3028,22 +2654,9 @@ describe("SessionRunnerLLM", () => { ), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call blocked" }), resume: false }) + yield* admit(session, "Call blocked") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-blocked", name: "blocked", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-blocked", "blocked", {}), reply.stop()] yield* session.resume(sessionID) @@ -3063,8 +2676,7 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts runner continuation when permission approval is declined", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ declined: Tool.make({ @@ -3074,15 +2686,9 @@ describe("SessionRunnerLLM", () => { execute: () => Effect.die(new PermissionV2.DeclinedError()), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call declined" }), resume: false }) + yield* admit(session, "Call declined") - requests.length = 0 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ] + response = reply.tool("call-declined", "declined", {}) const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -3107,8 +2713,7 @@ describe("SessionRunnerLLM", () => { it.effect("returns permission corrections to the model and continues", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ corrected: Tool.make({ @@ -3121,22 +2726,9 @@ describe("SessionRunnerLLM", () => { ), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call corrected" }), resume: false }) + yield* admit(session, "Call corrected") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-corrected", name: "corrected", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], - ] + responses = [reply.tool("call-corrected", "corrected", {}), reply.stop()] yield* session.resume(sessionID) @@ -3156,20 +2748,10 @@ describe("SessionRunnerLLM", () => { it.effect("fails the drain when tool output persistence fails", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Call storefail" }), resume: false }) + const session = yield* setup + yield* admit(session, "Call storefail") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-storefail", name: "storefail", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] + responses = [reply.tool("call-storefail", "storefail", {}), []] const exit = yield* session.resume(sessionID).pipe(Effect.exit) @@ -3201,8 +2783,7 @@ describe("SessionRunnerLLM", () => { it.effect("preserves permission rejection and stops before continuation", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ permissionfail: Tool.make({ @@ -3220,19 +2801,9 @@ describe("SessionRunnerLLM", () => { }), }), }) - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Reject permission" }), - resume: false, - }) - requests.length = 0 + yield* admit(session, "Reject permission") responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-permission", name: "permissionfail", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], + reply.tool("call-permission", "permissionfail", {}), [LLMEvent.stepStart({ index: 0 }), LLMEvent.stepFinish({ index: 0, reason: "stop" })], ] @@ -3270,8 +2841,7 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts runner continuation when a question is cancelled", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service + const session = yield* setup const registry = yield* ToolRegistry.Service yield* registry.register({ question: Tool.make({ @@ -3281,18 +2851,9 @@ describe("SessionRunnerLLM", () => { execute: () => Effect.die(new QuestionTool.CancelledError()), }), }) - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Ask then stop" }), resume: false }) + yield* admit(session, "Ask then stop") - requests.length = 0 - responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-question", name: "question", input: {} }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [], - ] + responses = [reply.tool("call-question", "question", {}), []] const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild) const exit = yield* Fiber.join(run) @@ -3318,13 +2879,8 @@ describe("SessionRunnerLLM", () => { it.effect("awaits started local tools before surfacing provider stream failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Settle before failing" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Settle before failing") const failure = providerUnavailable() toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( @@ -3364,14 +2920,8 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails blocked local tools when a provider turn is interrupted", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt blocked tool" }), - resume: false, - }) - executions.length = 0 + const session = yield* setup + yield* admit(session, "Interrupt blocked tool") toolExecutionGate = yield* Deferred.make() responseStream = Stream.concat( Stream.fromIterable([ @@ -3426,15 +2976,8 @@ describe("SessionRunnerLLM", () => { it.effect("interrupts a blocked provider turn without local tool execution", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt provider" }), - resume: false, - }) - requests.length = 0 - response = [] + const session = yield* setup + yield* admit(session, "Interrupt provider") streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -3458,23 +3001,12 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Interrupt tool settlement" }), - resume: false, - }) - executions.length = 0 + const session = yield* setup + yield* admit(session, "Interrupt tool settlement") toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-await-interrupt", name: "echo", input: { text: "blocked" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ] + response = reply.tool("call-await-interrupt", "echo", { text: "blocked" }) const runner = yield* SessionRunner.Service const run = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) @@ -3506,35 +3038,18 @@ describe("SessionRunnerLLM", () => { it.effect("forces a text response on an agent's configured final step", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Finish at the limit" }), - resume: false, - }) + yield* admit(session, "Finish at the limit") - requests.length = 0 - executions.length = 0 responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], + reply.tool("call-terminal", "echo", { text: "done" }), + reply.tool("call-forbidden", "echo", { text: "forbidden" }), ] yield* session.resume(sessionID) @@ -3558,36 +3073,19 @@ describe("SessionRunnerLLM", () => { it.effect("resets the configured step allowance when steering input promotes", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Start work" }), resume: false }) + yield* admit(session, "Start work") - requests.length = 0 - executions.length = 0 responses = [ - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-before-steer", name: "echo", input: { text: "before" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: "call-after-steer", name: "echo", input: { text: "after" } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ], - [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.stepFinish({ index: 0, reason: "stop" }), - LLMEvent.finish({ reason: "stop" }), - ], + reply.tool("call-before-steer", "echo", { text: "before" }), + reply.tool("call-after-steer", "echo", { text: "after" }), + reply.stop(), ] streamGate = yield* Deferred.make() streamStarted = yield* Deferred.make() @@ -3610,14 +3108,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects provider errors as terminal assistant step failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail durably" }), resume: false }) + const session = yield* setup + yield* admit(session, "Fail durably") - requests.length = 0 - responses = undefined - streamGate = undefined - streamStarted = undefined response = [LLMEvent.stepStart({ index: 0 }), LLMEvent.providerError({ message: "Provider unavailable" })] expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") @@ -3632,11 +3125,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects provider errors emitted before assistant step start", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Fail before step" }), resume: false }) + const session = yield* setup + yield* admit(session, "Fail before step") - requests.length = 0 response = [LLMEvent.providerError({ message: "Provider unavailable" })] expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider unavailable") @@ -3651,9 +3142,8 @@ describe("SessionRunnerLLM", () => { it.effect("projects content-filter finishes as visible terminal failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Blocked response" }), resume: false }) + const session = yield* setup + yield* admit(session, "Blocked response") response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "partial" }), @@ -3688,13 +3178,8 @@ describe("SessionRunnerLLM", () => { it.effect("settles a local tool before one content-filter step failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Tool before blocked response" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Tool before blocked response") toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 @@ -3728,15 +3213,9 @@ describe("SessionRunnerLLM", () => { it.effect("does not recover context overflow after durable assistant output", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail after output" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Fail after output") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-partial" }), @@ -3761,13 +3240,8 @@ describe("SessionRunnerLLM", () => { it.effect("projects raw provider stream failures as terminal assistant step failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail raw stream durably" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Fail raw stream durably") const failure = invalidRequest() responseStream = Stream.fail(failure) @@ -3782,12 +3256,10 @@ describe("SessionRunnerLLM", () => { it.effect("retries eligible pre-output failures after exponential backoff", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry transport" }), resume: false }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Retry transport") responseStream = Stream.fail(providerUnavailable()) - response = fragmentFixture("text", "retry-success", ["Recovered"]).completeEvents + response = reply.text("Recovered", "retry-success") const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow @@ -3811,12 +3283,10 @@ describe("SessionRunnerLLM", () => { it.effect("uses a larger provider retry-after delay", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Retry rate limit" }), resume: false }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Retry rate limit") responseStream = Stream.fail(rateLimited(5_000)) - response = fragmentFixture("text", "retry-after-success", ["Recovered"]).completeEvents + response = reply.text("Recovered", "retry-after-success") const run = yield* session.resume(sessionID).pipe(Effect.forkChild) while (requests.length < 1) yield* Effect.yieldNow @@ -3830,10 +3300,8 @@ describe("SessionRunnerLLM", () => { it.effect("stops after five total retry attempts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Exhaust retries" }), resume: false }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Exhaust retries") streamFailure = providerUnavailable() const run = yield* session.resume(sessionID).pipe(Effect.forkChild) @@ -3866,20 +3334,14 @@ describe("SessionRunnerLLM", () => { it.effect("counts retry attempts against the agent step allowance", () => Effect.gen(function* () { - yield* setup + const session = yield* setup const agents = yield* AgentV2.Service yield* agents.transform((editor) => editor.update(AgentV2.ID.make("build"), (agent) => { agent.steps = 2 }), ) - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Bound retries by steps" }), - resume: false, - }) - requests.length = 0 + yield* admit(session, "Bound retries by steps") const failure = providerUnavailable() responseStream = Stream.fail(failure) streamFailure = failure @@ -3899,10 +3361,8 @@ describe("SessionRunnerLLM", () => { it.effect("does not retry non-eligible provider failures", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Do not retry" }), resume: false }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Do not retry") const failure = invalidRequest() streamFailure = failure @@ -3914,16 +3374,9 @@ describe("SessionRunnerLLM", () => { it.effect("does not continue automatically after a provider error follows a local tool call", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Do not continue failed provider" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Do not continue failed provider") - requests.length = 0 - const executionCount = executions.length toolExecutionGate = yield* Deferred.make() toolExecutionsStarted = yield* Deferred.make() toolExecutionsReady = 1 @@ -3941,7 +3394,7 @@ describe("SessionRunnerLLM", () => { toolExecutionsStarted = undefined expect(requests).toHaveLength(1) - expect(executions.slice(executionCount)).toEqual(["settled"]) + expect(executions).toEqual(["settled"]) const context = yield* session.context(sessionID) const assistant = requireAssistant(context) expect((yield* recordedStepSettlementEvents(sessionID, assistant.id)).map((event) => event.type)).toEqual([ @@ -3955,23 +3408,12 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails a hosted tool when its provider errors before returning a result", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail hosted tool durably" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Fail hosted tool durably") - requests.length = 0 response = [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-provider-error", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), + hostedCall("call-hosted-provider-error", "effect"), LLMEvent.providerError({ message: "Provider unavailable" }), ] @@ -3998,13 +3440,8 @@ describe("SessionRunnerLLM", () => { it.effect("preserves a tool defect before provider failure settlement", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Defect while provider fails" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Defect while provider fails") response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-defect-provider-error", name: "defect", input: {} }), @@ -4028,22 +3465,9 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails a hosted tool left unresolved at normal provider EOF", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail hosted tool at EOF" }), - resume: false, - }) - response = [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-eof", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), - ] + const session = yield* setup + yield* admit(session, "Fail hosted tool at EOF") + response = [LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-eof", "effect")] expect((yield* session.resume(sessionID).pipe(Effect.flip)).message).toBe("Provider did not return a tool result") const assistant = requireAssistant(yield* session.context(sessionID)) @@ -4073,21 +3497,11 @@ describe("SessionRunnerLLM", () => { it.effect("fails an unresolved hosted tool before one clean step end", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Settle hosted tool before ending" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Settle hosted tool before ending") response = [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-clean-end", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), + hostedCall("call-hosted-clean-end", "effect"), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), ] @@ -4110,13 +3524,8 @@ describe("SessionRunnerLLM", () => { it.effect("settles unresolved local and hosted tools before one raw provider failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail unresolved tools" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Fail unresolved tools") const failure = invalidRequest() const providerFailed = yield* Deferred.make() toolExecutionGate = yield* Deferred.make() @@ -4124,12 +3533,7 @@ describe("SessionRunnerLLM", () => { Stream.fromIterable([ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolCall({ id: "call-local-raw-failure", name: "defect", input: {} }), - LLMEvent.toolCall({ - id: "call-hosted-raw-failure-pair", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), + hostedCall("call-hosted-raw-failure-pair", "effect"), ]), Stream.fromEffect(Deferred.succeed(providerFailed, undefined)).pipe(Stream.flatMap(() => Stream.fail(failure))), ) @@ -4158,25 +3562,11 @@ describe("SessionRunnerLLM", () => { it.effect("durably fails a hosted tool left unresolved by a raw provider stream failure", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Fail hosted tool on raw failure" }), - resume: false, - }) - requests.length = 0 + const session = yield* setup + yield* admit(session, "Fail hosted tool on raw failure") const failure = providerUnavailable() responseStream = Stream.concat( - Stream.fromIterable([ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ - id: "call-hosted-raw-failure", - name: "web_search", - input: { query: "effect" }, - providerExecuted: true, - }), - ]), + Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]), Stream.fail(failure), ) @@ -4208,13 +3598,9 @@ describe("SessionRunnerLLM", () => { it.effect("rejects a second text start before the open fragment ends", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Two blocks" }), resume: false }) + const session = yield* setup + yield* admit(session, "Two blocks") - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-1" }), @@ -4230,13 +3616,9 @@ describe("SessionRunnerLLM", () => { it.effect("projects sequential text fragments as separate content parts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: PromptInput.Prompt.make({ text: "Two blocks" }), resume: false }) + const session = yield* setup + yield* admit(session, "Two blocks") - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.textStart({ id: "text-1" }), @@ -4278,11 +3660,7 @@ describe("SessionRunnerLLM", () => { it.effect("rejects duplicate streamed text starts", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - responses = undefined - streamGate = undefined - streamStarted = undefined + const session = yield* setup response = [LLMEvent.textStart({ id: "text-1" }), LLMEvent.textStart({ id: "text-1" })] const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) @@ -4294,23 +3672,15 @@ describe("SessionRunnerLLM", () => { it.effect("transitions streamed raw tool input to parsed called input", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ - sessionID, - prompt: PromptInput.Prompt.make({ text: "Call provider tool" }), - resume: false, - }) + const session = yield* setup + yield* admit(session, "Call provider tool") - responses = undefined - streamGate = undefined - streamStarted = undefined response = [ LLMEvent.stepStart({ index: 0 }), LLMEvent.toolInputStart({ id: "call-parsed", name: "web_search" }), LLMEvent.toolInputDelta({ id: "call-parsed", name: "web_search", text: '{"query":"hello"}' }), LLMEvent.toolInputEnd({ id: "call-parsed", name: "web_search" }), - LLMEvent.toolCall({ id: "call-parsed", name: "web_search", input: { query: "hello" }, providerExecuted: true }), + hostedCall("call-parsed", "hello"), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), ] @@ -4329,11 +3699,7 @@ describe("SessionRunnerLLM", () => { it.effect("rejects malformed streamed tool input ordering", () => Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - responses = undefined - streamGate = undefined - streamStarted = undefined + const session = yield* setup response = [LLMEvent.toolInputDelta({ id: "call-1", name: "read", text: "{}" })] const defect = yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed)) From ed6ad272ec16b4f78016a2c8bc9b8c79d70587d5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Jul 2026 22:10:11 -0400 Subject: [PATCH 29/34] refactor(schema): apply session review decisions (#35793) --- .../src/context/global-sync/event-reducer.ts | 4 +- .../context/global-sync/session-cache.test.ts | 6 +- .../src/context/global-sync/session-cache.ts | 4 +- packages/app/src/context/global-sync/types.ts | 4 +- packages/app/src/context/server-session.ts | 6 +- packages/app/src/pages/session/review-tab.tsx | 4 +- .../src/pages/session/session-side-panel.tsx | 14 +- .../src/pages/session/v2/review-diff-kinds.ts | 8 +- .../src/pages/session/v2/review-panel-v2.tsx | 13 +- packages/app/src/utils/diffs.test.ts | 4 +- packages/app/src/utils/diffs.ts | 4 +- packages/cli/src/mini/catalog.shared.ts | 5 +- packages/cli/src/mini/footer.prompt.tsx | 4 +- packages/cli/src/mini/noninteractive.ts | 1 - packages/cli/src/mini/run.ts | 13 +- packages/cli/src/mini/stream-v2.subagent.ts | 14 +- packages/cli/src/mini/stream-v2.transport.ts | 31 +- packages/cli/src/mini/types.ts | 1 + .../client/src/promise/generated/types.ts | 502 +++--- .../client/test/contract-identity.test.ts | 2 +- .../test/fixtures/opencode-v2-openapi.json | 1400 ++++++++--------- packages/core/src/agent.ts | 2 + packages/core/src/config/plugin/provider.ts | 5 +- packages/core/src/config/provider.ts | 9 +- packages/core/src/database/migration.gen.ts | 1 + ...260707120000_migrate_prelaunch_v2_state.ts | 227 +++ packages/core/src/file.ts | 4 +- packages/core/src/git.ts | 2 +- packages/core/src/models-dev.ts | 25 +- packages/core/src/plugin/agent.ts | 7 + packages/core/src/plugin/models-dev.ts | 54 +- packages/core/src/plugin/provider/opencode.ts | 18 +- packages/core/src/plugin/skill.ts | 29 +- packages/core/src/session.ts | 37 +- packages/core/src/session/compaction.ts | 39 +- packages/core/src/session/history.ts | 2 +- packages/core/src/session/info.ts | 11 +- packages/core/src/session/input.ts | 6 +- packages/core/src/session/message-updater.ts | 71 +- packages/core/src/session/projector.ts | 80 +- packages/core/src/session/revert.ts | 12 +- packages/core/src/session/runner/llm.ts | 35 +- .../src/session/runner/publish-llm-event.ts | 16 +- .../core/src/session/runner/to-llm-message.ts | 8 +- packages/core/src/session/sql.ts | 13 +- packages/core/src/session/store.ts | 6 +- packages/core/src/skill.ts | 36 +- packages/core/src/skill/guidance.ts | 14 +- packages/core/src/snapshot.ts | 13 +- packages/core/src/tool/skill.ts | 16 +- packages/core/test/catalog.test.ts | 23 +- packages/core/test/config/command.test.ts | 3 +- packages/core/test/config/provider.test.ts | 13 +- packages/core/test/database-migration.test.ts | 254 ++- packages/core/test/git.test.ts | 4 +- packages/core/test/location-layer.test.ts | 5 +- packages/core/test/plugin/models-dev.test.ts | 50 +- .../core/test/plugin/provider-openai.test.ts | 12 +- .../test/plugin/provider-opencode.test.ts | 12 +- packages/core/test/plugin/skill.test.ts | 8 +- packages/core/test/session-compact.test.ts | 10 +- packages/core/test/session-compaction.test.ts | 8 +- packages/core/test/session-create.test.ts | 17 +- .../core/test/session-instructions.test.ts | 1 - packages/core/test/session-log.test.ts | 7 +- packages/core/test/session-projector.test.ts | 163 +- packages/core/test/session-prompt.test.ts | 6 +- .../core/test/session-runner-message.test.ts | 35 +- .../core/test/session-runner-model.test.ts | 9 +- .../test/session-runner-tool-events.test.ts | 6 +- packages/core/test/session-runner.test.ts | 115 +- packages/core/test/session-skill.test.ts | 7 +- .../core/test/session-tool-progress.test.ts | 3 +- packages/core/test/shared-schema.test.ts | 5 +- packages/core/test/skill.test.ts | 13 +- packages/core/test/skill/guidance.test.ts | 27 +- packages/core/test/snapshot.test.ts | 2 +- packages/core/test/tool-shell.test.ts | 8 +- packages/core/test/tool-skill.test.ts | 20 +- packages/core/test/tool-subagent.test.ts | 3 +- packages/docs/openapi.json | 1400 ++++++++--------- packages/enterprise/src/core/share.ts | 4 +- .../enterprise/src/routes/share/[shareID].tsx | 4 +- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/snapshot/index.ts | 4 +- .../test/cli/run/stream-v2.transport.test.ts | 43 +- .../test/server/httpapi-session.test.ts | 38 +- .../test/v2/session-message-updater.test.ts | 44 +- packages/plugin/src/v2/effect/agent.ts | 8 +- packages/plugin/src/v2/effect/catalog.ts | 8 +- packages/plugin/src/v2/effect/command.ts | 8 +- packages/plugin/src/v2/effect/skill.ts | 6 +- packages/protocol/src/errors.ts | 3 +- packages/protocol/src/groups/message.ts | 2 +- packages/protocol/src/groups/session.ts | 12 +- packages/schema/src/agent.ts | 9 +- packages/schema/src/command.ts | 5 +- packages/schema/src/event.ts | 22 +- packages/schema/src/file-diff.ts | 22 +- packages/schema/src/index.ts | 4 +- packages/schema/src/model.ts | 19 +- packages/schema/src/money.ts | 18 + packages/schema/src/revert.ts | 24 - packages/schema/src/session-event.ts | 57 +- packages/schema/src/session-input.ts | 11 +- packages/schema/src/session-message.ts | 121 +- packages/schema/src/session-revert.ts | 86 + packages/schema/src/session.ts | 23 +- packages/schema/src/shell.ts | 2 +- packages/schema/src/skill.ts | 29 +- packages/schema/src/snapshot.ts | 6 + packages/schema/src/token-usage.ts | 14 + packages/schema/src/v1/session.ts | 10 +- packages/schema/test/contract-hygiene.test.ts | 90 +- packages/schema/test/event-manifest.test.ts | 7 + packages/sdk/js/src/v2/client.ts | 4 + packages/sdk/js/src/v2/gen/sdk.gen.ts | 163 +- packages/sdk/js/src/v2/gen/types.gen.ts | 1063 +++++++------ packages/server/src/handlers/message.ts | 2 +- .../session-ui/src/components/session-diff.ts | 5 +- .../src/components/session-review.tsx | 10 +- .../src/components/session-turn.tsx | 16 +- packages/session-ui/src/context/data.tsx | 4 +- .../session-review-file-preview-v2.tsx | 4 +- .../tui/src/component/dialog-session-list.tsx | 33 +- packages/tui/src/component/dialog-skill.tsx | 4 +- .../tui/src/component/prompt/autocomplete.tsx | 6 +- packages/tui/src/component/prompt/index.tsx | 12 +- packages/tui/src/context/data.tsx | 163 +- packages/tui/src/context/sync.tsx | 4 +- .../feature-plugins/system/diff-viewer.tsx | 4 +- packages/tui/src/routes/session/index.tsx | 89 +- .../tui/src/routes/session/permission.tsx | 2 +- packages/tui/src/routes/session/rows.ts | 16 +- packages/tui/src/util/session.ts | 9 +- packages/tui/src/util/tool-display.ts | 2 +- .../test/cli/cmd/tui/notifications.test.ts | 2 +- packages/tui/test/cli/tui/data.test.tsx | 68 +- .../tui/test/cli/tui/session-rows.test.ts | 28 +- packages/tui/test/util/session.test.ts | 4 +- packages/tui/test/util/tool-display.test.ts | 2 +- 142 files changed, 4321 insertions(+), 3256 deletions(-) create mode 100644 packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts create mode 100644 packages/schema/src/money.ts delete mode 100644 packages/schema/src/revert.ts create mode 100644 packages/schema/src/session-revert.ts create mode 100644 packages/schema/src/snapshot.ts create mode 100644 packages/schema/src/token-usage.ts diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 15d125df39..40b7acbbd3 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -8,7 +8,7 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" import type { State, VcsCache } from "./types" @@ -188,7 +188,7 @@ export function applyDirectoryEvent(input: { break } case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) break } diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 4b2be505ea..3dda5a5429 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -5,7 +5,7 @@ import type { PermissionRequest, QuestionRequest, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" @@ -33,7 +33,7 @@ describe("app session cache", () => { test("dropSessionCaches clears orphaned parts without message rows", () => { const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record @@ -67,7 +67,7 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 05cdc84643..39535abe2f 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -4,7 +4,7 @@ import type { PermissionRequest, QuestionRequest, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" @@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record part: Record diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 86b489cd09..a117e4d007 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -13,7 +13,7 @@ import type { ReferenceInfo, Session, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" @@ -51,7 +51,7 @@ export type State = { } session_working(id: string): boolean session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } todo: { [sessionID: string]: Todo[] diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 6898cc2304..a46eb744d1 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -8,7 +8,7 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, + FileDiffInfo, Todo, } from "@opencode-ai/sdk/v2/client" import { batch } from "solid-js" @@ -139,7 +139,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const [data, setData] = createStore({ info: {} as Record, session_status: {} as Record, - session_diff: {} as Record, + session_diff: {} as Record, todo: {} as Record, permission: {} as Record, question: {} as Record, @@ -769,7 +769,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) return } diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 3854bf0276..586942399d 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,6 @@ import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SessionReview } from "@opencode-ai/session-ui/session-review" import type { SessionReviewCommentActions, @@ -14,7 +14,7 @@ import type { LineComment } from "@/context/comments" export type DiffStyle = "unified" | "split" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff export interface SessionReviewTabProps { title?: JSX.Element diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 3f44aba488..01384a6ee9 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Mark } from "@opencode-ai/ui/logo" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -23,7 +23,6 @@ import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useSettings } from "@/context/settings" -import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { @@ -36,15 +35,9 @@ import { import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" -type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff - -function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { - return typeof value.file === "string" -} - export function SessionSidePanel(props: { canReview: () => boolean - diffs: () => (SnapshotFileDiff | VcsFileDiff)[] + diffs: () => (FileDiffInfo | VcsFileDiff)[] diffsReady: () => boolean empty: () => string hasReview: () => boolean @@ -59,7 +52,6 @@ export function SessionSidePanel(props: { }) { const layout = useLayout() const settings = useSettings() - const sync = useSync() const file = useFile() const language = useLanguage() const command = useCommand() @@ -88,7 +80,7 @@ export function SessionSidePanel(props: { }) const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) - const diffs = createMemo(() => props.diffs().filter(renderDiff)) + const diffs = createMemo(() => props.diffs()) const diffFiles = createMemo(() => diffs().map((d) => d.file)) const kinds = createMemo(() => { const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => { diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index c288c35706..760ace3311 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,17 +1,13 @@ -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { Kind } from "@/components/file-tree-v2" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" -export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +export type RenderDiff = FileDiffInfo | VcsFileDiff export function normalizePath(p: string) { return normalizeFileTreeV2Path(p) } -export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { - return typeof value.file === "string" -} - export function reviewDiffKinds(diffs: RenderDiff[]) { const merge = (a: Kind | undefined, b: Kind) => { if (!a) return b diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index fcd52756ab..41f75a081e 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,5 +1,5 @@ import { createMemo, createSignal, Show, type JSX } from "solid-js" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, @@ -21,16 +21,11 @@ import type { import FileTreeV2 from "@/components/file-tree-v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" -import { - filterRenderableDiff, - filterReviewFiles, - reviewDiffKinds, - type RenderDiff, -} from "@/pages/session/v2/review-diff-kinds" +import { filterReviewFiles, reviewDiffKinds, type RenderDiff } from "@/pages/session/v2/review-diff-kinds" import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff export type ReviewPanelV2Props = { title?: JSX.Element @@ -54,7 +49,7 @@ export type ReviewPanelV2Props = { export function ReviewPanelV2(props: ReviewPanelV2Props) { const sdk = useSDK() - const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff)) + const diffs = createMemo(() => props.diffs()) const filteredFiles = createMemo(() => filterReviewFiles( diffs().map((diff) => diff.file), diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index 5fbca469b7..f6d768e1de 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/sdk/v2" import type { Message } from "@opencode-ai/sdk/v2/client" import { diffs, message } from "./diffs" @@ -9,7 +9,7 @@ const item = { additions: 1, deletions: 1, status: "modified", -} satisfies SnapshotFileDiff +} satisfies FileDiffInfo describe("diffs", () => { test("keeps valid arrays", () => { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index 0cb2504fbe..60df039410 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,7 +1,7 @@ -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/sdk/v2" import type { Message } from "@opencode-ai/sdk/v2/client" -type Diff = SnapshotFileDiff | VcsFileDiff +type Diff = FileDiffInfo function diff(value: unknown): value is Diff { if (!value || typeof value !== "object" || Array.isArray(value)) return false diff --git a/packages/cli/src/mini/catalog.shared.ts b/packages/cli/src/mini/catalog.shared.ts index 535147e651..0868d3a94b 100644 --- a/packages/cli/src/mini/catalog.shared.ts +++ b/packages/cli/src/mini/catalog.shared.ts @@ -37,7 +37,8 @@ function defaultCost(model: CurrentModel) { export function runAgent(input: CurrentAgent): RunAgent { return { - name: input.id, + id: input.id, + name: input.name, description: input.description, mode: input.mode, hidden: input.hidden, @@ -53,7 +54,7 @@ export function runCommand(input: CurrentCommand): RunCommand { export function runSkill(input: CurrentSkill): RunCommand { return { - name: input.name, + name: input.id, description: input.description, source: "skill", } diff --git a/packages/cli/src/mini/footer.prompt.tsx b/packages/cli/src/mini/footer.prompt.tsx index 6ba7940ab6..4c6214d9a8 100644 --- a/packages/cli/src/mini/footer.prompt.tsx +++ b/packages/cli/src/mini/footer.prompt.tsx @@ -333,10 +333,10 @@ export function createPromptState(input: PromptInput): PromptState { .map((item) => ({ kind: "mention", display: "@" + item.name, - value: item.name, + value: item.id, part: { type: "agent", - name: item.name, + name: item.id, source: { start: 0, end: 0, diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index 8549b36d37..bc356eb743 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -281,7 +281,6 @@ export async function runNonInteractivePrompt(input: Input) { metadata: { structured: event.data.structured, content: event.data.content, - outputPaths: event.data.outputPaths, result: event.data.result, providerCall: current.provider, providerResult: { executed: event.data.executed, state: event.data.resultState }, diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index d8e3dcd228..a6aedbb97d 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -89,12 +89,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr !explicitModel && !sessionModel ? await client.model .default({ location: { directory: cwd, workspace } }) - .then((result) => - result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined, - ) + .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) : undefined const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel) - if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) + if (input.variant && !model) + return reportError(input, "Cannot select a variant before selecting a model", session?.id) if (model) { await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) const available = await client.model.list({ location: { directory: cwd, workspace } }) @@ -112,9 +111,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr if (!session && input.title !== undefined) { await client.session.rename({ sessionID: selected.id, - title: - input.title || - prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""), + title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""), }) } @@ -200,7 +197,7 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`) return } - const agent = agents.find((item) => item.name === name) + const agent = agents.find((item) => item.id === name) if (!agent) { warning(`agent "${name}" not found. Falling back to default agent`) return diff --git a/packages/cli/src/mini/stream-v2.subagent.ts b/packages/cli/src/mini/stream-v2.subagent.ts index 4aae8e1278..091ff296ee 100644 --- a/packages/cli/src/mini/stream-v2.subagent.ts +++ b/packages/cli/src/mini/stream-v2.subagent.ts @@ -16,7 +16,7 @@ // backgrounding is intentionally absent: subagent jobs block the parent // session, so only whole-session `v2.session.background(parentID)` exists. import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" -import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2" import { Locale } from "@opencode-ai/tui/util/locale" import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" @@ -54,7 +54,7 @@ export function legacyTool(input: { callID: tool.id, tool: tool.name, } - if (tool.state.status === "pending") { + if (tool.state.status === "streaming") { return { ...base, state: { status: "pending", input: {}, raw: tool.state.input }, @@ -83,7 +83,6 @@ export function legacyTool(input: { metadata: { structured: tool.state.structured, content: tool.state.content, - outputPaths: tool.state.outputPaths, result: tool.state.result, providerCall, providerResult, @@ -179,7 +178,7 @@ export type SubagentTrackerInput = { export type SubagentTracker = { main(event: V2Event): void foreign(sessionID: string, event: V2Event): void - hydrate(next: { messages: SessionMessage[]; active: Record }): Promise + hydrate(next: { messages: SessionMessageInfo[]; active: Record }): Promise select(sessionID: string | undefined): void snapshot(): FooterSubagentState } @@ -316,7 +315,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac messageID, tool: item, }) - if (item.state.status === "pending") return + if (item.state.status === "streaming") return child.callIDs.add(item.id) if (item.state.status === "running") { setFrame(child, `tool:${item.id}`, toolCommit(part, "start")) @@ -327,7 +326,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac setFrame(child, `tool:${item.id}`, toolCommit(part, "final")) } - const rebuild = (child: ChildState, messages: SessionMessage[]) => { + const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => { child.frames = [] child.text.clear() child.projectedText.clear() @@ -412,7 +411,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac for (const [id, prompt] of pendingPrompts) { if (!child.prompts.has(id)) child.prompts.set(id, prompt) } - rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[]) + rebuild(child, structuredClone(response.data).toReversed() as SessionMessageInfo[]) for (const [id, tool] of pendingTools) { if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool) } @@ -622,7 +621,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac input: current?.input ?? {}, structured: event.data.structured, content: event.data.content, - outputPaths: event.data.outputPaths, result: event.data.result, }, time: { diff --git a/packages/cli/src/mini/stream-v2.transport.ts b/packages/cli/src/mini/stream-v2.transport.ts index a82fea2e58..ae854ef2e3 100644 --- a/packages/cli/src/mini/stream-v2.transport.ts +++ b/packages/cli/src/mini/stream-v2.transport.ts @@ -2,7 +2,7 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p import type { PermissionRequest, QuestionRequest, - SessionMessage, + SessionMessageInfo, SessionMessageAssistantTool, } from "@opencode-ai/sdk/v2" import { Event } from "@opencode-ai/schema/event" @@ -389,7 +389,7 @@ export async function createSessionTransport(input: StreamInput): Promise { + const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => { if (message.type === "user") { const waiting = state.wait?.messageID === message.id if (waiting && state.wait) state.wait.promoted = true @@ -438,34 +438,34 @@ export async function createSessionTransport(input: StreamInput): Promise } }> @@ -408,13 +408,12 @@ export type SessionCreateOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -447,13 +446,12 @@ export type SessionGetOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -491,13 +489,12 @@ export type SessionForkOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -933,13 +930,12 @@ export type SessionRevertStageOutput = { readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } }["data"] @@ -994,7 +990,6 @@ export type SessionContextOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } - readonly sessionID: string readonly text: string readonly description?: string readonly type: "synthetic" @@ -1011,6 +1006,7 @@ export type SessionContextOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "skill" + readonly skill: string readonly name: string readonly text: string } @@ -1019,21 +1015,10 @@ export type SessionContextOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" - readonly shell: { - readonly id: string - readonly status: "running" | "exited" | "timeout" | "killed" - readonly command: string - readonly cwd: string - readonly shell: string - readonly file: string - readonly pid?: number - readonly exit?: number | "Infinity" | "-Infinity" | "NaN" - readonly metadata: { readonly [x: string]: JsonValue } - readonly time: { - readonly started: number | "Infinity" | "-Infinity" | "NaN" - readonly completed?: number | "Infinity" | "-Infinity" | "NaN" - } - } + readonly shellID: string + readonly command: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" readonly output?: { readonly output: string readonly cursor: number @@ -1064,7 +1049,7 @@ export type SessionContextOutput = { readonly providerState?: { readonly [x: string]: JsonValue } readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: - | { readonly status: "pending"; readonly input: string } + | { readonly status: "streaming"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } @@ -1077,19 +1062,10 @@ export type SessionContextOutput = { | { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } - readonly attachments?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly structured: { readonly [x: string]: JsonValue } readonly result?: JsonValue } @@ -1104,12 +1080,7 @@ export type SessionContextOutput = { readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } - readonly time: { - readonly created: number - readonly ran?: number - readonly completed?: number - readonly pruned?: number - } + readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number } } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } @@ -1128,16 +1099,37 @@ export type SessionContextOutput = { readonly error: { readonly type: string; readonly message: string } } } - | { - readonly type: "compaction" - readonly status: "queued" | "running" | "completed" | "failed" - readonly reason: "auto" | "manual" - readonly summary: string - readonly recent: string - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } - } + | ( + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "running" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "failed" + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + } + ) > }["data"] @@ -1175,7 +1167,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.agent.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly agent: string } } @@ -1184,7 +1176,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.model.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1196,7 +1188,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.moved" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1209,7 +1201,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.renamed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly title: string } } @@ -1218,7 +1210,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.deleted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 2 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -1227,7 +1219,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.forked" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } @@ -1236,7 +1228,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.promoted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -1245,7 +1237,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1273,7 +1265,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -1282,7 +1274,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.succeeded" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -1291,7 +1283,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1303,7 +1295,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.interrupted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" } } @@ -1312,7 +1304,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.instructions.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly text: string } } @@ -1321,7 +1313,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.synthetic" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1335,16 +1327,21 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.skill.activated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } + readonly data: { + readonly sessionID: string + readonly id: string + readonly name: string + readonly text: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1367,7 +1364,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1396,7 +1393,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1411,7 +1408,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1433,7 +1430,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1453,7 +1450,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number } } @@ -1462,7 +1459,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1476,7 +1473,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1490,7 +1487,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1505,7 +1502,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1519,7 +1516,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1533,7 +1530,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.called" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1549,7 +1546,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.progress" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1567,7 +1564,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.success" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1578,7 +1575,6 @@ export type SessionLogOutput = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly result?: unknown readonly executed: boolean readonly resultState?: { readonly [x: string]: unknown } @@ -1589,7 +1585,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1606,7 +1602,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.retry.scheduled" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1621,7 +1617,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -1630,16 +1626,21 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly recent: string + readonly inputID?: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1653,16 +1654,21 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + readonly inputID?: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.staged" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -1670,13 +1676,12 @@ export type SessionLogOutput = readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -1686,7 +1691,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.cleared" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -1695,7 +1700,7 @@ export type SessionLogOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.committed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly to: string } } @@ -1755,7 +1760,6 @@ export type SessionMessageOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } - readonly sessionID: string readonly text: string readonly description?: string readonly type: "synthetic" @@ -1772,6 +1776,7 @@ export type SessionMessageOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "skill" + readonly skill: string readonly name: string readonly text: string } @@ -1780,21 +1785,10 @@ export type SessionMessageOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" - readonly shell: { - readonly id: string - readonly status: "running" | "exited" | "timeout" | "killed" - readonly command: string - readonly cwd: string - readonly shell: string - readonly file: string - readonly pid?: number - readonly exit?: number | "Infinity" | "-Infinity" | "NaN" - readonly metadata: { readonly [x: string]: JsonValue } - readonly time: { - readonly started: number | "Infinity" | "-Infinity" | "NaN" - readonly completed?: number | "Infinity" | "-Infinity" | "NaN" - } - } + readonly shellID: string + readonly command: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" readonly output?: { readonly output: string readonly cursor: number @@ -1825,7 +1819,7 @@ export type SessionMessageOutput = { readonly providerState?: { readonly [x: string]: JsonValue } readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: - | { readonly status: "pending"; readonly input: string } + | { readonly status: "streaming"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } @@ -1838,19 +1832,10 @@ export type SessionMessageOutput = { | { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } - readonly attachments?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly structured: { readonly [x: string]: JsonValue } readonly result?: JsonValue } @@ -1865,12 +1850,7 @@ export type SessionMessageOutput = { readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } - readonly time: { - readonly created: number - readonly ran?: number - readonly completed?: number - readonly pruned?: number - } + readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number } } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } @@ -1889,16 +1869,37 @@ export type SessionMessageOutput = { readonly error: { readonly type: string; readonly message: string } } } - | { - readonly type: "compaction" - readonly status: "queued" | "running" | "completed" | "failed" - readonly reason: "auto" | "manual" - readonly summary: string - readonly recent: string - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } - } + | ( + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "running" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "failed" + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + } + ) }["data"] export type MessageListInput = { @@ -1960,7 +1961,6 @@ export type MessageListOutput = { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } - readonly sessionID: string readonly text: string readonly description?: string readonly type: "synthetic" @@ -1977,6 +1977,7 @@ export type MessageListOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number } readonly type: "skill" + readonly skill: string readonly name: string readonly text: string } @@ -1985,21 +1986,10 @@ export type MessageListOutput = { readonly metadata?: { readonly [x: string]: JsonValue } readonly time: { readonly created: number; readonly completed?: number } readonly type: "shell" - readonly shell: { - readonly id: string - readonly status: "running" | "exited" | "timeout" | "killed" - readonly command: string - readonly cwd: string - readonly shell: string - readonly file: string - readonly pid?: number - readonly exit?: number | "Infinity" | "-Infinity" | "NaN" - readonly metadata: { readonly [x: string]: JsonValue } - readonly time: { - readonly started: number | "Infinity" | "-Infinity" | "NaN" - readonly completed?: number | "Infinity" | "-Infinity" | "NaN" - } - } + readonly shellID: string + readonly command: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly exit?: number | "Infinity" | "-Infinity" | "NaN" readonly output?: { readonly output: string readonly cursor: number @@ -2030,7 +2020,7 @@ export type MessageListOutput = { readonly providerState?: { readonly [x: string]: JsonValue } readonly providerResultState?: { readonly [x: string]: JsonValue } readonly state: - | { readonly status: "pending"; readonly input: string } + | { readonly status: "streaming"; readonly input: string } | { readonly status: "running" readonly input: { readonly [x: string]: JsonValue } @@ -2043,19 +2033,10 @@ export type MessageListOutput = { | { readonly status: "completed" readonly input: { readonly [x: string]: JsonValue } - readonly attachments?: ReadonlyArray<{ - readonly data: string - readonly mime: string - readonly source: { readonly type: "inline" } | { readonly type: "uri"; readonly uri: string } - readonly name?: string - readonly description?: string - readonly mention?: { readonly start: number; readonly end: number; readonly text: string } - }> readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly structured: { readonly [x: string]: JsonValue } readonly result?: JsonValue } @@ -2070,12 +2051,7 @@ export type MessageListOutput = { readonly error: { readonly type: string; readonly message: string } readonly result?: JsonValue } - readonly time: { - readonly created: number - readonly ran?: number - readonly completed?: number - readonly pruned?: number - } + readonly time: { readonly created: number; readonly ran?: number; readonly completed?: number } } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } @@ -2094,16 +2070,37 @@ export type MessageListOutput = { readonly error: { readonly type: string; readonly message: string } } } - | { - readonly type: "compaction" - readonly status: "queued" | "running" | "completed" | "failed" - readonly reason: "auto" | "manual" - readonly summary: string - readonly recent: string - readonly id: string - readonly metadata?: { readonly [x: string]: JsonValue } - readonly time: { readonly created: number } - } + | ( + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "running" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "completed" + readonly reason: "auto" | "manual" + readonly summary: string + readonly recent: string + } + | { + readonly type: "compaction" + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly status: "failed" + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + } + ) > readonly cursor: { readonly previous?: string | null; readonly next?: string | null } } @@ -3908,6 +3905,7 @@ export type SkillListOutput = { readonly project: { readonly id: string; readonly directory: string } } readonly data: ReadonlyArray<{ + readonly id: string readonly name: string readonly description?: string readonly slash?: boolean @@ -3963,7 +3961,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.created" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4025,7 +4023,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4087,7 +4085,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.deleted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4149,7 +4147,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4254,7 +4252,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.removed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly messageID: string } } @@ -4263,7 +4261,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4502,7 +4500,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "message.part.removed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly messageID: string; readonly partID: string } } @@ -4511,7 +4509,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.agent.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly agent: string } } @@ -4520,7 +4518,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.model.selected" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4532,7 +4530,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.moved" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4545,7 +4543,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.renamed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly title: string } } @@ -4571,7 +4569,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.deleted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 2 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -4580,7 +4578,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.forked" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string } } @@ -4589,7 +4587,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.promoted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -4598,7 +4596,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.prompt.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4626,7 +4624,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -4635,7 +4633,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.succeeded" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -4644,7 +4642,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly error: { readonly type: string; readonly message: string } } } @@ -4653,7 +4651,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.execution.interrupted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" } } @@ -4662,7 +4660,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.instructions.updated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly text: string } } @@ -4671,7 +4669,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.synthetic" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4685,16 +4683,16 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.skill.activated" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly name: string; readonly text: string } + readonly data: { readonly sessionID: string; readonly id: string; readonly name: string; readonly text: string } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4717,7 +4715,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.shell.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4746,7 +4744,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4761,7 +4759,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4783,7 +4781,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.step.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4803,7 +4801,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number } } @@ -4825,7 +4823,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.text.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4839,7 +4837,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4866,7 +4864,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.reasoning.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4881,7 +4879,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4908,7 +4906,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.input.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4922,7 +4920,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.called" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4938,7 +4936,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.progress" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4956,7 +4954,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.success" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4967,7 +4965,6 @@ export type EventSubscribeOutput = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } > - readonly outputPaths?: ReadonlyArray readonly result?: unknown readonly executed: boolean readonly resultState?: { readonly [x: string]: unknown } @@ -4978,7 +4975,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.tool.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -4995,7 +4992,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.retry.scheduled" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5010,7 +5007,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.admitted" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly inputID: string } } @@ -5019,9 +5016,14 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.started" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly recent: string + readonly inputID?: string + } } | { readonly id: string @@ -5036,7 +5038,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.ended" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5050,16 +5052,21 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.compaction.failed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } - readonly data: { readonly sessionID: string } + readonly data: { + readonly sessionID: string + readonly reason: "auto" | "manual" + readonly error: { readonly type: string; readonly message: string } + readonly inputID?: string + } } | { readonly id: string readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.staged" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string @@ -5067,13 +5074,12 @@ export type EventSubscribeOutput = readonly messageID: string readonly partID?: string readonly snapshot?: string - readonly diff?: string readonly files?: ReadonlyArray<{ - readonly path: string - readonly status: "added" | "modified" | "deleted" + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly patch: string + readonly status: "added" | "deleted" | "modified" }> } } @@ -5083,7 +5089,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.cleared" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string } } @@ -5092,7 +5098,7 @@ export type EventSubscribeOutput = readonly created: number readonly metadata?: { readonly [x: string]: unknown } readonly type: "session.revert.committed" - readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: 1 } readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly sessionID: string; readonly to: string } } @@ -6213,11 +6219,11 @@ export type VcsDiffOutput = { readonly project: { readonly id: string; readonly directory: string } } readonly data: ReadonlyArray<{ - readonly file?: string - readonly patch?: string + readonly file: string + readonly patch: string readonly additions: number readonly deletions: number - readonly status?: "added" | "deleted" | "modified" + readonly status: "added" | "deleted" | "modified" }> } diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index 8e14ad1ee8..553c120f7a 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -38,7 +38,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(ProjectV2.Directory).toBe(Project.Directory) expect(ProjectV2.Directories).toBe(Project.Directories) expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) - expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) + expect(CoreSessionMessage.Info).toBe(SessionMessage.Info) expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.project"].identifier).toBe("server.project") expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups)) diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index a37a3c5bd7..c2cd5a1d21 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -223,7 +223,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/AgentV2.Info" + "$ref": "#/components/schemas/Agent.Info" } } }, @@ -595,7 +595,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -778,7 +778,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -928,7 +928,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -2317,7 +2317,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -2624,7 +2624,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } } }, @@ -3331,7 +3331,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, "required": [ @@ -3594,7 +3594,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" } } }, @@ -3705,7 +3705,7 @@ "data": { "anyOf": [ { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" }, { "type": "null" @@ -7312,7 +7312,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/CommandV2.Info" + "$ref": "#/components/schemas/Command.Info" } } }, @@ -7413,7 +7413,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SkillV2.Info" + "$ref": "#/components/schemas/Skill.Info" } } }, @@ -8508,7 +8508,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } } }, @@ -8605,7 +8605,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -8750,7 +8750,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -8970,7 +8970,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -10200,7 +10200,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -10562,12 +10562,15 @@ "$ref": "#/components/schemas/PermissionV2.Rule" } }, - "AgentV2.Info": { + "Agent.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "name": { + "type": "string" + }, "model": { "$ref": "#/components/schemas/Model.Ref" }, @@ -10608,6 +10611,7 @@ }, "required": [ "id", + "name", "request", "mode", "hidden", @@ -10627,6 +10631,46 @@ ], "additionalProperties": false }, + "Money.USD": { + "type": "number" + }, + "TokenUsage.Info": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, "Location.Ref": { "type": "object", "properties": { @@ -10647,19 +10691,14 @@ ], "additionalProperties": false }, - "File.Diff": { + "FileDiff.Info": { "type": "object", "properties": { - "path": { + "file": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "added", - "modified", - "deleted" - ] + "patch": { + "type": "string" }, "additions": { "type": "integer", @@ -10677,20 +10716,25 @@ } ] }, - "patch": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] } }, "required": [ - "path", - "status", + "file", + "patch", "additions", "deletions", - "patch" + "status" ], "additionalProperties": false }, - "Revert.State": { + "Session.Revert": { "type": "object", "properties": { "messageID": { @@ -10707,13 +10751,10 @@ "snapshot": { "type": "string" }, - "diff": { - "type": "string" - }, "files": { "type": "array", "items": { - "$ref": "#/components/schemas/File.Diff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -10722,7 +10763,7 @@ ], "additionalProperties": false }, - "SessionV2.Info": { + "Session.Info": { "type": "object", "properties": { "id": { @@ -10776,44 +10817,10 @@ "$ref": "#/components/schemas/Model.Ref" }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "time": { "type": "object", @@ -10844,7 +10851,7 @@ "type": "string" }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -10864,7 +10871,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "cursor": { @@ -11667,14 +11674,6 @@ ], "additionalProperties": false }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, "text": { "type": "string" }, @@ -11691,7 +11690,6 @@ "required": [ "id", "time", - "sessionID", "text", "type" ], @@ -11773,6 +11771,9 @@ "skill" ] }, + "skill": { + "type": "string" + }, "name": { "type": "string" }, @@ -11784,187 +11785,12 @@ "id", "time", "type", + "skill", "name", "text" ], "additionalProperties": false }, - "Shell": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^sh_" - } - ] - }, - "status": { - "type": "string", - "enum": [ - "running", - "exited", - "timeout", - "killed" - ] - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string" - }, - "shell": { - "type": "string" - }, - "file": { - "type": "string" - }, - "pid": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "exit": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "started": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "completed": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - } - }, - "required": [ - "started" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "status", - "command", - "cwd", - "shell", - "file", - "metadata", - "time" - ], - "additionalProperties": false - }, "Session.Message.Shell": { "type": "object", "properties": { @@ -12000,8 +11826,62 @@ "shell" ] }, - "shell": { - "$ref": "#/components/schemas/Shell" + "shellID": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "command": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] }, "output": { "type": "object", @@ -12042,7 +11922,9 @@ "id", "time", "type", - "shell" + "shellID", + "command", + "status" ], "additionalProperties": false }, @@ -12105,13 +11987,13 @@ ], "additionalProperties": false }, - "Session.Message.ToolState.Pending": { + "Session.Message.ToolState.Streaming": { "type": "object", "properties": { "status": { "type": "string", "enum": [ - "pending" + "streaming" ] }, "input": { @@ -12221,24 +12103,12 @@ "input": { "type": "object" }, - "attachments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prompt.FileAttachment" - } - }, "content": { "type": "array", "items": { "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "structured": { "type": "object" }, @@ -12330,7 +12200,7 @@ "state": { "anyOf": [ { - "$ref": "#/components/schemas/Session.Message.ToolState.Pending" + "$ref": "#/components/schemas/Session.Message.ToolState.Streaming" }, { "$ref": "#/components/schemas/Session.Message.ToolState.Running" @@ -12354,9 +12224,6 @@ }, "completed": { "type": "number" - }, - "pruned": { - "type": "number" } }, "required": [ @@ -12486,44 +12353,10 @@ ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "error": { "$ref": "#/components/schemas/Session.StructuredError" @@ -12542,7 +12375,7 @@ ], "additionalProperties": false }, - "Session.Message.Compaction": { + "Session.Message.Compaction.Running": { "type": "object", "properties": { "type": { @@ -12551,28 +12384,6 @@ "compaction" ] }, - "status": { - "type": "string", - "enum": [ - "queued", - "running", - "completed", - "failed" - ] - }, - "reason": { - "type": "string", - "enum": [ - "auto", - "manual" - ] - }, - "summary": { - "type": "string" - }, - "recent": { - "type": "string" - }, "id": { "type": "string", "allOf": [ @@ -12595,20 +12406,174 @@ "created" ], "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" } }, "required": [ "type", + "id", + "time", "status", "reason", "summary", - "recent", - "id", - "time" + "recent" ], "additionalProperties": false }, - "Session.Message": { + "Session.Message.Compaction.Completed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "summary", + "recent" + ], + "additionalProperties": false + }, + "Session.Message.Compaction.Failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "error" + ], + "additionalProperties": false + }, + "Session.Message.Compaction": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.Compaction.Running" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Failed" + } + ] + }, + "Session.Message.Info": { "anyOf": [ { "$ref": "#/components/schemas/Session.Message.AgentSelected" @@ -12700,11 +12665,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12787,11 +12750,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12874,11 +12835,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12964,11 +12923,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13051,11 +13008,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 2 ] } }, @@ -13134,11 +13089,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13234,11 +13187,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13326,11 +13277,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13430,11 +13379,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13513,11 +13460,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13596,11 +13541,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13683,11 +13626,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13775,11 +13716,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13862,11 +13801,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13955,11 +13892,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13984,6 +13919,9 @@ } ] }, + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -13993,6 +13931,7 @@ }, "required": [ "sessionID", + "id", "name", "text" ], @@ -14008,7 +13947,7 @@ ], "additionalProperties": false }, - "Shell1": { + "Shell": { "type": "object", "properties": { "id": { @@ -14186,11 +14125,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14216,7 +14153,7 @@ ] }, "shell": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -14273,11 +14210,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14303,7 +14238,7 @@ ] }, "shell": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" }, "output": { "type": "object", @@ -14395,11 +14330,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14498,11 +14431,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14547,44 +14478,10 @@ ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "snapshot": { "type": "string" @@ -14653,11 +14550,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14694,44 +14589,10 @@ "$ref": "#/components/schemas/Session.StructuredError" }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ @@ -14789,11 +14650,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14890,11 +14749,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14998,11 +14855,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15105,11 +14960,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15213,11 +15066,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15313,11 +15164,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15416,11 +15265,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15523,11 +15370,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15633,11 +15478,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15682,12 +15525,6 @@ "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, "executed": { "type": "boolean" @@ -15757,11 +15594,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15865,11 +15700,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15979,11 +15812,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16071,11 +15902,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16106,11 +15935,23 @@ "auto", "manual" ] + }, + "recent": { + "type": "string" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] } }, "required": [ "sessionID", - "reason" + "reason", + "recent" ], "additionalProperties": false } @@ -16162,11 +16003,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16261,11 +16100,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16289,10 +16126,30 @@ "pattern": "^ses" } ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] } }, "required": [ - "sessionID" + "sessionID", + "reason", + "error" ], "additionalProperties": false } @@ -16344,11 +16201,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16374,7 +16229,7 @@ ] }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -16431,11 +16286,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16514,11 +16367,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16568,7 +16419,7 @@ ], "additionalProperties": false }, - "SessionDurableEvent": { + "Session.Event.Durable": { "oneOf": [ { "$ref": "#/components/schemas/session.agent.selected" @@ -16717,7 +16568,7 @@ "SessionLogItem": { "anyOf": [ { - "$ref": "#/components/schemas/SessionDurableEvent" + "$ref": "#/components/schemas/Session.Event.Durable" }, { "$ref": "#/components/schemas/EventLog.Synced" @@ -16737,7 +16588,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, "cursor": { @@ -16823,6 +16674,9 @@ ], "additionalProperties": false }, + "Money.USDPerMillionTokens": { + "type": "number" + }, "Model.Cost": { "type": "object", "properties": { @@ -16846,19 +16700,19 @@ "additionalProperties": false }, "input": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "output": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "cache": { "type": "object", "properties": { "read": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "write": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" } }, "required": [ @@ -16875,7 +16729,7 @@ ], "additionalProperties": false }, - "ModelV2.Info": { + "Model.Info": { "type": "object", "properties": { "id": { @@ -19285,7 +19139,7 @@ ], "additionalProperties": false }, - "CommandV2.Info": { + "Command.Info": { "type": "object", "properties": { "name": { @@ -19313,9 +19167,12 @@ ], "additionalProperties": false }, - "SkillV2.Info": { + "Skill.Info": { "type": "object", "properties": { + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -19336,6 +19193,7 @@ } }, "required": [ + "id", "name", "location", "content" @@ -19569,7 +19427,7 @@ ], "additionalProperties": false }, - "SnapshotFileDiff": { + "FileDiff.LegacyInfo": { "type": "object", "properties": { "file": { @@ -19633,7 +19491,7 @@ "$ref": "#/components/schemas/PermissionRule" } }, - "Session": { + "SessionV1.Info": { "type": "object", "properties": { "id": { @@ -19687,7 +19545,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -19902,11 +19760,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -19932,7 +19788,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -19989,11 +19845,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -20019,7 +19873,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -20076,11 +19930,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -20106,7 +19958,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -20268,7 +20120,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -20936,11 +20788,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -21023,11 +20873,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -22493,11 +22341,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -22584,11 +22430,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -22685,44 +22529,10 @@ ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ @@ -23836,7 +23646,7 @@ "type": "object", "properties": { "info": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -26794,6 +26604,182 @@ ], "additionalProperties": false }, + "Shell1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "completed": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" + ], + "additionalProperties": false + }, "ShellNotFoundError": { "type": "object", "properties": { diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 2dd54123ed..8ba976399d 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -8,6 +8,8 @@ import { State } from "./state" export const ID = Agent.ID export type ID = typeof ID.Type +export const Name = Agent.Name +export type Name = Agent.Name export const defaultID = ID.make("build") export const Color = Agent.Color diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 3cfc799d72..1bb05e0fa1 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -1,6 +1,7 @@ export * as ConfigProviderPlugin from "./provider" import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Money } from "@opencode-ai/schema/money" import { Effect, Stream } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" @@ -91,8 +92,8 @@ export const Plugin = define({ input: cost.input, output: cost.output, cache: { - read: cost.cache?.read ?? 0, - write: cost.cache?.write ?? 0, + read: cost.cache?.read ?? Money.USDPerMillionTokens.zero, + write: cost.cache?.write ?? Money.USDPerMillionTokens.zero, }, })) } diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index ec37182395..e2fa972806 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -1,6 +1,7 @@ export * as ConfigProvider from "./provider" import { Schema } from "effect" +import { Money } from "@opencode-ai/schema/money" import { ModelV2 } from "../model" const JsonRecord = Schema.Record(Schema.String, Schema.Json) @@ -17,8 +18,8 @@ export class Request extends Schema.Class("ConfigV2.Provider.Request")( }) {} class Cache extends Schema.Class("ConfigV2.Model.Cost.Cache")({ - read: Schema.Finite.pipe(Schema.optional), - write: Schema.Finite.pipe(Schema.optional), + read: Money.USDPerMillionTokens.pipe(Schema.optional), + write: Money.USDPerMillionTokens.pipe(Schema.optional), }) {} class Cost extends Schema.Class("ConfigV2.Model.Cost")({ @@ -26,8 +27,8 @@ class Cost extends Schema.Class("ConfigV2.Model.Cost")({ type: Schema.Literal("context"), size: Schema.Int, }).pipe(Schema.optional), - input: Schema.Finite, - output: Schema.Finite, + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, cache: Cache.pipe(Schema.optional), }) {} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 99ca206c1f..5b1735573d 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -48,5 +48,6 @@ export const migrations = ( import("./migration/20260705180000_rename_instructions"), import("./migration/20260706223930_add-session-fork"), import("./migration/20260707010146_durable_session_inbox"), + import("./migration/20260707120000_migrate_prelaunch_v2_state"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts b/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts new file mode 100644 index 0000000000..e921d40904 --- /dev/null +++ b/packages/core/src/database/migration/20260707120000_migrate_prelaunch_v2_state.ts @@ -0,0 +1,227 @@ +import { sql } from "drizzle-orm" +import { Effect, Schema } from "effect" +import type { DatabaseMigration } from "../migration" + +const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString) +const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown)) + +export default { + id: "20260707120000_migrate_prelaunch_v2_state", + up(tx) { + return Effect.gen(function* () { + yield* tx.run( + sql`DELETE FROM session_message WHERE type = 'compaction' AND json_extract(data, '$.status') = 'queued'`, + ) + const messages = yield* tx.all<{ id: string; type: string; data: string }>( + sql`SELECT id, type, data FROM session_message WHERE type IN ('skill', 'shell', 'assistant', 'compaction', 'synthetic')`, + ) + for (const row of messages) { + const data = object(decodeJson(row.data)) + yield* tx.run( + sql`UPDATE session_message SET data = ${JSON.stringify(messageData(row.type, data))} WHERE id = ${row.id}`, + ) + } + + yield* tx.run(sql`DELETE FROM event WHERE type = 'session.compaction.delta.1'`) + const events = yield* tx.all<{ id: string; aggregateID: string; seq: number; type: string; data: string }>(sql` + SELECT id, aggregate_id as aggregateID, seq, type, data + FROM event + WHERE type IN ( + 'session.skill.activated.1', + 'session.skill.activated.2', + 'session.compaction.started.1', + 'session.compaction.started.2', + 'session.compaction.ended.1', + 'session.compaction.failed.1', + 'session.compaction.failed.2', + 'session.revert.staged.1', + 'session.revert.staged.2' + ) + ORDER BY aggregate_id, seq + `) + const compactionReasons = new Map() + for (const row of events) { + const data = object(decodeJson(row.data)) + if (row.type.startsWith("session.compaction.ended.")) { + compactionReasons.delete(row.aggregateID) + continue + } + const event = eventData(row.type, data, compactionReasons.get(row.aggregateID)) + if (row.type.startsWith("session.compaction.started.")) + compactionReasons.set(row.aggregateID, event.data.reason === "auto" ? "auto" : "manual") + if (row.type.startsWith("session.compaction.failed.")) compactionReasons.delete(row.aggregateID) + yield* tx.run( + sql`UPDATE event SET type = ${event.type}, data = ${JSON.stringify(event.data)} WHERE id = ${row.id}`, + ) + } + }) + }, +} satisfies DatabaseMigration.Migration + +function messageData(type: string, data: Record) { + if (type === "skill") + return defined({ + metadata: data.metadata, + time: data.time, + skill: data.skill ?? data.id ?? data.name, + name: data.name, + text: data.text, + }) + if (type === "shell") { + const shell = object(data.shell) + return defined({ + metadata: data.metadata, + time: data.time, + shellID: data.shellID ?? shell.id, + command: data.command ?? shell.command, + status: data.status ?? shell.status, + exit: data.exit ?? shell.exit, + output: data.output, + }) + } + if (type === "assistant") + return defined({ + metadata: data.metadata, + time: data.time, + agent: data.agent, + model: data.model, + content: Array.isArray(data.content) ? data.content.map(assistantContent) : data.content, + snapshot: data.snapshot, + finish: data.finish, + cost: data.cost, + tokens: data.tokens, + error: data.error, + retry: data.retry, + }) + if (type === "compaction") { + if (data.status === "failed") + return defined({ + metadata: data.metadata, + time: data.time, + status: data.status, + reason: data.reason, + error: data.error ?? genericCompactionError, + }) + return defined({ + metadata: data.metadata, + time: data.time, + status: data.status, + reason: data.reason, + summary: data.summary, + recent: data.recent, + }) + } + if (type === "synthetic") + return defined({ metadata: data.metadata, time: data.time, text: data.text, description: data.description }) + const { sessionID: _, ...current } = data + return current +} + +function assistantContent(value: unknown) { + const content = object(value) + if (content.type === "text") return defined({ type: content.type, text: content.text }) + if (content.type === "reasoning") + return defined({ type: content.type, text: content.text, state: content.state, time: content.time }) + if (content.type !== "tool") return content + return defined({ + type: content.type, + id: content.id, + name: content.name, + executed: content.executed, + providerState: content.providerState, + providerResultState: content.providerResultState, + state: toolState(content.state), + time: content.time, + }) +} + +function toolState(value: unknown) { + const state = object(value) + if (state.status === "pending" || state.status === "streaming") + return defined({ status: "streaming", input: state.input }) + if (state.status === "running") + return defined({ status: state.status, input: state.input, structured: state.structured, content: state.content }) + if (state.status === "completed") + return defined({ + status: state.status, + input: state.input, + structured: state.structured, + content: state.content, + result: state.result, + }) + if (state.status === "error") + return defined({ + status: state.status, + input: state.input, + structured: state.structured, + content: state.content, + error: state.error, + result: state.result, + }) + return state +} + +function eventData(type: string, data: Record, compactionReason?: "auto" | "manual") { + if (type.startsWith("session.skill.activated.")) + return { + type: "session.skill.activated.1", + data: defined({ sessionID: data.sessionID, id: data.id ?? data.name, name: data.name, text: data.text }), + } + if (type.startsWith("session.compaction.started.")) + return { + type: "session.compaction.started.1", + data: defined({ + sessionID: data.sessionID, + reason: data.reason, + recent: data.recent ?? "", + inputID: data.inputID, + }), + } + if (type.startsWith("session.compaction.failed.")) + return { + type: "session.compaction.failed.1", + data: defined({ + sessionID: data.sessionID, + reason: data.reason ?? compactionReason ?? "manual", + error: data.error ?? genericCompactionError, + inputID: data.inputID, + }), + } + const revert = object(data.revert) + return { + type: "session.revert.staged.1", + data: defined({ + sessionID: data.sessionID, + revert: defined({ + messageID: revert.messageID, + partID: revert.partID, + snapshot: revert.snapshot, + files: Array.isArray(revert.files) + ? revert.files.map((value) => { + const file = object(value) + return defined({ + file: file.file ?? file.path, + patch: file.patch, + additions: file.additions, + deletions: file.deletions, + status: file.status, + }) + }) + : undefined, + }), + }), + } +} + +const genericCompactionError = { + type: "compaction.failed", + message: "Compaction failed before recording an error", +} + +function object(value: unknown): Record { + return isObject(value) ? value : {} +} + +function defined(value: Record) { + return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined)) +} diff --git a/packages/core/src/file.ts b/packages/core/src/file.ts index 87745c01ee..0f48d9a04c 100644 --- a/packages/core/src/file.ts +++ b/packages/core/src/file.ts @@ -1,6 +1,6 @@ export * as File from "./file" -import { Revert } from "@opencode-ai/schema/revert" +import { FileDiff } from "@opencode-ai/schema/file-diff" -export const Diff = Revert.FileDiff +export const Diff = FileDiff.Info export type Diff = typeof Diff.Type diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 19dcde8961..eea11c95f7 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -606,7 +606,7 @@ const layer = Layer.effect( file, ])).text return { - path: file, + file, status, additions: binary ? 0 : Number(stats[0] ?? 0), deletions: binary ? 0 : Number(stats[1] ?? 0), diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index a88aebfe07..faeee213fb 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -2,6 +2,7 @@ import path from "path" import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { ModelsDev } from "@opencode-ai/schema/models-dev" +import { Money } from "@opencode-ai/schema/money" import { Global } from "./global" import { Flag } from "./flag/flag" import { Flock } from "./util/flock" @@ -18,10 +19,10 @@ export type CatalogModelStatus = typeof CatalogModelStatus.Type const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` const CostTier = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - cache_read: Schema.optional(Schema.Finite), - cache_write: Schema.optional(Schema.Finite), + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, + cache_read: Schema.optional(Money.USDPerMillionTokens), + cache_write: Schema.optional(Money.USDPerMillionTokens), tier: Schema.Struct({ type: Schema.Literal("context"), size: Schema.Finite, @@ -29,17 +30,17 @@ const CostTier = Schema.Struct({ }) const Cost = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - cache_read: Schema.optional(Schema.Finite), - cache_write: Schema.optional(Schema.Finite), + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, + cache_read: Schema.optional(Money.USDPerMillionTokens), + cache_write: Schema.optional(Money.USDPerMillionTokens), tiers: Schema.optional(Schema.Array(CostTier)), context_over_200k: Schema.optional( Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - cache_read: Schema.optional(Schema.Finite), - cache_write: Schema.optional(Schema.Finite), + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, + cache_read: Schema.optional(Money.USDPerMillionTokens), + cache_write: Schema.optional(Money.USDPerMillionTokens), }), ), }) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 7549089bf3..e6e03a4f15 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -125,6 +125,7 @@ export const Plugin = define({ yield* ctx.agent.transform((draft) => { draft.update(AgentV2.defaultID, (item) => { + item.name = AgentV2.Name.make("Build") item.description = "The default agent. Executes tools based on configured permissions." item.mode = "primary" item.permissions.push( @@ -136,6 +137,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("plan"), (item) => { + item.name = AgentV2.Name.make("Plan") item.description = "Plan mode. Disallows all edit tools." item.mode = "primary" item.permissions.push( @@ -155,6 +157,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("general"), (item) => { + item.name = AgentV2.Name.make("General") item.description = "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." item.mode = "subagent" @@ -167,6 +170,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("explore"), (item) => { + item.name = AgentV2.Name.make("Explore") item.description = 'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.' item.system = PROMPT_EXPLORE @@ -189,6 +193,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("compaction"), (item) => { + item.name = AgentV2.Name.make("Compaction") item.mode = "primary" item.hidden = true item.system = PROMPT_COMPACTION @@ -196,6 +201,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("title"), (item) => { + item.name = AgentV2.Name.make("Title") item.mode = "primary" item.hidden = true item.system = PROMPT_TITLE @@ -203,6 +209,7 @@ export const Plugin = define({ }) draft.update(AgentV2.ID.make("summary"), (item) => { + item.name = AgentV2.Name.make("Summary") item.mode = "primary" item.hidden = true item.system = PROMPT_SUMMARY diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 67cf0069dc..ef59535e81 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,5 +1,6 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" +import { Money } from "@opencode-ai/schema/money" +import type { ModelInfo } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" import { ModelV2 } from "../model" @@ -11,13 +12,13 @@ function released(date: string) { return Number.isFinite(time) ? time : 0 } -function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { +function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] { const base = { - input: input?.input ?? 0, - output: input?.output ?? 0, + input: input?.input ?? Money.USDPerMillionTokens.zero, + output: input?.output ?? Money.USDPerMillionTokens.zero, cache: { - read: input?.cache_read ?? 0, - write: input?.cache_write ?? 0, + read: input?.cache_read ?? Money.USDPerMillionTokens.zero, + write: input?.cache_write ?? Money.USDPerMillionTokens.zero, }, } return [ @@ -27,8 +28,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { input: item.input, output: item.output, cache: { - read: item.cache_read ?? 0, - write: item.cache_write ?? 0, + read: item.cache_read ?? Money.USDPerMillionTokens.zero, + write: item.cache_write ?? Money.USDPerMillionTokens.zero, }, })) ?? []), ...(input?.context_over_200k @@ -41,8 +42,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { input: input.context_over_200k.input, output: input.context_over_200k.output, cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero, + write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero, }, }, ] @@ -50,13 +51,13 @@ function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { ] } -function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) { +function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] | undefined) { if (!override) return base const next = cost(override) const [baseDefault, ...baseTiers] = base const [nextDefault, ...nextTiers] = next - const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` - const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({ + const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({ ...left, ...right, tier: right.tier ?? left.tier, @@ -67,12 +68,25 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] const current = tiers.get(tierKey(item)) tiers.set(tierKey(item), current ? merge(current, item) : item) } - return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] + return [ + merge( + baseDefault ?? { + input: Money.USDPerMillionTokens.zero, + output: Money.USDPerMillionTokens.zero, + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + nextDefault, + ), + ...tiers.values(), + ] } const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] -function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable { +function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable { const npm = model.provider?.npm ?? provider.npm const options = model.reasoning_options ?? [] const effort = options.find((option) => option.type === "effort") @@ -117,7 +131,7 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2. function budgetVariants( npm: string | undefined, option: Extract[number], { type: "budget_tokens" }>, -): NonNullable { +): NonNullable { const max = option.max const high = option.max === undefined @@ -146,7 +160,7 @@ function modeName(model: ModelsDev.Model, mode: string) { return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` } -function mergeVariants(model: ModelV2Info, next: NonNullable) { +function mergeVariants(model: ModelInfo, next: NonNullable) { const variants = model.variants ?? [] const existing = new Map(variants.map((variant) => [variant.id, variant])) const nextIDs = new Set(next.map((variant) => variant.id)) @@ -157,13 +171,13 @@ function mergeVariants(model: ModelV2Info, next: NonNullable["modes"]>[string]["provider"] - readonly variants?: NonNullable + readonly variants?: NonNullable } = {}, ) { draft.name = input.name ?? model.name diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 58690df0b2..cca6a2dfa7 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -10,6 +10,7 @@ import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" import { ConfigProviderV1 } from "../../v1/config/provider" +import { Money } from "@opencode-ai/schema/money" import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options" import { ConfigV1 } from "../../v1/config/config" @@ -220,20 +221,23 @@ function withoutCredentials(body: Readonly> | undefined) function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) { const base = { - input: input.input, - output: input.output, - cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 }, + input: Money.USDPerMillionTokens.make(input.input), + output: Money.USDPerMillionTokens.make(input.output), + cache: { + read: Money.USDPerMillionTokens.make(input.cache_read ?? 0), + write: Money.USDPerMillionTokens.make(input.cache_write ?? 0), + }, } if (!input.context_over_200k) return [base] return [ base, { tier: { type: "context" as const, size: 200_000 }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, + input: Money.USDPerMillionTokens.make(input.context_over_200k.input), + output: Money.USDPerMillionTokens.make(input.context_over_200k.output), cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0), + write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0), }, }, ] diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 91c5297c0e..439b77fd36 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -33,7 +33,8 @@ export const Plugin = define({ SkillV2.EmbeddedSource.make({ type: "embedded", skill: SkillV2.Info.make({ - name: "opencode", + id: SkillV2.ID.make("opencode"), + name: SkillV2.Name.make("OpenCode"), description: OpencodeDescription, location: AbsolutePath.make("/builtin/opencode.md"), content: OpencodeContent, @@ -44,7 +45,8 @@ export const Plugin = define({ SkillV2.EmbeddedSource.make({ type: "embedded", skill: SkillV2.Info.make({ - name: "report", + id: SkillV2.ID.make("report"), + name: SkillV2.Name.make("Report"), description: REPORT_DESCRIPTION, slash: true, location: AbsolutePath.make("/builtin/report.md"), @@ -103,15 +105,22 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* ( }) function terminal() { - return [ - process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined, - process.env.TERM ? `TERM=${process.env.TERM}` : undefined, - process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined, - ] - .filter((item): item is string => item !== undefined) - .join(", ") || "Unavailable: terminal environment variables are not set" + return ( + [ + process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined, + process.env.TERM ? `TERM=${process.env.TERM}` : undefined, + process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined, + ] + .filter((item): item is string => item !== undefined) + .join(", ") || "Unavailable: terminal environment variables are not set" + ) } function shell() { - return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set" + return ( + process.env.SHELL ?? + process.env.ComSpec ?? + process.env.COMSPEC ?? + "Unavailable: shell environment variable is not set" + ) } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index ea18ed3909..ad53940971 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect" +import { Effect, Layer, Schema, Context, Stream, Scope } from "effect" import { ListAnchor } from "@opencode-ai/schema/session" import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" @@ -19,6 +19,7 @@ import { SessionSchema } from "./session/schema" import { AbsolutePath, PositiveInt, RelativePath } from "./schema" import { AgentV2 } from "./agent" import { SessionV1 } from "./v1/session" +import { Money } from "@opencode-ai/schema/money" import { InstallationVersion } from "./installation/version" import { Slug } from "./util/slug" import { ProjectTable } from "./project/sql" @@ -34,7 +35,7 @@ import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" -import { Revert } from "@opencode-ai/schema/revert" +import { Session } from "@opencode-ai/schema/session" import { FSUtil } from "./fs-util" import { Mime } from "./mime" import type { EventLog } from "@opencode-ai/schema/event-log" @@ -46,8 +47,8 @@ import { Shell as ShellSchema } from "@opencode-ai/schema/shell" import { KeyedMutex } from "./effect/keyed-mutex" import { fileURLToPath } from "url" -export const RevertState = Revert.State -export type RevertState = Revert.State +export const RevertState = Session.Revert +export type RevertState = Session.Revert // get project -> project.locations // @@ -136,7 +137,7 @@ export class BusyError extends Schema.TaggedErrorClass()("Session.Bus sessionID: SessionSchema.ID, }) {} export class SkillNotFoundError extends Schema.TaggedErrorClass()("Session.SkillNotFoundError", { - skill: Schema.String, + skill: SkillV2.ID, }) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -170,14 +171,14 @@ export interface Interface { id: SessionMessage.ID direction: "previous" | "next" } - }) => Effect.Effect + }) => Effect.Effect readonly message: (input: { sessionID: SessionSchema.ID messageID: SessionMessage.ID - }) => Effect.Effect + }) => Effect.Effect readonly context: ( sessionID: SessionSchema.ID, - ) => Effect.Effect + ) => Effect.Effect /** * Durable, ordered, gap-free session log read. Replays public durable * session events after the exclusive `after` cursor, emits a `Synced` @@ -191,7 +192,10 @@ export interface Interface { after?: number follow?: boolean }) => Stream.Stream - readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect + readonly switchAgent: (input: { + sessionID: SessionSchema.ID + agent: AgentV2.ID + }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref @@ -209,7 +213,7 @@ export interface Interface { sessionID: SessionSchema.ID command: string arguments?: string - agent?: string + agent?: AgentV2.ID model?: ModelV2.Ref files?: PromptInput.Prompt["files"] agents?: PromptInput.Prompt["agents"] @@ -227,7 +231,7 @@ export interface Interface { readonly skill: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID - skill: string + skill: SkillV2.ID resume?: boolean }) => Effect.Effect readonly compact: ( @@ -250,7 +254,7 @@ export interface Interface { sessionID: SessionSchema.ID messageID: SessionMessage.ID files?: boolean - }) => Effect.Effect + }) => Effect.Effect readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect } @@ -273,7 +277,7 @@ const layer = Layer.effect( const scope = yield* Scope.Scope const activeShells = new Set() const shellLocks = KeyedMutex.makeUnsafe() - const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( @@ -322,7 +326,7 @@ const layer = Layer.effect( variant: input.model.variant, } : undefined, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: now, updated: now }, }) @@ -529,7 +533,7 @@ const layer = Layer.effect( }) const model = command.model ?? commandAgent?.model ?? input.model if (agent !== undefined && session.agent !== AgentV2.ID.make(agent)) - yield* result.switchAgent({ sessionID: input.sessionID, agent }) + yield* result.switchAgent({ sessionID: input.sessionID, agent: AgentV2.ID.make(agent) }) if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model }) return yield* result.prompt({ @@ -591,12 +595,13 @@ const layer = Layer.effect( skill: Effect.fn("V2Session.skill")(function* (input) { const session = yield* result.get(input.sessionID) const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location))) - const skill = (yield* skills.list()).find((item) => item.name === input.skill) + const skill = (yield* skills.list()).find((item) => item.id === input.skill) if (!skill) return yield* new SkillNotFoundError({ skill: input.skill }) yield* events.publish( SessionEvent.Skill.Activated, { sessionID: input.sessionID, + id: skill.id, name: skill.name, text: skill.content, }, diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 8b975e3298..ab5e41e405 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -64,19 +64,21 @@ type Dependencies = { export type AutoInput = { readonly sessionID: SessionSchema.ID - readonly messages: readonly SessionMessage.Message[] + readonly messages: readonly SessionMessage.Info[] readonly request: LLMRequest } type CompactInput = { readonly sessionID: SessionSchema.ID - readonly messages: readonly SessionMessage.Message[] + readonly messages: readonly SessionMessage.Info[] readonly model: Model + readonly inputID?: SessionMessage.ID } export type ManualInput = { readonly session: SessionSchema.Info - readonly messages: readonly SessionMessage.Message[] + readonly messages: readonly SessionMessage.Info[] + readonly inputID: SessionMessage.ID } export interface Interface { @@ -99,7 +101,7 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[ ) .join("\n") -const serialize = (message: SessionMessage.Message) => { +const serialize = (message: SessionMessage.Info) => { if (message.type === "user") { const files = message.files?.map( @@ -128,7 +130,7 @@ const serialize = (message: SessionMessage.Message) => { if (message.type === "system") return `[System update]: ${message.text}` if (message.type === "synthetic") return `[Synthetic context]: ${message.text}` if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}` - if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}` + if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}` return "" } @@ -147,7 +149,7 @@ const settings = (documents: readonly Config.Entry[]) => { } const select = ( - messages: readonly SessionMessage.Message[], + messages: readonly SessionMessage.Info[], tokens: number, ): { readonly head: string; readonly recent: string } | undefined => { const conversation = messages @@ -198,6 +200,7 @@ const make = (dependencies: Dependencies) => { readonly context: readonly string[] readonly recent: string readonly output?: number + readonly inputID?: SessionMessage.ID }) { const context = input.model.route.defaults.limits?.context if (context === undefined || context <= 0) return false @@ -208,6 +211,8 @@ const make = (dependencies: Dependencies) => { yield* dependencies.events.publish(SessionEvent.Compaction.Started, { sessionID: input.sessionID, reason: input.reason, + recent: input.recent, + inputID: input.inputID, }) const chunks: string[] = [] @@ -235,9 +240,27 @@ const make = (dependencies: Dependencies) => { }), Effect.as(true), Effect.catchTag("LLM.Error", () => Effect.succeed(false)), + Effect.onInterrupt(() => + input.reason === "auto" + ? dependencies.events.publish(SessionEvent.Compaction.Failed, { + sessionID: input.sessionID, + reason: input.reason, + error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, + inputID: input.inputID, + }) + : Effect.void, + ), ) const summary = chunks.join("") - if (!summarized || failed || !summary.trim()) return false + if (!summarized || failed || !summary.trim()) { + yield* dependencies.events.publish(SessionEvent.Compaction.Failed, { + sessionID: input.sessionID, + reason: input.reason, + error: { type: "compaction.failed", message: "Compaction produced no summary" }, + inputID: input.inputID, + }) + return false + } yield* dependencies.events.publish(SessionEvent.Compaction.Ended, { sessionID: input.sessionID, reason: input.reason, @@ -284,6 +307,7 @@ const make = (dependencies: Dependencies) => { ), recent: forcedShortContext ? "" : selected.recent, output: input.output, + inputID: input.inputID, }) }) const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) { @@ -327,6 +351,7 @@ export const layer = Layer.effect( sessionID: input.session.id, messages: input.messages, model: resolved.model, + inputID: input.inputID, }) }), }) diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index e719366940..f2e3122c96 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -8,7 +8,7 @@ import { InstructionCheckpointTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -const decode = Schema.decodeUnknownEffect(SessionMessage.Message) +const decode = Schema.decodeUnknownEffect(SessionMessage.Info) export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { return yield* db diff --git a/packages/core/src/session/info.ts b/packages/core/src/session/info.ts index 1c8ddf153d..2c507d0058 100644 --- a/packages/core/src/session/info.ts +++ b/packages/core/src/session/info.ts @@ -1,4 +1,4 @@ -import { DateTime } from "effect" +import { DateTime, Schema } from "effect" import { AgentV2 } from "../agent" import { Location } from "../location" import { ModelV2 } from "../model" @@ -9,7 +9,10 @@ import { WorkspaceV2 } from "../workspace" import { SessionSchema } from "./schema" import { SessionTable } from "./sql" import { SessionMessage } from "./message" -import { Snapshot } from "../snapshot" +import { PersistedRevert } from "@opencode-ai/schema/session-revert" +import { Money } from "@opencode-ai/schema/money" + +const decodeRevert = Schema.decodeUnknownSync(PersistedRevert) export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info { return SessionSchema.Info.make({ @@ -31,7 +34,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In variant: ModelV2.VariantID.make(row.model.variant ?? "default"), } : undefined, - cost: row.cost, + cost: Money.USD.make(row.cost), tokens: { input: row.tokens_input, output: row.tokens_output, @@ -46,7 +49,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined, }), subpath: row.path ? RelativePath.make(row.path) : undefined, - revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined, + revert: row.revert ? decodeRevert(row.revert) : undefined, time: { created: DateTime.makeUnsafe(row.time_created), updated: DateTime.makeUnsafe(row.time_updated), diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts index c08439d0c1..41eb36ea5b 100644 --- a/packages/core/src/session/input.ts +++ b/packages/core/src/session/input.ts @@ -2,7 +2,7 @@ export * as SessionInput from "./input" import { and, asc, eq, isNull } from "drizzle-orm" import { DateTime, Effect, Schema } from "effect" -import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input" +import { Admitted, Compaction, Delivery, Info, PromptEntry } from "@opencode-ai/schema/session-input" import type { Database } from "../database/database" import type { EventV2 } from "../event" import { KeyedMutex } from "../effect/keyed-mutex" @@ -14,7 +14,7 @@ import { SessionInputTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] -export { Admitted, Compaction, Delivery, Entry, PromptEntry } +export { Admitted, Compaction, Delivery, Info, PromptEntry } const decodePrompt = Schema.decodeUnknownSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt) @@ -24,7 +24,7 @@ export class LifecycleConflict extends Schema.TaggedErrorClass { +const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => { const base = { admittedSeq: row.admitted_seq, id: SessionMessage.ID.make(row.id), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index dc61f96dd4..01e045ce5f 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -4,7 +4,7 @@ import { SessionEvent } from "./event" import { SessionMessage } from "./message" export type MemoryState = { - messages: SessionMessage.Message[] + messages: SessionMessage.Info[] } export interface Adapter { @@ -14,13 +14,13 @@ export interface Adapter { messageID: SessionMessage.ID, ) => Effect.Effect readonly getShell: ( - shellID: SessionMessage.Shell["shell"]["id"], + shellID: SessionMessage.Shell["shellID"], ) => Effect.Effect readonly getCompaction: () => Effect.Effect readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect - readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect + readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect } export function memory(state: MemoryState): Adapter { @@ -29,9 +29,7 @@ export function memory(state: MemoryState): Adapter { const shellIndex = (messageID: SessionMessage.ID) => state.messages.findLastIndex((message) => message.id === messageID) const compactionIndex = () => - state.messages.findLastIndex( - (message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"), - ) + state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running") // A newer step supersedes stale incomplete rows; never resume an older assistant projection. const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") @@ -64,7 +62,7 @@ export function memory(state: MemoryState): Adapter { getShell(shellID) { return Effect.sync(() => { return state.messages.find((message): message is SessionMessage.Shell => { - return message.type === "shell" && message.shell.id === shellID + return message.type === "shell" && message.shellID === shellID }) }) }, @@ -186,13 +184,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: SessionMessage.ID.fromEvent(event.id), type: "system", text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }), ), "session.synthetic": (event) => { return adapter.appendMessage( SessionMessage.Synthetic.make({ - sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, metadata: event.data.metadata, @@ -207,8 +205,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { SessionMessage.Skill.make({ id: SessionMessage.ID.fromEvent(event.id), type: "skill", + skill: event.data.id, name: event.data.name, text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }), ) @@ -219,7 +219,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: SessionMessage.ID.fromEvent(event.id), type: "shell", metadata: event.metadata, - shell: event.data.shell, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, time: { created: event.created }, }), ) @@ -230,7 +232,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { if (currentShell) { yield* adapter.updateShell( produce(currentShell, (draft) => { - draft.shell = castDraft(event.data.shell) + draft.status = event.data.shell.status + draft.exit = event.data.shell.exit draft.output = event.data.output draft.time.completed = event.created }), @@ -270,6 +273,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { type: "assistant", agent: event.data.agent, model: event.data.model, + metadata: event.metadata, time: { created: event.created }, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, @@ -329,7 +333,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: event.data.callID, name: event.data.name, time: { created: event.created }, - state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }), + state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }), }), ), ) @@ -339,7 +343,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.tool.input.ended": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) - if (match && match.state.status === "pending") match.state.input = event.data.text + if (match && match.state.status === "streaming") match.state.input = event.data.text }) }, "session.tool.called": (event) => { @@ -382,7 +386,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { input: match.state.input, structured: event.data.structured, content: [...event.data.content], - outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [], result: event.data.result, }), ) @@ -392,7 +395,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { "session.tool.failed": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { const match = latestTool(draft, event.data.callID) - if (match && (match.state.status === "pending" || match.state.status === "running")) { + if (match && (match.state.status === "streaming" || match.state.status === "running")) { match.executed = event.data.executed || match.executed === true match.providerResultState = event.data.resultState match.time.completed = event.created @@ -448,31 +451,30 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { } }) }, - "session.compaction.admitted": (event) => + "session.compaction.admitted": () => Effect.void, + "session.compaction.started": (event) => adapter.appendMessage( - SessionMessage.Compaction.make({ - id: event.data.inputID, + SessionMessage.CompactionRunning.make({ + id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id), type: "compaction", - status: "queued", + status: "running", metadata: event.metadata, - reason: "manual", + reason: event.data.reason, summary: "", - recent: "", + recent: event.data.recent ?? "", time: { created: event.created }, }), ), - "session.compaction.started": (event) => + "session.compaction.delta": (event) => Effect.gen(function* () { - if (event.data.reason !== "manual") return const current = yield* adapter.getCompaction() - if (!current) return - yield* adapter.updateCompaction({ ...current, status: "running" }) + if (current?.status !== "running") return + yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text }) }), - "session.compaction.delta": () => Effect.void, "session.compaction.ended": (event) => { return Effect.gen(function* () { - const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined - if (current) { + const current = yield* adapter.getCompaction() + if (current?.status === "running") { yield* adapter.updateCompaction({ ...current, status: "completed", @@ -496,11 +498,20 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }) }, - "session.compaction.failed": () => + "session.compaction.failed": (event) => Effect.gen(function* () { const current = yield* adapter.getCompaction() - if (!current) return - yield* adapter.updateCompaction({ ...current, status: "failed" }) + const failed = SessionMessage.CompactionFailed.make({ + id: current?.id ?? event.data.inputID ?? SessionMessage.ID.fromEvent(event.id), + type: "compaction", + status: "failed", + metadata: current?.metadata ?? event.metadata, + reason: event.data.reason, + error: event.data.error, + time: current?.time ?? { created: event.created }, + }) + if (current?.status === "running") return yield* adapter.updateCompaction(failed) + yield* adapter.appendMessage(failed) }), "session.revert.staged": () => Effect.void, "session.revert.cleared": () => Effect.void, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index f0a2f83d3f..dcf79ff3a1 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -24,15 +24,14 @@ import { } from "./sql" import type { DeepMutable } from "../schema" import { Slug } from "../util/slug" +import { Money } from "@opencode-ai/schema/money" type DatabaseService = Database.Interface["db"] -type MessageEvent = Exclude< - SessionEvent.DurableEvent, - typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type -> +type CurrentDurableEvent = Extract +type MessageEvent = Exclude -const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info) +const encodeMessage = Schema.encodeSync(SessionMessage.Info) export class SessionAlreadyProjected extends Error {} @@ -87,7 +86,14 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning, tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read, tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write, - revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null, + revert: info.revert + ? { + messageID: SessionMessage.ID.make(info.revert.messageID), + partID: info.revert.partID, + snapshot: info.revert.snapshot, + diff: info.revert.diff, + } + : null, permission: info.permission ? [...info.permission] : undefined, time_created: info.time.created, time_updated: info.time.updated, @@ -151,7 +157,7 @@ const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* if (!row) return yield* events.publish(SessionEvent.UsageUpdated, { sessionID, - cost: row.cost, + cost: Money.USD.make(row.cost), tokens: { input: row.input, output: row.output, @@ -257,7 +263,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( eq(SessionMessageTable.session_id, event.data.parentID), gt(SessionMessageTable.seq, cursor), lt(SessionMessageTable.seq, copiedSeq + 1), - sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`, + sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`, ), ) .orderBy(asc(SessionMessageTable.seq)) @@ -280,7 +286,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( seq: row.seq, time_created: row.time_created, time_updated: row.time_updated, - data: row.type === "synthetic" ? { ...row.data, sessionID: event.data.sessionID } : row.data, + data: row.data, } }), ) @@ -336,7 +342,7 @@ function run(db: DatabaseService, event: MessageEvent) { return Effect.gen(function* () { const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => decodeMessage({ ...row.data, id: row.id, type: row.type }) - const updateMessage = (message: SessionMessage.Message) => { + const updateMessage = (message: SessionMessage.Info) => { if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) @@ -353,7 +359,7 @@ function run(db: DatabaseService, event: MessageEvent) { .run() .pipe(Effect.orDie) } - const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message) + const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message) const adapter: SessionMessageUpdater.Adapter = { getModel() { return db @@ -412,7 +418,7 @@ function run(db: DatabaseService, event: MessageEvent) { and( eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell"), - sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`, + sql`json_extract(${SessionMessageTable.data}, '$.shellID') = ${shellID}`, ), ) .orderBy(desc(SessionMessageTable.seq)) @@ -433,7 +439,7 @@ function run(db: DatabaseService, event: MessageEvent) { and( eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction"), - sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`, + sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`, ), ) .orderBy(desc(SessionMessageTable.seq)) @@ -454,7 +460,7 @@ function run(db: DatabaseService, event: MessageEvent) { }) } -function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Message) { +function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) { if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence")) const encoded = encodeMessage(message) const { id, type, ...data } = encoded @@ -661,14 +667,12 @@ const layer = Layer.effectDiscard( Effect.gen(function* () { if (event.durable === undefined) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) - const admitted = yield* SessionInput.projectCompactionAdmitted(db, { + yield* SessionInput.projectCompactionAdmitted(db, { admittedSeq: event.durable.seq, id: event.data.inputID, sessionID: event.data.sessionID, timeCreated: event.created, }) - if (admitted.id !== event.data.inputID) return - yield* run(db, event) }), ) yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event)) @@ -676,15 +680,7 @@ const layer = Layer.effectDiscard( yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event)) yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) - yield* events.project(SessionEvent.Skill.Activated, (event) => - insertMessage(db, event, { - id: SessionMessage.ID.fromEvent(event.id), - type: "skill", - name: event.data.name, - text: event.data.text, - time: { created: event.created }, - }), - ) + yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Step.Started, (event) => run(db, event)) @@ -730,22 +726,26 @@ const layer = Layer.effectDiscard( yield* run(db, event) if (event.durable === undefined) return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence")) - yield* SessionInput.settleCompaction(db, { - sessionID: event.data.sessionID, - handledSeq: event.durable.seq, - }) + if (event.data.reason === "manual") + yield* SessionInput.settleCompaction(db, { + sessionID: event.data.sessionID, + handledSeq: event.durable.seq, + }) }), ) yield* events.project(SessionEvent.RevertEvent.Staged, (event) => - db - .update(SessionTable) - .set({ - revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined }, - time_updated: DateTime.toEpochMillis(event.created), - }) - .where(eq(SessionTable.id, event.data.sessionID)) - .run() - .pipe(Effect.orDie, Effect.asVoid), + Effect.gen(function* () { + const revert = event.data.revert + yield* db + .update(SessionTable) + .set({ + revert: { ...revert, files: revert.files ? [...revert.files] : undefined }, + time_updated: DateTime.toEpochMillis(event.created), + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + }), ) yield* events.project(SessionEvent.RevertEvent.Cleared, (event) => db diff --git a/packages/core/src/session/revert.ts b/packages/core/src/session/revert.ts index 80ec187472..42bc34e694 100644 --- a/packages/core/src/session/revert.ts +++ b/packages/core/src/session/revert.ts @@ -1,7 +1,7 @@ export * as SessionRevert from "./revert" import { and, asc, eq, gt } from "drizzle-orm" -import { DateTime, Effect, Schema } from "effect" +import { Effect, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" import { RelativePath } from "../schema" @@ -46,7 +46,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) { .orderBy(asc(SessionMessageTable.seq)) .all() .pipe(Effect.orDie) - const decode = Schema.decodeUnknownEffect(SessionMessage.Message) + const decode = Schema.decodeUnknownEffect(SessionMessage.Info) const files = new Map() for (const row of rows) { const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie) @@ -70,7 +70,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID }) const restore = new Map() if (original) { - for (const file of input.session.revert?.files ?? []) restore.set(file.path, original) + for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original) } if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree) if (restore.size) yield* snapshot.restore({ files: restore }) @@ -81,10 +81,6 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: { const revert = { messageID: input.messageID, snapshot: original, - diff: files - .map((file) => file.patch) - .join("") - .trim(), files, } satisfies SessionSchema.Info["revert"] yield* events.publish(SessionEvent.RevertEvent.Staged, { @@ -100,7 +96,7 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined if (original) yield* snapshot.restore({ - files: new Map((session.revert.files ?? []).map((file) => [file.path, original])), + files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])), }) const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Cleared, { diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3da43acd8c..dea133ebb9 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -11,6 +11,7 @@ import { type ProviderErrorEvent, } from "@opencode-ai/llm" import { SessionError } from "@opencode-ai/schema/session-error" +import { Money } from "@opencode-ai/schema/money" import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" @@ -67,13 +68,13 @@ export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) { .filter((cost) => cost.tier?.type === "context" && context > cost.tier.size) .toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0] const cost = tier ?? costs.find((cost) => cost.tier === undefined) - if (!cost) return 0 - return ( + if (!cost) return Money.USD.zero + return Money.USD.make( (tokens.input * cost.input + (tokens.output + tokens.reasoning) * cost.output + tokens.cache.read * cost.cache.read + tokens.cache.write * cost.cache.write) / - 1_000_000 + 1_000_000, ) } @@ -165,7 +166,7 @@ const layer = Layer.effect( for (const message of yield* store.context(sessionID)) { if (message.type !== "assistant") continue for (const tool of message.content) { - if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue + if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue yield* events.publish(SessionEvent.Tool.Failed, { sessionID, assistantMessageID: message.id, @@ -300,8 +301,7 @@ const layer = Layer.effect( // Durable publishes are serialized so tool fibers and step settlement never interleave // mid-event. const serialized = (effect: Effect.Effect) => publication.withPermit(effect) - const publish = (event: LLMEvent, outputPaths: ReadonlyArray = [], error?: SessionError.Error) => - serialized(publisher.publish(event, outputPaths, error)) + const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error)) let overflowFailure: ProviderErrorEvent | undefined const providerStream = llm.stream(hookedRequest).pipe( Stream.runForEach((event) => @@ -359,7 +359,6 @@ const layer = Layer.effect( result: settlement.result, output: settlement.output, }), - settlement.outputPaths ?? [], settlement.error, ).pipe( Effect.andThen( @@ -599,12 +598,30 @@ const layer = Layer.effect( return yield* compaction.compactManual({ session, messages: yield* store.context(sessionID), + inputID: pending.id, }) }), ).pipe(Effect.exit) if (Exit.isSuccess(compacted) && compacted.value) return true - yield* events.publish(SessionEvent.Compaction.Failed, { sessionID }) - if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause) + if (Exit.isFailure(compacted)) { + const unsettled = yield* SessionInput.pendingCompaction(db, sessionID) + if (unsettled) + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "manual", + error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) }, + inputID: unsettled.id, + }) + return yield* Effect.failCause(compacted.cause) + } + const unsettled = yield* SessionInput.pendingCompaction(db, sessionID) + if (unsettled) + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "manual", + error: { type: "compaction.failed", message: "Compaction could not start" }, + inputID: unsettled.id, + }) return true }), ) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index c93223c307..e7f5454c39 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -6,13 +6,16 @@ import { SessionEvent } from "../event" import { SessionMessage } from "../message" import { SessionSchema } from "../schema" import { SessionError } from "@opencode-ai/schema/session-error" +import { Money } from "@opencode-ai/schema/money" +import { AgentV2 } from "../../agent" +import { Snapshot } from "../../snapshot" type Input = { readonly sessionID: SessionSchema.ID - readonly agent: string + readonly agent: AgentV2.ID readonly model: ModelV2.Ref readonly provider: string - readonly snapshot?: string + readonly snapshot?: Snapshot.ID readonly assistantMessageID?: SessionMessage.ID } @@ -226,7 +229,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) }) const publishStepFailure = Effect.fnUntraced(function* (usage?: { - readonly cost: number + readonly cost: Money.USD readonly tokens: ReturnType }) { if (stepFailed || stepFailure === undefined) return @@ -265,11 +268,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`)) } - const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( - event: LLMEvent, - outputPaths: ReadonlyArray = [], - error?: SessionError.Error, - ) { + const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) { switch (event.type) { case "step-start": yield* startAssistant() @@ -395,7 +394,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantMessageID: tool.assistantMessageID, callID: event.id, ...result, - outputPaths, ...(executed ? { result: event.result } : {}), executed, resultState, diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 23de369102..048374f519 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -69,7 +69,7 @@ const providerMetadata = ( ): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state }) const toolInput = (tool: SessionMessage.AssistantTool) => - tool.state.status === "pending" + tool.state.status === "streaming" ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input) : tool.state.input @@ -168,7 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { ] } -function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] { +function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref): Message[] { switch (message.type) { case "agent-switched": case "model-switched": @@ -202,7 +202,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess Message.make({ id: message.id, role: "user", - content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`, + content: `Shell command: ${message.command}\n\n${message.output?.output ?? ""}`, metadata: message.metadata, }), ] @@ -232,5 +232,5 @@ ${message.recent} } /** Translate projected V2 Session history into canonical @opencode-ai/llm context. */ -export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) => +export const toLLMMessages = (messages: readonly SessionMessage.Info[], model: ModelV2.Ref) => messages.flatMap((message) => toLLMMessage(message, model)) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index e7d3bdca58..6e09505e3c 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql" import type { SessionMessage } from "./message" import type { Prompt } from "@opencode-ai/schema/prompt" import type { SessionInput } from "./input" -import type { Snapshot } from "../snapshot" +import type { FileDiff } from "@opencode-ai/schema/file-diff" import { PermissionV1 } from "../v1/permission" import { ProjectV2 } from "../project" import type { SessionSchema } from "./schema" @@ -13,10 +13,11 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session" import { WorkspaceV2 } from "../workspace" import { Timestamps } from "../database/schema.sql" import type { Instructions } from "../instructions/index" -import type { Revert } from "@opencode-ai/schema/revert" +import type { Session } from "@opencode-ai/schema/session" +import type { RevertV1 } from "@opencode-ai/schema/session-revert" import type { Schema } from "effect" -type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> +type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id"> type V1MessageData = Omit type V1PartData = Omit @@ -41,7 +42,7 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - summary_diffs: text({ mode: "json" }).$type(), + summary_diffs: text({ mode: "json" }).$type(), metadata: text({ mode: "json" }).$type>(), cost: real().notNull().default(0), tokens_input: integer().notNull().default(0), @@ -49,7 +50,7 @@ export const SessionTable = sqliteTable( tokens_reasoning: integer().notNull().default(0), tokens_cache_read: integer().notNull().default(0), tokens_cache_write: integer().notNull().default(0), - revert: text({ mode: "json" }).$type(), + revert: text({ mode: "json" }).$type(), permission: text({ mode: "json" }).$type(), agent: text(), model: text({ mode: "json" }).$type<{ @@ -148,7 +149,7 @@ export const SessionInputTable = sqliteTable( .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().$type().notNull(), + type: text().$type().notNull(), prompt: text({ mode: "json" }).$type(), delivery: text().$type(), admitted_seq: integer().notNull(), diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 6458aa85fb..24c99cf515 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -13,10 +13,10 @@ import { fromRow } from "./info" export interface Interface { readonly get: (sessionID: Session.ID) => Effect.Effect - readonly context: (sessionID: Session.ID) => Effect.Effect + readonly context: (sessionID: Session.ID) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, - ) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Message } | undefined> + ) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined> } export class Service extends Context.Service()("@opencode/v2/SessionStore") {} @@ -25,7 +25,7 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service - const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info) return Service.of({ get: Effect.fn("SessionStore.get")(function* (sessionID) { diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 511e02af87..13b93004e4 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -28,11 +28,15 @@ export type Source = typeof Source.Type export const Info = Skill.Info export type Info = Skill.Info +export const ID = Skill.ID +export type ID = Skill.ID +export const Name = Skill.Name +export type Name = Skill.Name export const Event = Skill.Event export const available = (skills: ReadonlyArray, agent: AgentV2.Info) => - skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny") + skills.filter((skill) => PermissionV2.evaluate("skill", skill.id, agent.permissions).effect !== "deny") const Frontmatter = Schema.Struct({ name: Schema.String.pipe(Schema.optional), @@ -96,7 +100,7 @@ const layer = Layer.effect( source: Source.key(source), type: source.type, directories: [], - skills: [source.skill.name], + skills: [source.skill.id], }) return { skills: [source.skill], directories: [] } } @@ -112,15 +116,13 @@ const layer = Layer.effect( if (!markdown) continue const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined if (!frontmatter) continue - const name = - frontmatter.name !== undefined - ? frontmatter.name - : path.dirname(filepath) === directory - ? path.basename(filepath, ".md") - : undefined - if (!name) continue + const id = + path.dirname(filepath) === directory + ? path.basename(filepath, ".md") + : path.basename(path.dirname(filepath)) skills.push({ - name, + id: ID.make(id), + name: Name.make(frontmatter.name ?? id), description: frontmatter.description, slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash, autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"), @@ -133,7 +135,7 @@ const layer = Layer.effect( source: Source.key(source), type: source.type, directories, - skills: skills.map((skill) => skill.name), + skills: skills.map((skill) => skill.id), }) return { skills, directories } }) @@ -148,7 +150,7 @@ const layer = Layer.effect( yield* Effect.logInfo("skill cache invalidated", { file, sources: invalidated.map(([key]) => key), - skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.name)), + skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)), }) yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid) }) @@ -159,12 +161,12 @@ const layer = Layer.effect( ) const list = Effect.fn("SkillV2.list")(function* () { - const skills = new Map() + const skills = new Map() for (const source of state.get().sources) { const key = Source.key(source) const loaded = cache.get(key) ?? (yield* load(source)) cache.set(key, loaded) - for (const skill of loaded.skills) skills.set(skill.name, skill) + for (const skill of loaded.skills) skills.set(skill.id, skill) } return Array.from(skills.values()) }) @@ -180,4 +182,8 @@ const layer = Layer.effect( }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] }) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [SkillDiscovery.node, FSUtil.node, EventV2.node], +}) diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 95ebe87eed..5eae66065b 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -8,7 +8,8 @@ import { SkillV2 } from "../skill" import { Instructions } from "../instructions/index" const Summary = Schema.Struct({ - name: Schema.String, + id: SkillV2.ID, + name: SkillV2.Name, description: Schema.String, }) type Summary = typeof Summary.Type @@ -16,6 +17,7 @@ type Summary = typeof Summary.Type const entries = (skills: ReadonlyArray) => skills.flatMap((skill) => [ " ", + ` ${skill.id}`, ` ${skill.name}`, ` ${skill.description}`, " ", @@ -34,8 +36,8 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray skill.name, - (before, after) => before.description !== after.description, + (skill) => skill.id, + (before, after) => before.name !== after.name || before.description !== after.description, ) // Additions and removals render as small deltas; anything else restates the full list. if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) @@ -50,7 +52,7 @@ const update = (previous: ReadonlyArray, current: ReadonlyArray skill.name).join(", ")}.`, + `The following skill IDs are no longer available and must not be used: ${diff.removed.map((skill) => skill.id).join(", ")}.`, ]), ].join("\n") } @@ -77,9 +79,9 @@ const layer = Layer.effect( .flatMap((skill) => skill.description === undefined || skill.autoinvoke === false ? [] - : [{ name: skill.name, description: skill.description }], + : [{ id: skill.id, name: skill.name, description: skill.description }], ) - .toSorted((a, b) => a.name.localeCompare(b.name)) + .toSorted((a, b) => a.id.localeCompare(b.id)) return Instructions.make({ key: Instructions.Key.make("core/skill-guidance"), codec: Schema.toCodecJson(Schema.Array(Summary)), diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 9843e69226..8ef532f488 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -10,10 +10,10 @@ import { Git } from "./git" import { Global } from "./global" import { Location } from "./location" import { AbsolutePath, RelativePath } from "./schema" +import { ID } from "@opencode-ai/schema/snapshot" import { Hash } from "./util/hash" -export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) -export type ID = typeof ID.Type +export { ID } export class Error extends Schema.TaggedErrorClass()("Snapshot.Error", { operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]), @@ -253,12 +253,3 @@ function failure(operation: Error["operation"], cause: unknown) { cause, }) } - -/** Legacy persisted session diff shape. */ -export type LegacyFileDiff = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 8e9133b029..1f80bd9684 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -13,11 +13,11 @@ export const name = "skill" const FILE_LIMIT = 10 export const Input = Schema.Struct({ - name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }), + id: SkillV2.ID.annotate({ description: "The ID of the skill from the available skills list" }), }) export const Output = Schema.Struct({ - name: Schema.String, + name: SkillV2.Name, directory: Schema.String, output: Schema.String, }) @@ -27,7 +27,7 @@ export const description = [ "", "Use this tool to inject the skill's instructions and resources into the current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc. in the same directory as the skill.", "", - "The skill name must match one of the available skills in the instructions.", + "The skill ID must match one of the available skills in the instructions.", ].join("\n") export const toModelOutput = (skill: SkillV2.Info, files: ReadonlyArray) => { @@ -70,13 +70,13 @@ export const Plugin = { execute: (input, context) => Effect.gen(function* () { const current = yield* skills.list() - const skill = current.find((skill) => skill.name === input.name) - if (!skill) return yield* unableToLoad(input.name) + const skill = current.find((skill) => skill.id === input.id) + if (!skill) return yield* unableToLoad(input.id) return yield* Effect.gen(function* () { yield* permission.assert({ action: name, - resources: [skill.name], - save: [skill.name], + resources: [skill.id], + save: [skill.id], sessionID: context.sessionID, agent: context.agent, source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, @@ -94,7 +94,7 @@ export const Plugin = { directory, output: toModelOutput(skill, files), } - }).pipe(Effect.mapError((error) => unableToLoad(input.name, error))) + }).pipe(Effect.mapError((error) => unableToLoad(input.id, error))) }), }), ), diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 83f71c3398..ad667f288c 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect, Fiber, Layer, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" @@ -298,13 +299,31 @@ describe("CatalogV2", () => { catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] - model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(1), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] model.time.released = Date.now() }) catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => { model.capabilities.input = ["text"] model.capabilities.output = ["text"] - model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(10), + output: Money.USDPerMillionTokens.make(10), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, + ] model.time.released = Date.now() }) }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index f371e19543..f3d1defc68 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -4,6 +4,7 @@ import { describe, expect } from "bun:test" import { Effect, PubSub, Schema, Stream } from "effect" import { Config as ConfigSchema } from "@opencode-ai/schema/config" import { CommandV2 } from "@opencode-ai/core/command" +import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -87,7 +88,7 @@ Review files`, name: "review", template: "Review files", description: "File review", - agent: "reviewer", + agent: AgentV2.ID.make("reviewer"), model: { providerID: ProviderV2.ID.make("anthropic"), id: ModelV2.ID.make("claude"), diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index d1d27dc6e9..0c7c85a1ca 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" @@ -253,7 +254,17 @@ describe("ConfigProviderPlugin.Plugin", () => { expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) expect(model.enabled).toBe(false) expect(model.limit).toEqual({ context: 100, output: 75 }) - expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) + expect(model.cost).toEqual([ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + tier: undefined, + }, + ]) expect(model.settings).toEqual({ baseURL: "https://example.test", retained: true }) expect(model.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(model.variants?.map((variant) => variant.id)).toEqual([ diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 4f147c0f3d..10f02a7342 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url" import path from "path" import { SqliteClient } from "@effect/sql-sqlite-bun" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" -import { Effect, Layer } from "effect" +import { Effect, Layer, Schema } from "effect" import { eq, inArray, sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" import { migrations } from "@opencode-ai/core/database/migration.gen" @@ -17,6 +17,7 @@ import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/ import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events" import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox" +import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -26,6 +27,7 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionSchema } from "@opencode-ai/core/session/schema" +import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionTable } from "@opencode-ai/core/session/sql" import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" @@ -42,6 +44,256 @@ const run = (effect: Effect.Effect) => const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { + test("migrates pre-launch V2 state in place", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run( + sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`, + ) + yield* db.run( + sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`, + ) + yield* db.run( + sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`, + ) + const messages = [ + ["msg_skill", "skill", { name: "effect", text: "Use Effect", time: { created: 1 } }], + [ + "msg_shell", + "shell", + { + shell: { id: "sh_old", command: "pwd", status: "exited", exit: 0, cwd: "/tmp" }, + output: { output: "/tmp", cursor: 4, size: 4, truncated: false }, + time: { created: 2, completed: 3 }, + }, + ], + [ + "msg_assistant", + "assistant", + { + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_old", + name: "read", + provider: "removed", + state: { status: "pending", input: '{"path":"README.md"}', title: "removed" }, + time: { created: 3 }, + }, + ], + time: { created: 3 }, + }, + ], + [ + "msg_failed", + "compaction", + { + status: "failed", + reason: "manual", + summary: "removed", + recent: "removed", + time: { created: 4 }, + }, + ], + [ + "msg_queued", + "compaction", + { status: "queued", reason: "manual", summary: "", recent: "", time: { created: 5 } }, + ], + [ + "msg_synthetic", + "synthetic", + { sessionID: "ses_test", text: "context", description: "source", time: { created: 6 } }, + ], + [ + "msg_running", + "compaction", + { status: "running", reason: "auto", summary: "partial", recent: "recent", time: { created: 7 } }, + ], + [ + "msg_completed", + "compaction", + { status: "completed", reason: "auto", summary: "summary", recent: "recent", time: { created: 8 } }, + ], + ] as const + for (const [id, type, data] of messages) + yield* db.run( + sql`INSERT INTO session_message VALUES (${id}, 'ses_test', ${type}, 1, 10, 11, ${JSON.stringify(data)})`, + ) + yield* db.run( + sql`INSERT INTO session_input VALUES ('msg_queued', 'ses_test', 'compaction', NULL, NULL, 4, NULL, 5)`, + ) + yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 9, 'owner')`) + yield* db.run(sql`INSERT INTO instruction_checkpoint VALUES ('ses_test', 'baseline', '{"source":"value"}', 7)`) + const events = [ + ["evt_skill", 1, 101, "session.skill.activated.1", { sessionID: "ses_test", name: "effect", text: "Use" }], + ["evt_started", 2, 102, "session.compaction.started.1", { sessionID: "ses_test", reason: "auto" }], + ["evt_delta", 3, 103, "session.compaction.delta.1", { sessionID: "ses_test", text: "partial" }], + ["evt_failed", 4, 104, "session.compaction.failed.1", { sessionID: "ses_test" }], + [ + "evt_revert", + 5, + 105, + "session.revert.staged.1", + { + sessionID: "ses_test", + revert: { + messageID: "msg_skill", + snapshot: "tree", + diff: "removed", + files: [{ path: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }], + }, + }, + ], + [ + "evt_skill_current", + 6, + 106, + "session.skill.activated.2", + { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" }, + ], + ] as const + for (const [id, seq, created, type, data] of events) + yield* db.run( + sql`INSERT INTO event VALUES (${id}, 'ses_test', ${seq}, ${created}, ${type}, ${JSON.stringify(data)})`, + ) + + yield* DatabaseMigration.applyOnly(db, [migratePrelaunchV2StateMigration]) + + const rows = yield* db.all<{ + id: string + type: string + seq: number + time_created: number + time_updated: number + data: string + }>(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message ORDER BY id`) + for (const row of rows) + Schema.decodeUnknownSync(SessionMessage.Info)({ ...JSON.parse(row.data), id: row.id, type: row.type }) + expect(rows.every((row) => row.seq === 1 && row.time_created === 10 && row.time_updated === 11)).toBe(true) + expect(rows.map((row) => [row.id, JSON.parse(row.data)])).toEqual([ + [ + "msg_assistant", + expect.objectContaining({ + content: [expect.objectContaining({ state: { status: "streaming", input: '{"path":"README.md"}' } })], + }), + ], + ["msg_completed", expect.objectContaining({ status: "completed", summary: "summary", recent: "recent" })], + [ + "msg_failed", + { + time: { created: 4 }, + status: "failed", + reason: "manual", + error: { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + }, + ], + ["msg_running", expect.objectContaining({ status: "running", summary: "partial", recent: "recent" })], + ["msg_shell", expect.objectContaining({ shellID: "sh_old", command: "pwd", status: "exited", exit: 0 })], + ["msg_skill", { time: { created: 1 }, skill: "effect", name: "effect", text: "Use Effect" }], + ["msg_synthetic", { time: { created: 6 }, text: "context", description: "source" }], + ]) + expect(yield* db.get(sql`SELECT * FROM session_input`)).toEqual({ + id: "msg_queued", + session_id: "ses_test", + type: "compaction", + prompt: null, + delivery: null, + admitted_seq: 4, + promoted_seq: null, + time_created: 5, + }) + const migratedEvents = yield* db.all<{ + id: string + aggregate_id: string + seq: number + created: number + type: string + data: string + }>(sql`SELECT * FROM event ORDER BY seq`) + expect(migratedEvents.map((event) => ({ ...event, data: JSON.parse(event.data) }))).toEqual([ + { + id: "evt_skill", + aggregate_id: "ses_test", + seq: 1, + created: 101, + type: "session.skill.activated.1", + data: { sessionID: "ses_test", id: "effect", name: "effect", text: "Use" }, + }, + { + id: "evt_started", + aggregate_id: "ses_test", + seq: 2, + created: 102, + type: "session.compaction.started.1", + data: { sessionID: "ses_test", reason: "auto", recent: "" }, + }, + { + id: "evt_failed", + aggregate_id: "ses_test", + seq: 4, + created: 104, + type: "session.compaction.failed.1", + data: { + sessionID: "ses_test", + reason: "auto", + error: { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + }, + }, + { + id: "evt_revert", + aggregate_id: "ses_test", + seq: 5, + created: 105, + type: "session.revert.staged.1", + data: { + sessionID: "ses_test", + revert: { + messageID: "msg_skill", + snapshot: "tree", + files: [{ file: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }], + }, + }, + }, + { + id: "evt_skill_current", + aggregate_id: "ses_test", + seq: 6, + created: 106, + type: "session.skill.activated.1", + data: { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" }, + }, + ]) + expect(yield* db.get(sql`SELECT * FROM event_sequence`)).toEqual({ + aggregate_id: "ses_test", + seq: 9, + owner_id: "owner", + }) + expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({ + session_id: "ses_test", + baseline: "baseline", + snapshot: '{"source":"value"}', + baseline_seq: 7, + }) + }), + ) + }) + test("resets incompatible V2 Session event history", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/git.test.ts b/packages/core/test/git.test.ts index f77e32fd8b..7441ff83db 100644 --- a/packages/core/test/git.test.ts +++ b/packages/core/test/git.test.ts @@ -146,7 +146,7 @@ describe("Git trees", () => { RelativePath.make("scope/tracked.txt"), ]) const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 1 }) - expect(diffs.map((item) => [item.path, item.status])).toEqual([ + expect(diffs.map((item) => [item.file, item.status])).toEqual([ [RelativePath.make("scope/added.txt"), "added"], [RelativePath.make("scope/tracked.txt"), "modified"], ]) @@ -154,7 +154,7 @@ describe("Git trees", () => { const files = new Map([[RelativePath.make("scope/tracked.txt"), before]]) const preview = yield* git.tree.preview({ repository, current: after, files, context: 1 }) expect(preview).toHaveLength(1) - expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt")) yield* git.tree.restore({ repository, files }) expect(yield* read(path.join(root.path, "scope", "tracked.txt"))).toBe("one\n") expect(yield* read(path.join(root.path, "scope", "added.txt"))).toBe("added\n") diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index c538d1151f..dcfecdb0ae 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -3,6 +3,7 @@ import path from "path" import { describe, expect } from "bun:test" import { Config } from "@opencode-ai/schema/config" import { Plugin } from "@opencode-ai/schema/plugin" +import { Money } from "@opencode-ai/schema/money" import { Context, DateTime, Effect, Equal, Hash, RcMap, Schema, Stream } from "effect" import { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" @@ -356,7 +357,7 @@ describe("LocationServiceMap", () => { id: ModelV2.ID.make("chat"), providerID: ProviderV2.ID.make("unavailable"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location, @@ -405,7 +406,7 @@ describe("LocationServiceMap", () => { providerID: ProviderV2.ID.make("aliased"), variant: ModelV2.VariantID.make("high"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location, diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 545e09e0c3..c336666c86 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -1,5 +1,6 @@ import path from "path" import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect, Layer } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" @@ -51,23 +52,31 @@ describe("ModelsDevPlugin", () => { temperature: true, tool_call: true, cost: { - input: 2.5, - output: 15, + input: Money.USDPerMillionTokens.make(2.5), + output: Money.USDPerMillionTokens.make(15), tiers: [ { tier: { type: "context", size: 272_000 }, - input: 3, - output: 18, - cache_read: 0.25, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(18), + cache_read: Money.USDPerMillionTokens.make(0.25), }, ], - context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 }, + context_over_200k: { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(22.5), + cache_read: Money.USDPerMillionTokens.make(0.5), + }, }, limit: { context: 1_050_000, input: 922_000, output: 128_000 }, experimental: { modes: { fast: { - cost: { input: 5, output: 30, cache_read: 0.5 }, + cost: { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(30), + cache_read: Money.USDPerMillionTokens.make(0.5), + }, provider: { headers: { "x-mode": "fast" }, body: { service_tier: "priority" }, @@ -107,18 +116,31 @@ describe("ModelsDevPlugin", () => { variants: [], }) expect(fast?.cost).toEqual([ - { input: 5, output: 30, cache: { read: 0.5, write: 0 } }, + { + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(30), + cache: { + read: Money.USDPerMillionTokens.make(0.5), + write: Money.USDPerMillionTokens.zero, + }, + }, { tier: { type: "context", size: 272_000 }, - input: 3, - output: 18, - cache: { read: 0.25, write: 0 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(18), + cache: { + read: Money.USDPerMillionTokens.make(0.25), + write: Money.USDPerMillionTokens.zero, + }, }, { tier: { type: "context", size: 200_000 }, - input: 5, - output: 22.5, - cache: { read: 0.5, write: 0 }, + input: Money.USDPerMillionTokens.make(5), + output: Money.USDPerMillionTokens.make(22.5), + cache: { + read: Money.USDPerMillionTokens.make(0.5), + write: Money.USDPerMillionTokens.zero, + }, }, ]) }), diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index f20091d514..13109550c7 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -1,4 +1,5 @@ import { AISDK } from "@opencode-ai/core/aisdk" +import { Money } from "@opencode-ai/schema/money" import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" @@ -185,7 +186,16 @@ describe("OpenAIPlugin", () => { draft.package = item.package }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => { - model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }] + model.cost = [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.make(0.1), + write: Money.USDPerMillionTokens.zero, + }, + }, + ] }) catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {}) catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {}) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index c8b3757d3d..f8334026f9 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { Money } from "@opencode-ai/schema/money" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" @@ -65,7 +66,16 @@ function withEnv(vars: Record, effect: () = ) } -const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0, write: 0 } }] +const cost = (input: number, output = 0) => [ + { + input: Money.USDPerMillionTokens.make(input), + output: Money.USDPerMillionTokens.make(output), + cache: { + read: Money.USDPerMillionTokens.zero, + write: Money.USDPerMillionTokens.zero, + }, + }, +] describe("OpencodePlugin", () => { it.effect("registers account and service account methods", () => diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 2bcf675052..de0f745183 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -37,17 +37,19 @@ describe("SkillPlugin.Plugin", () => { Effect.provide(NodeFileSystem.layer), ) const skills = yield* skill.list() - const report = skills.find((item) => item.name === "report") + const report = skills.find((item) => item.id === "report") expect(skills).toContainEqual( expect.objectContaining({ - name: "opencode", + id: "opencode", + name: "OpenCode", description: expect.stringContaining("any question about OpenCode itself"), }), ) expect(skills).toContainEqual( expect.objectContaining({ - name: "report", + id: "report", + name: "Report", description: expect.stringContaining("opencode issue"), }), ) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index 40d9f48a19..703b098f63 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/schema/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -104,13 +105,10 @@ describe("SessionV2.compact", () => { expect(second.id).toBe(first.id) expect(requests).toHaveLength(0) - expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({ - type: "compaction", - status: "queued", - reason: "manual", - summary: "", - recent: "", + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, created.id)).toMatchObject({ + id: first.id, }) + expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined() }), ) }) diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index d4d96abbc4..e8fb3addec 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -141,7 +141,13 @@ it.effect("manual compaction summarizes short context instead of no-op", () => .subscribe(SessionEvent.Compaction.Delta) .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) yield* Effect.yieldNow - expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true) + expect( + yield* compaction.compactManual({ + session, + messages: [userMessage], + inputID: SessionMessage.ID.make("msg_manual_compaction"), + }), + ).toBe(true) expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"]) expect(requests).toHaveLength(1) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 86e593c722..3c4bb65a44 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import path from "path" import { DateTime, Effect, Layer, Stream } from "effect" +import { Money } from "@opencode-ai/schema/money" import { AgentV2 } from "@opencode-ai/core/agent" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" @@ -210,7 +211,7 @@ describe("SessionV2.create", () => { expect(forked.parentID).toBeUndefined() expect(forkContext).toMatchObject([ { type: "user", text: "First" }, - { type: "synthetic", text: "parent note", sessionID: forked.id }, + { type: "synthetic", text: "parent note" }, ]) expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id)) expect(history).toHaveLength(1) @@ -273,14 +274,14 @@ describe("SessionV2.create", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID: parent.id, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model, }) yield* events.publish(SessionEvent.Step.Ended, { sessionID: parent.id, assistantMessageID, finish: "stop", - cost: 0.75, + cost: Money.USD.make(0.75), tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, }) @@ -533,7 +534,7 @@ describe("SessionV2.create", () => { const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") - expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } }) + expect(shell).toMatchObject({ type: "shell", command: "echo hello", status: "exited", exit: 0 }) expect(shell?.output?.output).toContain("hello") expect(shell?.output?.truncated).toBe(false) expect(shell?.time.completed).toBeDefined() @@ -553,8 +554,8 @@ describe("SessionV2.create", () => { const messages = yield* session.messages({ sessionID: created.id, order: "asc" }) const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell") - expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } }) - expect(shell?.shell.exit).not.toBe(0) + expect(shell).toMatchObject({ type: "shell", command: "false", status: "exited" }) + expect(shell?.exit).not.toBe(0) expect(shell?.time.completed).toBeDefined() }), ), @@ -565,7 +566,7 @@ describe("SessionV2.create", () => { const session = yield* SessionV2.Service const created = yield* session.create({ location }) - yield* session.switchAgent({ sessionID: created.id, agent: "plan" }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("plan") }) expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) expect( @@ -580,7 +581,7 @@ describe("SessionV2.create", () => { const missing = SessionV2.ID.make("ses_missing_agent_switch") expect( - yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe( + yield* session.switchAgent({ sessionID: missing, agent: AgentV2.ID.make("plan") }).pipe( Effect.flip, Effect.map((error) => error._tag), ), diff --git a/packages/core/test/session-instructions.test.ts b/packages/core/test/session-instructions.test.ts index 854cd73d4b..34439836a4 100644 --- a/packages/core/test/session-instructions.test.ts +++ b/packages/core/test/session-instructions.test.ts @@ -303,7 +303,6 @@ describe("SessionInstructions", () => { const synthetic = SessionMessage.Synthetic.make({ id: SessionMessage.ID.make("msg_synthetic"), type: "synthetic", - sessionID: SessionV2.ID.make("ses_test"), text: "Instructions from: /repo/sub/AGENTS.md\ncontent", description: "Loaded /repo/sub/AGENTS.md", metadata: { instruction: { paths: ["/repo/sub/AGENTS.md"] } }, diff --git a/packages/core/test/session-log.test.ts b/packages/core/test/session-log.test.ts index ac2fe59496..a6c7797e0f 100644 --- a/packages/core/test/session-log.test.ts +++ b/packages/core/test/session-log.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema, Stream } from "effect" import { Database } from "@opencode-ai/core/database/database" +import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -87,11 +88,11 @@ describe("SessionV2.log", () => { const session = yield* SessionV2.Service const events = yield* EventV2.Service const created = yield* session.create({ location }) - yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("one") }) // Not in the durable manifest, so reads must skip it without failing. yield* events.publish(GapEvent, { sessionID: created.id, value: "filtered" }) - yield* session.switchAgent({ sessionID: created.id, agent: "two" }) - yield* session.switchAgent({ sessionID: created.id, agent: "three" }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("two") }) + yield* session.switchAgent({ sessionID: created.id, agent: AgentV2.ID.make("three") }) const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id, after: 1 }))) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index cc0655c55f..1eb7f6b6fd 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Option, Schema, Stream } from "effect" -import { asc, eq } from "drizzle-orm" +import { asc, eq, sql } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { AgentV2 } from "@opencode-ai/core/agent" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EventV2 } from "@opencode-ai/core/event" @@ -15,9 +16,11 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/schema/prompt" +import { Money } from "@opencode-ai/schema/money" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { fromRow } from "@opencode-ai/core/session/info" import { SessionInput } from "@opencode-ai/core/session/input" import { Shell } from "@opencode-ai/schema/shell" import { @@ -35,7 +38,8 @@ const sessionID = SessionV2.ID.make("ses_projector_test") const created = DateTime.makeUnsafe(0) const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") } const previousModel = { ...model, variant: ModelV2.VariantID.make("medium") } -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const encodeMessage = Schema.encodeSync(SessionMessage.Info) +const build = AgentV2.defaultID const assistantRow = ( id: SessionMessage.ID, @@ -48,12 +52,108 @@ const assistantRow = ( type, ...data } = encodeMessage( - SessionMessage.Assistant.make({ id, type: "assistant", agent: "build", model, content: [], time, ...usage }), + SessionMessage.Assistant.make({ id, type: "assistant", agent: build, model, content: [], time, ...usage }), ) return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data } } describe("SessionProjector", () => { + it.effect("does not settle a pending manual compaction on an auto failure", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + const events = yield* EventV2.Service + const inputID = SessionMessage.ID.make("msg_manual_compaction") + yield* SessionInput.admitCompaction(db, events, { id: inputID, sessionID }) + + yield* events.publish(SessionEvent.Compaction.Failed, { + sessionID, + reason: "auto", + error: { type: "compaction.failed", message: "Auto compaction failed" }, + }) + + expect(yield* SessionInput.pendingCompaction(db, sessionID)).toMatchObject({ id: inputID }) + }), + ) + + it.effect("loads legacy revert storage into canonical state", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + yield* db + .insert(ProjectTable) + .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: Project.ID.global, + slug: "test", + directory: "/project", + title: "test", + version: "test", + }) + .run() + const legacy = JSON.stringify({ + messageID: "msg_boundary", + snapshot: "tree", + diff: "legacy patch", + files: [{ path: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }], + }) + yield* db.run(sql`update session set revert = ${legacy} where id = ${sessionID}`) + const stored = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get() + if (!stored) return yield* Effect.die("Session row missing") + const storedRevert = fromRow(stored).revert + expect(String(storedRevert?.messageID)).toBe("msg_boundary") + expect(String(storedRevert?.snapshot)).toBe("tree") + expect(storedRevert?.files).toEqual([ + { file: "src/old.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }, + ]) + }), + ) + + it.effect("folds live compaction deltas into running memory state", () => + Effect.gen(function* () { + const state = { + messages: [ + SessionMessage.CompactionRunning.make({ + id: SessionMessage.ID.make("msg_compaction"), + type: "compaction", + status: "running", + reason: "manual", + summary: "partial ", + recent: "recent", + time: { created }, + }), + ], + } + yield* SessionMessageUpdater.update( + SessionMessageUpdater.memory(state), + SessionEvent.Compaction.Delta.make({ + id: EventV2.ID.make("evt_delta"), + type: "session.compaction.delta", + created, + data: { sessionID, text: "summary" }, + }), + ) + expect(state.messages[0]).toMatchObject({ status: "running", summary: "partial summary", recent: "recent" }) + }), + ) + it.effect("projects staged, cleared, and committed reverts", () => Effect.gen(function* () { const db = (yield* Database.Service).db @@ -89,7 +189,7 @@ describe("SessionProjector", () => { 1, { created }, { - cost: 0.5, + cost: Money.USD.make(0.5), tokens: { input: 4, output: 1, reasoning: 1, cache: { read: 1, write: 0 } }, }, ), @@ -98,7 +198,7 @@ describe("SessionProjector", () => { 2, { created }, { - cost: 0.75, + cost: Money.USD.make(0.75), tokens: { input: 6, output: 3, reasoning: 1, cache: { read: 2, write: 1 } }, }, ), @@ -111,7 +211,7 @@ describe("SessionProjector", () => { const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, - revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), diff: "patch", files: [] }, + revert: { messageID: boundary, snapshot: Snapshot.ID.make("tree"), files: [] }, }) expect((yield* db.select({ revert: SessionTable.revert }).from(SessionTable).get())?.revert).toMatchObject({ messageID: boundary, @@ -132,7 +232,7 @@ describe("SessionProjector", () => { (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), ).toEqual([earlier]) expect(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()).toMatchObject({ - cost: 1.25, + cost: Money.USD.make(1.25), tokens_input: 10, tokens_output: 4, tokens_reasoning: 2, @@ -285,7 +385,7 @@ describe("SessionProjector", () => { yield* events.publish(SessionEvent.AgentSelected, { sessionID, - agent: "build", + agent: build, }) yield* events.publish(SessionEvent.ModelSelected, { sessionID, @@ -327,6 +427,7 @@ describe("SessionProjector", () => { yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", + recent: "recent context", }) yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, @@ -336,18 +437,18 @@ describe("SessionProjector", () => { yield* db .select({ id: EventTable.id }) .from(EventTable) - .where(eq(EventTable.type, SessionEvent.Compaction.Delta.type)) + .where(sql`${EventTable.type} like 'session.compaction.delta.%'`) .all() .pipe(Effect.orDie), - ).toEqual([]) + ).toHaveLength(0) expect( yield* db - .select({ id: SessionMessageTable.id }) + .select({ data: SessionMessageTable.data }) .from(SessionMessageTable) .where(eq(SessionMessageTable.type, "compaction")) .all() .pipe(Effect.orDie), - ).toEqual([]) + ).toEqual([{ data: expect.objectContaining({ status: "running", summary: "", recent: "recent context" }) }]) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, reason: "manual", @@ -363,7 +464,7 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }), ) expect(messages.map((message) => message.type)).toEqual([ @@ -379,7 +480,9 @@ describe("SessionProjector", () => { }) expect(messages.find((message) => message.type === "model-switched")).toMatchObject({ previous: previousModel }) expect(messages.find((message) => message.type === "shell")).toMatchObject({ - shell: { command: "pwd", status: "exited", exit: 0 }, + command: "pwd", + status: "exited", + exit: 0, output: { output: "/project", truncated: false }, time: { completed: DateTime.makeUnsafe(0) }, }) @@ -419,11 +522,7 @@ describe("SessionProjector", () => { .pipe(Effect.orDie) const events = yield* EventV2.Service const id = SessionMessage.ID.make("msg_creator_collision") - const { - id: _, - type, - ...data - } = encodeMessage({ id, sessionID, type: "synthetic", text: "existing", time: { created } }) + const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } }) yield* db .insert(SessionMessageTable) .values({ id, session_id: sessionID, type, seq: 0, time_created: 0, data }) @@ -433,7 +532,7 @@ describe("SessionProjector", () => { .publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: id, - agent: "build", + agent: build, model, }) .pipe(Effect.exit) @@ -450,7 +549,7 @@ describe("SessionProjector", () => { const stale = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", - agent: "build", + agent: build, model, content: [], time: { created }, @@ -458,7 +557,7 @@ describe("SessionProjector", () => { const completed = SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", - agent: "build", + agent: build, model, content: [], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, @@ -493,7 +592,7 @@ describe("SessionProjector", () => { const events = yield* EventV2.Service const first = SessionMessage.ID.make("msg_retry_first") const second = SessionMessage.ID.make("msg_retry_second") - yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model }) + yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model }) yield* events.publish(SessionEvent.RetryScheduled, { sessionID, assistantMessageID: first, @@ -503,7 +602,7 @@ describe("SessionProjector", () => { }) const decode = (row: typeof SessionMessageTable.$inferSelect) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }) + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }) const firstRow = yield* db .select() .from(SessionMessageTable) @@ -515,7 +614,7 @@ describe("SessionProjector", () => { retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } }, }) - yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model }) + yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model }) yield* events.publish(SessionEvent.RetryScheduled, { sessionID, assistantMessageID: second, @@ -574,7 +673,7 @@ describe("SessionProjector", () => { sessionID, assistantMessageID: SessionMessage.ID.make("msg_assistant_2"), finish: "stop", - cost: 1.25, + cost: Money.USD.make(1.25), tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, }) @@ -586,13 +685,13 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }), ) expect(messages[0]).not.toHaveProperty("time.completed") expect(messages[1]).toMatchObject({ type: "assistant", finish: "stop", - cost: 1.25, + cost: Money.USD.make(1.25), tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, time: { completed: DateTime.makeUnsafe(0) }, }) @@ -608,7 +707,7 @@ describe("SessionProjector", () => { }) expect(Option.getOrThrow(yield* Fiber.join(usageUpdated)).data).toEqual({ sessionID, - cost: 1.25, + cost: Money.USD.make(1.25), tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }, }) }), @@ -661,13 +760,13 @@ describe("SessionProjector", () => { .all() .pipe(Effect.orDie) const messages = rows.map((row) => - Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type }), + Schema.decodeUnknownSync(SessionMessage.Info)({ ...row.data, id: row.id, type: row.type }), ) expect(messages).toEqual([ SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_completed"), type: "assistant", - agent: "build", + agent: build, model, content: [SessionMessage.AssistantText.make({ type: "text", text: "" })], time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, @@ -675,7 +774,7 @@ describe("SessionProjector", () => { SessionMessage.Assistant.make({ id: SessionMessage.ID.make("msg_assistant_stale"), type: "assistant", - agent: "build", + agent: build, model, content: [], time: { created }, diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 3e76ddbb42..34b1ca1487 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -6,6 +6,7 @@ import path from "path" import { pathToFileURL } from "url" import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { AgentV2 } from "@opencode-ai/core/agent" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -105,7 +106,7 @@ const eventCount = (type: string) => ), ) -const encodeMessage = Schema.encodeSync(SessionMessage.Message) +const encodeMessage = Schema.encodeSync(SessionMessage.Info) const assistantRow = (id: SessionMessage.ID, seq: number) => { const { id: _, @@ -115,7 +116,7 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => { SessionMessage.Assistant.make({ id, type: "assistant", - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [], time: { created: DateTime.makeUnsafe(0) }, @@ -677,7 +678,6 @@ describe("SessionV2.prompt", () => { ...data } = encodeMessage({ id: messageID, - sessionID, type: "synthetic", text: "Existing history", time: { created: DateTime.makeUnsafe(0) }, diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index fb79b35d11..5910c4e4fb 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -5,13 +5,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionMessage } from "@opencode-ai/core/session/message" import { AgentAttachment, Base64, FileAttachment } from "@opencode-ai/schema/prompt" import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" -import { SessionV2 } from "@opencode-ai/core/session" +import { AgentV2 } from "@opencode-ai/core/agent" import { Shell } from "@opencode-ai/schema/shell" import { DateTime } from "effect" const created = DateTime.makeUnsafe(0) const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = ModelV2.Ref.make({ id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }) +const build = AgentV2.defaultID describe("toLLMMessages", () => { test("omits empty assistant turns", () => { @@ -19,7 +20,7 @@ describe("toLLMMessages", () => { SessionMessage.Assistant.make({ id: id(value), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content, time: { created, completed: created }, @@ -56,7 +57,7 @@ describe("toLLMMessages", () => { SessionMessage.AgentSelected.make({ id: id("agent"), type: "agent-switched", - agent: "build", + agent: build, time: { created }, }), SessionMessage.ModelSelected.make({ @@ -82,24 +83,16 @@ describe("toLLMMessages", () => { SessionMessage.Synthetic.make({ id: id("synthetic"), type: "synthetic", - sessionID: SessionV2.ID.make("ses_translate"), text: "Synthetic context", time: { created }, }), SessionMessage.Shell.make({ id: id("shell"), type: "shell", - shell: Shell.Info.make({ - id: Shell.ID.make("sh_test"), - status: "exited", - command: "pwd", - cwd: "/project", - shell: "/bin/sh", - file: "/tmp/sh_test.out", - exit: 0, - metadata: {}, - time: { started: 0, completed: 0 }, - }), + shellID: Shell.ID.make("sh_test"), + status: "exited", + command: "pwd", + exit: 0, output: { output: "/project", cursor: 8, size: 8, truncated: false }, time: { created, completed: created }, }), @@ -282,7 +275,7 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantText.make({ type: "text", text: "Checking" }), @@ -295,7 +288,7 @@ Recent work type: "tool", id: "pending", name: "read", - state: SessionMessage.ToolStatePending.make({ status: "pending", input: '{"path":"README.md"}' }), + state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: '{"path":"README.md"}' }), time: { created }, }), SessionMessage.AssistantTool.make({ @@ -437,7 +430,7 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-openai-reasoning"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ @@ -467,7 +460,7 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-failed"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ @@ -536,7 +529,7 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-old-model"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("old-model"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ @@ -631,7 +624,7 @@ Recent work SessionMessage.Assistant.make({ id: id("assistant-alias"), type: "assistant", - agent: "build", + agent: build, model: { id: ModelV2.ID.make("fast"), providerID: ProviderV2.ID.make("provider") }, content: [ SessionMessage.AssistantReasoning.make({ diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 14bfc37bbe..c2e2dd2557 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { LLM, Model } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { DateTime, Effect } from "effect" +import { Money } from "@opencode-ai/schema/money" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" @@ -131,7 +132,7 @@ describe("SessionRunnerModel", () => { providerID: catalog.providerID, variant: ModelV2.VariantID.make("high"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -170,7 +171,7 @@ describe("SessionRunnerModel", () => { projectID: ProjectV2.ID.global, title: "test", model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -200,7 +201,7 @@ describe("SessionRunnerModel", () => { providerID: catalog.providerID, variant: ModelV2.VariantID.make("unknown"), }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, @@ -236,7 +237,7 @@ describe("SessionRunnerModel", () => { projectID: ProjectV2.ID.global, title: "test", model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") }, - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, location: { directory: AbsolutePath.make("/project") }, diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index d3aaf152c4..4b53ae4729 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -1,7 +1,9 @@ import { expect, test } from "bun:test" import { Effect, Schema, Stream } from "effect" import { LLMEvent } from "@opencode-ai/llm" +import { Money } from "@opencode-ai/schema/money" import { EventV2 } from "@opencode-ai/core/event" +import { AgentV2 } from "@opencode-ai/core/agent" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionV2 } from "@opencode-ai/core/session" @@ -40,7 +42,7 @@ const capture = () => { published, publisher: createLLMEventPublisher(events, { sessionID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -193,7 +195,7 @@ test("content-filter finish retains failure evidence until step closeout", async if (!settlement) throw new Error("Expected content-filter settlement") await Effect.runPromise( publisher.publishStepFailure({ - cost: 1.25, + cost: Money.USD.make(1.25), tokens: settlement.tokens, }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index da8d9539c7..fd34a1954e 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -30,6 +30,7 @@ import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionMessage } from "@opencode-ai/core/session/message" import { PromptInput } from "@opencode-ai/schema/prompt-input" +import { Money } from "@opencode-ai/schema/money" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" @@ -135,8 +136,23 @@ test("calculates step cost using the matching context tier", () => { expect( SessionRunnerLLM.calculateCost( [ - { input: 1, output: 2, cache: { read: 0.1, write: 0.5 } }, - { tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } }, + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.make(0.1), + write: Money.USDPerMillionTokens.make(0.5), + }, + }, + { + tier: { type: "context", size: 100 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(4), + cache: { + read: Money.USDPerMillionTokens.make(0.2), + write: Money.USDPerMillionTokens.make(0.6), + }, + }, ], { input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 1 } }, ), @@ -146,10 +162,20 @@ test("calculates step cost using the matching context tier", () => { test("does not apply an ineligible tier without base pricing", () => { expect( SessionRunnerLLM.calculateCost( - [{ tier: { type: "context", size: 100 }, input: 3, output: 4, cache: { read: 0.2, write: 0.6 } }], + [ + { + tier: { type: "context", size: 100 }, + input: Money.USDPerMillionTokens.make(3), + output: Money.USDPerMillionTokens.make(4), + cache: { + read: Money.USDPerMillionTokens.make(0.2), + write: Money.USDPerMillionTokens.make(0.6), + }, + }, + ], { input: 80, output: 10, reasoning: 2, cache: { read: 20, write: 0 } }, ), - ).toBe(0) + ).toBe(Money.USD.zero) }) const authorizations: Tool.Context[] = [] @@ -485,7 +511,7 @@ const recordedStepSettlementEvents = (id: SessionV2.ID, assistantMessageID: Sess const hostedCall = (id: string, query: string) => LLMEvent.toolCall({ id, name: "web_search", input: { query }, providerExecuted: true }) -const requireAssistant = (messages: readonly SessionMessage.Message[]) => { +const requireAssistant = (messages: readonly SessionMessage.Info[]) => { const assistant = messages.find((message) => message.type === "assistant") if (!assistant) throw new Error("Assistant message missing") return assistant @@ -581,7 +607,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string LLMEvent.toolInputStart({ id, name: "echo" }), ...chunks.map((text) => LLMEvent.toolInputDelta({ id, name: "echo", text })), ] - const expectedContent = { type: "tool", id, state: { status: "pending", input: text } } + const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } } return { delta: SessionEvent.Tool.Input.Delta, partialEvents, @@ -1056,7 +1082,7 @@ describe("SessionRunnerLLM", () => { skillBaselines.set(AgentV2.ID.make("reviewer"), "Reviewer skills") yield* events.publish(SessionEvent.AgentSelected, { sessionID, - agent: "reviewer", + agent: AgentV2.ID.make("reviewer"), }) yield* admit(session, "Second") yield* session.resume(sessionID) @@ -1082,7 +1108,7 @@ describe("SessionRunnerLLM", () => { return events .publish(SessionEvent.AgentSelected, { sessionID, - agent: "reviewer", + agent: AgentV2.ID.make("reviewer"), }) .pipe(Effect.asVoid) }) @@ -1265,6 +1291,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", + recent: "", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, @@ -1308,10 +1335,7 @@ describe("SessionRunnerLLM", () => { expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toMatchObject({ id: first.id, }) - expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({ - type: "compaction", - status: "queued", - }) + expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined() yield* admit(session, "Steer after compaction") yield* session.prompt({ @@ -1370,6 +1394,57 @@ describe("SessionRunnerLLM", () => { type: "compaction", status: "failed", }) + expect( + (yield* recordedEventTypes(sessionID)).filter( + (type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1), + ), + ).toHaveLength(1) + }), + ) + + it.effect("settles an admitted manual compaction that cannot start", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const compaction = yield* session.compact({ sessionID }) + + yield* session.resume(sessionID) + + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + reason: "manual", + error: { message: "Compaction could not start" }, + }) + expect( + (yield* recordedEventTypes(sessionID)).filter( + (type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1), + ), + ).toHaveLength(1) + }), + ) + + it.effect("settles an admitted manual compaction when pre-start resolution throws", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const compaction = yield* session.compact({ sessionID }) + modelResolveHook = Effect.die("model resolution failed") + + expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" }) + + expect(yield* SessionInput.pendingCompaction((yield* Database.Service).db, sessionID)).toBeUndefined() + expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({ + type: "compaction", + status: "failed", + reason: "manual", + }) + expect( + (yield* recordedEventTypes(sessionID)).filter( + (type) => type === EventV2.versionedType(SessionEvent.Compaction.Failed.type, 1), + ), + ).toHaveLength(1) }), ) @@ -1511,9 +1586,10 @@ describe("SessionRunnerLLM", () => { expect(requests).toHaveLength(2) const context = yield* session.context(sessionID) - expect(context.some((message) => message.type === "compaction")).toBe(false) - expect(context.slice(-2)).toMatchObject([ + expect(context).toContainEqual(expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" })) + expect(context.slice(-3)).toMatchObject([ { type: "user", text: "Continue" }, + { type: "compaction", status: "failed", reason: "auto" }, { type: "assistant", finish: "error", error: { message: "prompt too long" } }, ]) }), @@ -1540,7 +1616,9 @@ describe("SessionRunnerLLM", () => { expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" }) streamGate = undefined expect(requests).toHaveLength(2) - expect((yield* session.context(sessionID)).some((message) => message.type === "compaction")).toBe(false) + expect(yield* session.context(sessionID)).toContainEqual( + expect.objectContaining({ type: "compaction", status: "failed", reason: "auto" }), + ) }), ) @@ -1557,6 +1635,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", + recent: "", }) yield* events.publish(SessionEvent.Compaction.Ended, { sessionID, @@ -2299,7 +2378,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { @@ -2356,7 +2435,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { @@ -2407,7 +2486,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("fake-model"), providerID: ProviderV2.ID.make("fake") }, }) yield* events.publish(SessionEvent.Tool.Input.Started, { diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 8f9803ef4a..d8cd7fbb56 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -26,7 +26,8 @@ const skills = Layer.mock(SkillV2.Service, { list: () => Effect.succeed([ SkillV2.Info.make({ - name: "effect", + id: SkillV2.ID.make("effect"), + name: SkillV2.Name.make("Effect"), description: "Effect guidance", location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), content: "Use Effect", @@ -60,10 +61,10 @@ describe("SessionV2.skill", () => { const session = yield* sessions.create({ location }) const id = SessionMessage.ID.make("msg_caller_skill") - yield* sessions.skill({ id, sessionID: session.id, skill: "effect", resume: false }) + yield* sessions.skill({ id, sessionID: session.id, skill: SkillV2.ID.make("effect"), resume: false }) expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual( - expect.objectContaining({ id, type: "skill", name: "effect", text: "Use Effect" }), + expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: "Use Effect" }), ) }), ) diff --git a/packages/core/test/session-tool-progress.test.ts b/packages/core/test/session-tool-progress.test.ts index 8344771d2a..e61bb54b63 100644 --- a/packages/core/test/session-tool-progress.test.ts +++ b/packages/core/test/session-tool-progress.test.ts @@ -4,6 +4,7 @@ import { DateTime, Effect, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" +import { AgentV2 } from "@opencode-ai/core/agent" import { EventTable } from "@opencode-ai/core/event/sql" import { ModelV2 } from "@opencode-ai/core/model" import { Project } from "@opencode-ai/core/project" @@ -51,7 +52,7 @@ describe("Tool.Progress", () => { yield* service.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, - agent: "build", + agent: AgentV2.ID.make("build"), model, }) const readAssistant = Effect.gen(function* () { diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index ae68d6c7ff..100d44cfe2 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -76,6 +76,7 @@ test("Core reuses the canonical shared schemas", async () => { const schemas = [ [AgentV2.ID, Agent.ID], + [AgentV2.Name, Agent.Name], [AgentV2.Color, Agent.Color], [AgentV2.Info, Agent.Info], [coreCommand.Info, Command.Info], @@ -145,7 +146,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionMessage.Synthetic, SessionMessage.Synthetic], [coreSessionMessage.System, SessionMessage.System], [coreSessionMessage.Shell, SessionMessage.Shell], - [coreSessionMessage.ToolStatePending, SessionMessage.ToolStatePending], + [coreSessionMessage.ToolStateStreaming, SessionMessage.ToolStateStreaming], [coreSessionMessage.ToolStateRunning, SessionMessage.ToolStateRunning], [coreSessionMessage.ToolStateCompleted, SessionMessage.ToolStateCompleted], [coreSessionMessage.ToolStateError, SessionMessage.ToolStateError], @@ -156,7 +157,7 @@ test("Core reuses the canonical shared schemas", async () => { [coreSessionMessage.AssistantContent, SessionMessage.AssistantContent], [coreSessionMessage.Assistant, SessionMessage.Assistant], [coreSessionMessage.Compaction, SessionMessage.Compaction], - [coreSessionMessage.Message, SessionMessage.Message], + [coreSessionMessage.Info, SessionMessage.Info], [coreSessionTodo.Info, SessionTodo.Info], [coreSessionTodo.Event, SessionTodo.Event], [coreSkill.DirectorySource, Skill.DirectorySource], diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index b9193023bf..1b8d846e69 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -88,13 +88,15 @@ describe("SkillV2", () => { ]) expect(yield* skill.list()).toEqual([ SkillV2.Info.make({ - name: "foo", + id: SkillV2.ID.make("foo"), + name: SkillV2.Name.make("foo"), slash: true, location: AbsolutePath.make(path.join(first, "foo.md")), content: "# foo", }), { - name: "review", + id: SkillV2.ID.make("review"), + name: SkillV2.Name.make("review"), description: "Second", location: AbsolutePath.make(path.join(second, "review", "SKILL.md")), content: "# review", @@ -129,8 +131,8 @@ describe("SkillV2", () => { const skill = yield* SkillV2.Service yield* skill.transform((editor) => editor.source({ type: "url", url: "https://example.test/skills/" })) - expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) - expect((yield* skill.list()).map((item) => item.name)).toEqual(["deploy"]) + expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")]) + expect((yield* skill.list()).map((item) => item.name)).toEqual([SkillV2.Name.make("deploy")]) expect(pulls).toBe(1) expect(SkillV2.available(yield* skill.list(), (yield* agents.get(AgentV2.ID.make("reviewer")))!)).toEqual([]) }), @@ -165,7 +167,8 @@ metadata: expect(yield* skill.list()).toEqual([ { - name: "manual", + id: SkillV2.ID.make("manual"), + name: SkillV2.Name.make("manual"), description: "Manual only", slash: true, autoinvoke: false, diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 704f42b803..1c5b5b2839 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -11,24 +11,28 @@ import { it } from "../lib/effect" const build = AgentV2.ID.make("build") const effect = SkillV2.Info.make({ - name: "effect", + id: SkillV2.ID.make("effect"), + name: SkillV2.Name.make("Effect"), description: "Build applications with Effect", location: AbsolutePath.make(path.resolve("/skills/effect/SKILL.md")), content: "Effect guidance", }) const hidden = SkillV2.Info.make({ - name: "hidden", + id: SkillV2.ID.make("hidden"), + name: SkillV2.Name.make("Hidden"), location: AbsolutePath.make(path.resolve("/skills/hidden/SKILL.md")), content: "Undescribed guidance", }) const denied = SkillV2.Info.make({ - name: "denied", + id: SkillV2.ID.make("denied"), + name: SkillV2.Name.make("Denied"), description: "Must not be advertised", location: AbsolutePath.make(path.resolve("/skills/denied/SKILL.md")), content: "Denied guidance", }) const manual = SkillV2.Info.make({ - name: "manual", + id: SkillV2.ID.make("manual"), + name: SkillV2.Name.make("Manual"), description: "Load only when explicitly selected", autoinvoke: false, location: AbsolutePath.make(path.resolve("/skills/manual/SKILL.md")), @@ -59,7 +63,8 @@ describe("SkillGuidance", () => { "Use the skill tool to load a skill when a task matches its description.", "", " ", - " effect", + " effect", + " Effect", " Build applications with Effect", " ", "", @@ -74,7 +79,7 @@ describe("SkillGuidance", () => { .pipe(Effect.flatMap((context) => Instructions.reconcile(context, initialized.applied))), ).toMatchObject({ _tag: "Updated", - text: "The following skills are no longer available and must not be used: effect.", + text: "The following skill IDs are no longer available and must not be used: effect.", }) }).pipe(Effect.provide(layer(() => skills))) }) @@ -82,7 +87,8 @@ describe("SkillGuidance", () => { it.effect("announces added and removed skills as deltas without restating the list", () => { const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) const debugging = SkillV2.Info.make({ - name: "debugging", + id: SkillV2.ID.make("debugging"), + name: SkillV2.Name.make("Debugging"), description: "Diagnose hard bugs", location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")), content: "Debugging guidance", @@ -103,7 +109,8 @@ describe("SkillGuidance", () => { text: [ "New skills are available in addition to those previously listed:", " ", - " debugging", + " debugging", + " Debugging", " Diagnose hard bugs", " ", ].join("\n"), @@ -117,7 +124,7 @@ describe("SkillGuidance", () => { ) expect(removed).toMatchObject({ _tag: "Updated", - text: "The following skills are no longer available and must not be used: effect.", + text: "The following skill IDs are no longer available and must not be used: effect.", }) }).pipe(Effect.provide(layer(() => skills))) }) @@ -192,7 +199,7 @@ describe("SkillGuidance", () => { const guidance = yield* SkillGuidance.Service expect( (yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(Instructions.initialize))).text, - ).toContain("effect") + ).toContain("Effect") }).pipe(Effect.provide(layer(() => [effect]))) }) diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 5e01fefc17..3bc0f34101 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -56,7 +56,7 @@ describe("Snapshot", () => { const plan = new Map([[RelativePath.make("scope/tracked.txt"), before]]) const preview = yield* snapshot.preview({ files: plan, context: 1 }) expect(preview).toHaveLength(1) - expect(preview[0]?.path).toBe(RelativePath.make("scope/tracked.txt")) + expect(preview[0]?.file).toBe(RelativePath.make("scope/tracked.txt")) yield* snapshot.restore({ files: plan }) expect(yield* read(path.join(location, "tracked.txt"))).toBe("one\n") expect(yield* read(path.join(location, "added.txt"))).toBe("added\n") diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index e92465fc3d..6a6a53e171 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -3,6 +3,7 @@ import { realpathSync } from "node:fs" import path from "path" import { describe, expect, test } from "bun:test" import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect" +import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" @@ -104,7 +105,7 @@ const executionNode = makeGlobalNode({ sessionID: id, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, }) }) @@ -442,10 +443,7 @@ describe("ShellTool", () => { reset() return withSession(tmp.path, (registry) => Effect.gen(function* () { - const settled = yield* settleTool( - registry, - call({ command: idleCommand, timeout: 50, background: true }), - ) + const settled = yield* settleTool(registry, call({ command: idleCommand, timeout: 50, background: true })) const structured = settled.output?.structured as Record | undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined expect(settled.output?.structured).toMatchObject({ truncated: false }) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 6a49fa22fa..8d81917d8c 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -26,7 +26,7 @@ const skillToolNode = makeLocationNode({ const sessionID = SessionV2.ID.make("ses_skill_tool_test") describe("SkillTool", () => { - it.live("lists available skills, authorizes the selected name, and loads model-facing content", () => + it.live("lists available skills, authorizes the selected ID, and loads model-facing content", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -42,7 +42,8 @@ describe("SkillTool", () => { ) const info: SkillV2.Info = { - name: "effect", + id: SkillV2.ID.make("effect"), + name: SkillV2.Name.make("Effect"), description: "Use Effect", location: AbsolutePath.make(location), content: "# Effect\n\nGuidance", @@ -102,7 +103,7 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-skill", name: "skill", input: { name: "effect" } }, + call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } }, }), ).toEqual({ type: "text", @@ -113,11 +114,11 @@ describe("SkillTool", () => { yield* settleTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { name: "effect" } }, + call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } }, }), ).toMatchObject({ result: { type: "text", value: SkillTool.toModelOutput(info, [reference]) }, - output: { structured: { name: "effect" } }, + output: { structured: { name: "Effect" } }, }) expect(assertions).toMatchObject([ { sessionID, action: "skill", resources: ["effect"], save: ["effect"] }, @@ -127,7 +128,7 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { name: "missing" } }, + call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } }, }), ).toEqual({ type: "error", value: "Unable to load skill missing" }) deny = true @@ -135,12 +136,13 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { name: "effect" } }, + call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } }, }), ).toEqual({ type: "error", value: "Unable to load skill effect" }) deny = false const flat = SkillV2.Info.make({ - name: "public", + id: SkillV2.ID.make("public"), + name: SkillV2.Name.make("Public"), description: "Public guidance", location: AbsolutePath.make(path.join(tmp.path, "public.md")), content: "Public", @@ -156,7 +158,7 @@ describe("SkillTool", () => { yield* executeTool(registry, { sessionID, ...toolIdentity, - call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } }, + call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } }, }), ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) }).pipe(Effect.provide(skillToolLayer)) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 92dc0e833b..d2e6396710 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Layer, Schema } from "effect" +import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" @@ -70,7 +71,7 @@ const executionNode = makeGlobalNode({ sessionID, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens, }) }) diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index a37a3c5bd7..c2cd5a1d21 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -223,7 +223,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/AgentV2.Info" + "$ref": "#/components/schemas/Agent.Info" } } }, @@ -595,7 +595,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -778,7 +778,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -928,7 +928,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "required": [ @@ -2317,7 +2317,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -2624,7 +2624,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } } }, @@ -3331,7 +3331,7 @@ "type": "object", "properties": { "data": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, "required": [ @@ -3594,7 +3594,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" } } }, @@ -3705,7 +3705,7 @@ "data": { "anyOf": [ { - "$ref": "#/components/schemas/ModelV2.Info" + "$ref": "#/components/schemas/Model.Info" }, { "type": "null" @@ -7312,7 +7312,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/CommandV2.Info" + "$ref": "#/components/schemas/Command.Info" } } }, @@ -7413,7 +7413,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SkillV2.Info" + "$ref": "#/components/schemas/Skill.Info" } } }, @@ -8508,7 +8508,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } } }, @@ -8605,7 +8605,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -8750,7 +8750,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -8970,7 +8970,7 @@ "$ref": "#/components/schemas/Location.Info" }, "data": { - "$ref": "#/components/schemas/Shell" + "$ref": "#/components/schemas/Shell1" } }, "required": [ @@ -10200,7 +10200,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -10562,12 +10562,15 @@ "$ref": "#/components/schemas/PermissionV2.Rule" } }, - "AgentV2.Info": { + "Agent.Info": { "type": "object", "properties": { "id": { "type": "string" }, + "name": { + "type": "string" + }, "model": { "$ref": "#/components/schemas/Model.Ref" }, @@ -10608,6 +10611,7 @@ }, "required": [ "id", + "name", "request", "mode", "hidden", @@ -10627,6 +10631,46 @@ ], "additionalProperties": false }, + "Money.USD": { + "type": "number" + }, + "TokenUsage.Info": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": [ + "read", + "write" + ], + "additionalProperties": false + } + }, + "required": [ + "input", + "output", + "reasoning", + "cache" + ], + "additionalProperties": false + }, "Location.Ref": { "type": "object", "properties": { @@ -10647,19 +10691,14 @@ ], "additionalProperties": false }, - "File.Diff": { + "FileDiff.Info": { "type": "object", "properties": { - "path": { + "file": { "type": "string" }, - "status": { - "type": "string", - "enum": [ - "added", - "modified", - "deleted" - ] + "patch": { + "type": "string" }, "additions": { "type": "integer", @@ -10677,20 +10716,25 @@ } ] }, - "patch": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "added", + "deleted", + "modified" + ] } }, "required": [ - "path", - "status", + "file", + "patch", "additions", "deletions", - "patch" + "status" ], "additionalProperties": false }, - "Revert.State": { + "Session.Revert": { "type": "object", "properties": { "messageID": { @@ -10707,13 +10751,10 @@ "snapshot": { "type": "string" }, - "diff": { - "type": "string" - }, "files": { "type": "array", "items": { - "$ref": "#/components/schemas/File.Diff" + "$ref": "#/components/schemas/FileDiff.Info" } } }, @@ -10722,7 +10763,7 @@ ], "additionalProperties": false }, - "SessionV2.Info": { + "Session.Info": { "type": "object", "properties": { "id": { @@ -10776,44 +10817,10 @@ "$ref": "#/components/schemas/Model.Ref" }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "time": { "type": "object", @@ -10844,7 +10851,7 @@ "type": "string" }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -10864,7 +10871,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionV2.Info" + "$ref": "#/components/schemas/Session.Info" } }, "cursor": { @@ -11667,14 +11674,6 @@ ], "additionalProperties": false }, - "sessionID": { - "type": "string", - "allOf": [ - { - "pattern": "^ses" - } - ] - }, "text": { "type": "string" }, @@ -11691,7 +11690,6 @@ "required": [ "id", "time", - "sessionID", "text", "type" ], @@ -11773,6 +11771,9 @@ "skill" ] }, + "skill": { + "type": "string" + }, "name": { "type": "string" }, @@ -11784,187 +11785,12 @@ "id", "time", "type", + "skill", "name", "text" ], "additionalProperties": false }, - "Shell": { - "type": "object", - "properties": { - "id": { - "type": "string", - "allOf": [ - { - "pattern": "^sh_" - } - ] - }, - "status": { - "type": "string", - "enum": [ - "running", - "exited", - "timeout", - "killed" - ] - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string" - }, - "shell": { - "type": "string" - }, - "file": { - "type": "string" - }, - "pid": { - "type": "integer", - "allOf": [ - { - "minimum": 0 - } - ] - }, - "exit": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "metadata": { - "type": "object" - }, - "time": { - "type": "object", - "properties": { - "started": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - }, - "completed": { - "anyOf": [ - { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": [ - "NaN" - ] - }, - { - "type": "string", - "enum": [ - "Infinity" - ] - }, - { - "type": "string", - "enum": [ - "-Infinity" - ] - } - ] - }, - { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - } - ] - } - }, - "required": [ - "started" - ], - "additionalProperties": false - } - }, - "required": [ - "id", - "status", - "command", - "cwd", - "shell", - "file", - "metadata", - "time" - ], - "additionalProperties": false - }, "Session.Message.Shell": { "type": "object", "properties": { @@ -12000,8 +11826,62 @@ "shell" ] }, - "shell": { - "$ref": "#/components/schemas/Shell" + "shellID": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "command": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] }, "output": { "type": "object", @@ -12042,7 +11922,9 @@ "id", "time", "type", - "shell" + "shellID", + "command", + "status" ], "additionalProperties": false }, @@ -12105,13 +11987,13 @@ ], "additionalProperties": false }, - "Session.Message.ToolState.Pending": { + "Session.Message.ToolState.Streaming": { "type": "object", "properties": { "status": { "type": "string", "enum": [ - "pending" + "streaming" ] }, "input": { @@ -12221,24 +12103,12 @@ "input": { "type": "object" }, - "attachments": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prompt.FileAttachment" - } - }, "content": { "type": "array", "items": { "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "structured": { "type": "object" }, @@ -12330,7 +12200,7 @@ "state": { "anyOf": [ { - "$ref": "#/components/schemas/Session.Message.ToolState.Pending" + "$ref": "#/components/schemas/Session.Message.ToolState.Streaming" }, { "$ref": "#/components/schemas/Session.Message.ToolState.Running" @@ -12354,9 +12224,6 @@ }, "completed": { "type": "number" - }, - "pruned": { - "type": "number" } }, "required": [ @@ -12486,44 +12353,10 @@ ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "error": { "$ref": "#/components/schemas/Session.StructuredError" @@ -12542,7 +12375,7 @@ ], "additionalProperties": false }, - "Session.Message.Compaction": { + "Session.Message.Compaction.Running": { "type": "object", "properties": { "type": { @@ -12551,28 +12384,6 @@ "compaction" ] }, - "status": { - "type": "string", - "enum": [ - "queued", - "running", - "completed", - "failed" - ] - }, - "reason": { - "type": "string", - "enum": [ - "auto", - "manual" - ] - }, - "summary": { - "type": "string" - }, - "recent": { - "type": "string" - }, "id": { "type": "string", "allOf": [ @@ -12595,20 +12406,174 @@ "created" ], "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "running" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" } }, "required": [ "type", + "id", + "time", "status", "reason", "summary", - "recent", - "id", - "time" + "recent" ], "additionalProperties": false }, - "Session.Message": { + "Session.Message.Compaction.Completed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "completed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "summary": { + "type": "string" + }, + "recent": { + "type": "string" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "summary", + "recent" + ], + "additionalProperties": false + }, + "Session.Message.Compaction.Failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": [ + "created" + ], + "additionalProperties": false + }, + "status": { + "type": "string", + "enum": [ + "failed" + ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + } + }, + "required": [ + "type", + "id", + "time", + "status", + "reason", + "error" + ], + "additionalProperties": false + }, + "Session.Message.Compaction": { + "anyOf": [ + { + "$ref": "#/components/schemas/Session.Message.Compaction.Running" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Completed" + }, + { + "$ref": "#/components/schemas/Session.Message.Compaction.Failed" + } + ] + }, + "Session.Message.Info": { "anyOf": [ { "$ref": "#/components/schemas/Session.Message.AgentSelected" @@ -12700,11 +12665,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12787,11 +12750,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12874,11 +12835,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -12964,11 +12923,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13051,11 +13008,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 2 ] } }, @@ -13134,11 +13089,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13234,11 +13187,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13326,11 +13277,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13430,11 +13379,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13513,11 +13460,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13596,11 +13541,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13683,11 +13626,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13775,11 +13716,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13862,11 +13801,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13955,11 +13892,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -13984,6 +13919,9 @@ } ] }, + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -13993,6 +13931,7 @@ }, "required": [ "sessionID", + "id", "name", "text" ], @@ -14008,7 +13947,7 @@ ], "additionalProperties": false }, - "Shell1": { + "Shell": { "type": "object", "properties": { "id": { @@ -14186,11 +14125,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14216,7 +14153,7 @@ ] }, "shell": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -14273,11 +14210,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14303,7 +14238,7 @@ ] }, "shell": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" }, "output": { "type": "object", @@ -14395,11 +14330,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14498,11 +14431,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14547,44 +14478,10 @@ ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" }, "snapshot": { "type": "string" @@ -14653,11 +14550,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14694,44 +14589,10 @@ "$ref": "#/components/schemas/Session.StructuredError" }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ @@ -14789,11 +14650,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14890,11 +14749,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -14998,11 +14855,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15105,11 +14960,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15213,11 +15066,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15313,11 +15164,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15416,11 +15265,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15523,11 +15370,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15633,11 +15478,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15682,12 +15525,6 @@ "$ref": "#/components/schemas/LLM.ToolContent" } }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, "result": {}, "executed": { "type": "boolean" @@ -15757,11 +15594,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15865,11 +15700,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -15979,11 +15812,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16071,11 +15902,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16106,11 +15935,23 @@ "auto", "manual" ] + }, + "recent": { + "type": "string" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] } }, "required": [ "sessionID", - "reason" + "reason", + "recent" ], "additionalProperties": false } @@ -16162,11 +16003,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16261,11 +16100,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16289,10 +16126,30 @@ "pattern": "^ses" } ] + }, + "reason": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "error": { + "$ref": "#/components/schemas/Session.StructuredError" + }, + "inputID": { + "type": "string", + "allOf": [ + { + "pattern": "^msg_" + } + ] } }, "required": [ - "sessionID" + "sessionID", + "reason", + "error" ], "additionalProperties": false } @@ -16344,11 +16201,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16374,7 +16229,7 @@ ] }, "revert": { - "$ref": "#/components/schemas/Revert.State" + "$ref": "#/components/schemas/Session.Revert" } }, "required": [ @@ -16431,11 +16286,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16514,11 +16367,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -16568,7 +16419,7 @@ ], "additionalProperties": false }, - "SessionDurableEvent": { + "Session.Event.Durable": { "oneOf": [ { "$ref": "#/components/schemas/session.agent.selected" @@ -16717,7 +16568,7 @@ "SessionLogItem": { "anyOf": [ { - "$ref": "#/components/schemas/SessionDurableEvent" + "$ref": "#/components/schemas/Session.Event.Durable" }, { "$ref": "#/components/schemas/EventLog.Synced" @@ -16737,7 +16588,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/Session.Message" + "$ref": "#/components/schemas/Session.Message.Info" } }, "cursor": { @@ -16823,6 +16674,9 @@ ], "additionalProperties": false }, + "Money.USDPerMillionTokens": { + "type": "number" + }, "Model.Cost": { "type": "object", "properties": { @@ -16846,19 +16700,19 @@ "additionalProperties": false }, "input": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "output": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "cache": { "type": "object", "properties": { "read": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" }, "write": { - "type": "number" + "$ref": "#/components/schemas/Money.USDPerMillionTokens" } }, "required": [ @@ -16875,7 +16729,7 @@ ], "additionalProperties": false }, - "ModelV2.Info": { + "Model.Info": { "type": "object", "properties": { "id": { @@ -19285,7 +19139,7 @@ ], "additionalProperties": false }, - "CommandV2.Info": { + "Command.Info": { "type": "object", "properties": { "name": { @@ -19313,9 +19167,12 @@ ], "additionalProperties": false }, - "SkillV2.Info": { + "Skill.Info": { "type": "object", "properties": { + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -19336,6 +19193,7 @@ } }, "required": [ + "id", "name", "location", "content" @@ -19569,7 +19427,7 @@ ], "additionalProperties": false }, - "SnapshotFileDiff": { + "FileDiff.LegacyInfo": { "type": "object", "properties": { "file": { @@ -19633,7 +19491,7 @@ "$ref": "#/components/schemas/PermissionRule" } }, - "Session": { + "SessionV1.Info": { "type": "object", "properties": { "id": { @@ -19687,7 +19545,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -19902,11 +19760,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -19932,7 +19788,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -19989,11 +19845,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -20019,7 +19873,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -20076,11 +19930,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -20106,7 +19958,7 @@ ] }, "info": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/SessionV1.Info" } }, "required": [ @@ -20268,7 +20120,7 @@ "diffs": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "$ref": "#/components/schemas/FileDiff.LegacyInfo" } } }, @@ -20936,11 +20788,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -21023,11 +20873,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -22493,11 +22341,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -22584,11 +22430,9 @@ ] }, "version": { - "type": "integer", - "allOf": [ - { - "minimum": 1 - } + "type": "number", + "enum": [ + 1 ] } }, @@ -22685,44 +22529,10 @@ ] }, "cost": { - "type": "number" + "$ref": "#/components/schemas/Money.USD" }, "tokens": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "reasoning": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": [ - "read", - "write" - ], - "additionalProperties": false - } - }, - "required": [ - "input", - "output", - "reasoning", - "cache" - ], - "additionalProperties": false + "$ref": "#/components/schemas/TokenUsage.Info" } }, "required": [ @@ -23836,7 +23646,7 @@ "type": "object", "properties": { "info": { - "$ref": "#/components/schemas/Shell1" + "$ref": "#/components/schemas/Shell" } }, "required": [ @@ -26794,6 +26604,182 @@ ], "additionalProperties": false }, + "Shell1": { + "type": "object", + "properties": { + "id": { + "type": "string", + "allOf": [ + { + "pattern": "^sh_" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "running", + "exited", + "timeout", + "killed" + ] + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "file": { + "type": "string" + }, + "pid": { + "type": "integer", + "allOf": [ + { + "minimum": 0 + } + ] + }, + "exit": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "started": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "completed": { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "started" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "status", + "command", + "cwd", + "shell", + "file", + "metadata", + "time" + ], + "additionalProperties": false + }, "ShellNotFoundError": { "type": "object", "properties": { diff --git a/packages/enterprise/src/core/share.ts b/packages/enterprise/src/core/share.ts index 781bcd5cbe..ce429323d8 100644 --- a/packages/enterprise/src/core/share.ts +++ b/packages/enterprise/src/core/share.ts @@ -1,4 +1,4 @@ -import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import { FileDiffInfo, Message, Model, Part, Session } from "@opencode-ai/sdk/v2" import { iife } from "@opencode-ai/core/util/iife" import z from "zod" import { Storage } from "./storage" @@ -30,7 +30,7 @@ export namespace Share { }), z.object({ type: z.literal("session_diff"), - data: z.custom(), + data: z.custom(), }), z.object({ type: z.literal("model"), diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 8c8ac59f7a..91cb50891d 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -1,4 +1,4 @@ -import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" +import { FileDiffInfo, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" import { SessionTurn } from "@opencode-ai/session-ui/session-turn" import { SessionReview } from "@opencode-ai/session-ui/session-review" import { DataProvider } from "@opencode-ai/session-ui/context" @@ -65,7 +65,7 @@ const getData = query(async (shareID) => { shareID: string session: Session[] session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } session_status: { [sessionID: string]: SessionStatus diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 641ae2ddf8..2ce86abe43 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -64,7 +64,7 @@ export function fromRow(row: SessionRow): Info { messageID: MessageID.make(row.revert.messageID), partID: row.revert.partID ? PartID.make(row.revert.partID) : undefined, snapshot: row.revert.snapshot, - diff: row.revert.diff, + diff: "diff" in row.revert ? row.revert.diff : undefined, } : undefined return { diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 60112e10d9..a42c367ec2 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -51,7 +51,7 @@ type State = { type Data = | { type: "session" - data: SDK.Session + data: SDK.SessionV1Info } | { type: "message" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 4da9bc3ca8..f393caa1a7 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -9,7 +9,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Hash } from "@opencode-ai/core/util/hash" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" -import { Info } from "@opencode-ai/schema/file-diff" +import { LegacyInfo } from "@opencode-ai/schema/file-diff" export const Patch = Schema.Struct({ hash: Schema.String, @@ -17,7 +17,7 @@ export const Patch = Schema.Struct({ }) export type Patch = typeof Patch.Type -export const FileDiff = Info +export const FileDiff = LegacyInfo export type FileDiff = typeof FileDiff.Type const prune = "7.days" diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts index be248d3a3d..5d0894c0d7 100644 --- a/packages/opencode/test/cli/run/stream-v2.transport.test.ts +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -53,7 +53,13 @@ function connected(id = "evt_connected") { return { id, type: "server.connected", data: {} } satisfies RunV2Event } -function durable(sessionID: string, seq = 0, version = 1) { +function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 } +function durable( + sessionID: string, + seq: number, + version: Version, +): { aggregateID: string; seq: number; version: Version } +function durable(sessionID: string, seq = 0, version: 1 | 2 = 1) { return { aggregateID: sessionID, seq, version } } @@ -1311,17 +1317,10 @@ describe("V2 mini transport", () => { { id: "msg_shell", type: "shell" as const, - shell: { - id: "sh_1", - status: "exited", - command: "ls", - cwd: "/tmp", - shell: "/bin/sh", - file: "/tmp/opencode-shell", - exit: 0, - metadata: {}, - time: { started: 0, completed: 1 }, - }, + shellID: "sh_1", + status: "exited", + command: "ls", + exit: 0, output: { output: "file.txt", cursor: 8, size: 8, truncated: false }, time: { created: 1, completed: 2 }, }, @@ -1378,17 +1377,10 @@ describe("V2 mini transport", () => { { id: "msg_failed_shell", type: "shell" as const, - shell: { - id: "sh_failed", - status: "exited", - command: "false", - cwd: "/tmp", - shell: "/bin/sh", - file: "/tmp/failed", - exit: 7, - metadata: {}, - time: { started: 0, completed: 1 }, - }, + shellID: "sh_failed", + status: "exited", + command: "false", + exit: 7, output: { output: "failure output", cursor: 14, size: 14, truncated: false }, time: { created: 1, completed: 2 }, }, @@ -1551,6 +1543,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: input.skill ?? "tigerstyle", name: input.skill ?? "tigerstyle", text: "skill instructions", }, @@ -1633,6 +1626,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: "other", name: "other", text: "other instructions", }, @@ -1655,6 +1649,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: "tigerstyle", name: "tigerstyle", text: "skill instructions", }, @@ -1722,6 +1717,7 @@ describe("V2 mini transport", () => { { id: "msg_skill", type: "skill" as const, + skill: "tigerstyle", name: "tigerstyle", text: "skill instructions", time: { created: 2 }, @@ -1745,6 +1741,7 @@ describe("V2 mini transport", () => { durable: durable("ses_1"), data: { sessionID: "ses_1", + id: "tigerstyle", name: "tigerstyle", text: "skill instructions", }, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 9d7643cb33..a5677d5274 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -28,6 +28,7 @@ import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from ". import { Database } from "@opencode-ai/core/database/database" import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" import { SessionMessage } from "@opencode-ai/core/session/message" +import { Agent } from "@opencode-ai/schema/agent" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" @@ -76,7 +77,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) { id: MessageID.ascending(), role: "user", sessionID, - agent: "build", + agent: Agent.ID.make("build"), model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, time: { created: Date.now() }, }) @@ -123,7 +124,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, seq = 1, time = const message = SessionMessage.Assistant.make({ id: SessionMessage.ID.create(), type: "assistant", - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -380,7 +381,7 @@ describe("session HttpApi", () => { yield* insertLegacyAssistantMessage(parent.id) expect( - (yield* requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { + (yield* requestJson<{ data: SessionMessage.Info[] }>(`/api/session/${parent.id}/message`, { headers, })).data, ).toMatchObject([{ type: "assistant" }]) @@ -474,7 +475,7 @@ describe("session HttpApi", () => { }) const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers }) - const messageBody = yield* json<{ data: SessionMessage.Message[]; cursor: { next?: string } }>(messagePage) + const messageBody = yield* json<{ data: SessionMessage.Info[]; cursor: { next?: string } }>(messagePage) const messageCursor = messageBody.cursor.next expect(messageCursor).toBeTruthy() expect(messageBody.data.map((message) => message.id)).toEqual([secondMessage.id]) @@ -488,7 +489,7 @@ describe("session HttpApi", () => { headers, }) expect( - (yield* json<{ data: SessionMessage.Message[] }>(nextMessagePage)).data.map((message) => message.id), + (yield* json<{ data: SessionMessage.Info[] }>(nextMessagePage)).data.map((message) => message.id), ).toEqual([firstMessage.id]) const legacyMessageCursor = Buffer.from( @@ -498,7 +499,7 @@ describe("session HttpApi", () => { headers, }) expect( - (yield* json<{ data: SessionMessage.Message[] }>(legacyMessagePage)).data.map((message) => message.id), + (yield* json<{ data: SessionMessage.Info[] }>(legacyMessagePage)).data.map((message) => message.id), ).toEqual([firstMessage.id]) const messageCursorWithOrder = yield* request( @@ -625,7 +626,7 @@ describe("session HttpApi", () => { }) expect(wake.status).toBe(200) const message = yield* pollWithTimeout( - requestJson<{ data: SessionMessage.Message[] }>(`/api/session/${session.id}/message`, { headers }).pipe( + requestJson<{ data: SessionMessage.Info[] }>(`/api/session/${session.id}/message`, { headers }).pipe( Effect.map(({ data }) => data.find((message) => message.id === wakeID)), ), "V2 prompt was not promoted after wake", @@ -637,28 +638,25 @@ describe("session HttpApi", () => { ) it.instance( - "returns v2 public unavailable errors for unfinished session mutations", + "supports current session compact and wait endpoints", () => Effect.gen(function* () { const test = yield* TestInstance const headers = { "x-opencode-directory": test.directory } const session = yield* createSession({ title: "v2 unavailable" }) - const compact = yield* request(`/api/session/${session.id}/compact`, { method: "POST", headers }) - expect(compact.status).toBe(503) - expect(yield* responseJson(compact)).toEqual({ - _tag: "ServiceUnavailableError", - message: "Session compact is not available yet", - service: "session.compact", + const compact = yield* request(`/api/session/${session.id}/compact`, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({}), + }) + expect(compact.status).toBe(200) + expect(yield* responseJson(compact)).toMatchObject({ + data: { type: "compaction", sessionID: session.id }, }) const wait = yield* request(`/api/session/${session.id}/wait`, { method: "POST", headers }) - expect(wait.status).toBe(503) - expect(yield* responseJson(wait)).toEqual({ - _tag: "ServiceUnavailableError", - message: "Session wait is not available yet", - service: "session.wait", - }) + expect(wait.status).toBe(204) }), { git: true, config: { formatter: false, lsp: false } }, ) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 8f8286d562..d29a7398f4 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test" -import { Effect } from "effect" -import * as DateTime from "effect/DateTime" +import { DateTime, Effect } from "effect" import { SessionID } from "../../src/session/schema" import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" @@ -8,6 +7,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" import { SessionMessage } from "@opencode-ai/core/session/message" +import { Agent } from "@opencode-ai/schema/agent" +import { Money } from "@opencode-ai/schema/money" +import { Snapshot } from "@opencode-ai/schema/snapshot" function durable(sessionID: SessionID, seq = 0, version = 1) { return { aggregateID: sessionID, seq: EventV2.Seq.make(seq), version: EventV2.Version.make(version) } @@ -27,13 +29,13 @@ test.skip("step snapshots carry over to assistant messages", () => { data: { sessionID, assistantMessageID, - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), variant: ModelV2.VariantID.make("default"), }, - snapshot: "before", + snapshot: Snapshot.ID.make("before"), }, } satisfies SessionEvent.Event), ) @@ -50,21 +52,24 @@ test.skip("step snapshots carry over to assistant messages", () => { sessionID, assistantMessageID, finish: "stop", - cost: 0, + cost: Money.USD.zero, tokens: { input: 1, output: 2, reasoning: 0, cache: { read: 0, write: 0 }, }, - snapshot: "after", + snapshot: Snapshot.ID.make("after"), }, } satisfies SessionEvent.Event), ) expect(state.messages[0]?.type).toBe("assistant") if (state.messages[0]?.type !== "assistant") return - expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" }) + expect(state.messages[0].snapshot).toEqual({ + start: Snapshot.ID.make("before"), + end: Snapshot.ID.make("after"), + }) expect(state.messages[0].finish).toBe("stop") }) @@ -82,7 +87,7 @@ test.skip("text ended populates assistant text content", () => { data: { sessionID, assistantMessageID, - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -141,7 +146,7 @@ test.skip("tool completion stores completed timestamp", () => { data: { sessionID, assistantMessageID, - agent: "build", + agent: Agent.ID.make("build"), model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider"), @@ -213,7 +218,7 @@ test.skip("tool completion stores completed timestamp", () => { }) }) -test("compaction events reduce to compaction message only when completed", () => { +test("compaction events reduce to a compaction message through completion", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] } const sessionID = SessionID.make("session") const id = EventV2.ID.create() @@ -224,15 +229,25 @@ test("compaction events reduce to compaction message only when completed", () => id, created: DateTime.makeUnsafe(0), type: "session.compaction.started", - durable: durable(sessionID), + durable: durable(sessionID, 0, 2), data: { sessionID, reason: "auto", + recent: "recent context", }, } satisfies SessionEvent.Event), ) - expect(state.messages).toEqual([]) + expect(state.messages).toMatchObject([ + { + id: SessionMessage.ID.fromEvent(id), + type: "compaction", + reason: "auto", + recent: "recent context", + status: "running", + summary: "", + }, + ]) Effect.runSync( SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { @@ -263,7 +278,7 @@ test("compaction events reduce to compaction message only when completed", () => id: endedID, created: DateTime.makeUnsafe(0), type: "session.compaction.ended", - durable: durable(sessionID, 1), + durable: durable(sessionID, 3), data: { sessionID, reason: "auto", @@ -275,9 +290,10 @@ test("compaction events reduce to compaction message only when completed", () => expect(state.messages).toHaveLength(1) expect(state.messages[0]).toMatchObject({ - id: SessionMessage.ID.fromEvent(endedID), + id: SessionMessage.ID.fromEvent(id), type: "compaction", reason: "auto", + status: "completed", summary: "final summary", recent: "recent context", time: { created: DateTime.makeUnsafe(0) }, diff --git a/packages/plugin/src/v2/effect/agent.ts b/packages/plugin/src/v2/effect/agent.ts index 80932c5e9e..d5125cca2a 100644 --- a/packages/plugin/src/v2/effect/agent.ts +++ b/packages/plugin/src/v2/effect/agent.ts @@ -1,13 +1,13 @@ import type { AgentApi } from "@opencode-ai/client/effect/api" -import type { AgentV2Info } from "@opencode-ai/sdk/v2/types" +import type { AgentInfo } from "@opencode-ai/sdk/v2/types" import type { Effect } from "effect" import type { Transform } from "./registration.js" export interface AgentDraft { - list(): readonly AgentV2Info[] - get(id: string): AgentV2Info | undefined + list(): readonly AgentInfo[] + get(id: string): AgentInfo | undefined default(id: string | undefined): void - update(id: string, update: (agent: AgentV2Info) => void): void + update(id: string, update: (agent: AgentInfo) => void): void remove(id: string): void } diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 0792d5f39c..b96ceec4a8 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,11 +1,11 @@ -import type { ModelV2Info, ProviderV2Info } from "@opencode-ai/sdk/v2/types" +import type { ModelInfo, ProviderV2Info } from "@opencode-ai/sdk/v2/types" import type { CatalogApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" import type { Transform } from "./registration.js" export interface CatalogProviderRecord { readonly provider: ProviderV2Info - readonly models: ReadonlyMap + readonly models: ReadonlyMap } export interface CatalogDraft { @@ -16,8 +16,8 @@ export interface CatalogDraft { remove(providerID: string): void } readonly model: { - get(providerID: string, modelID: string): ModelV2Info | undefined - update(providerID: string, modelID: string, update: (model: ModelV2Info) => void): void + get(providerID: string, modelID: string): ModelInfo | undefined + update(providerID: string, modelID: string, update: (model: ModelInfo) => void): void remove(providerID: string, modelID: string): void readonly default: { get(): { providerID: string; modelID: string } | undefined diff --git a/packages/plugin/src/v2/effect/command.ts b/packages/plugin/src/v2/effect/command.ts index bd9ebfa919..6e4764a485 100644 --- a/packages/plugin/src/v2/effect/command.ts +++ b/packages/plugin/src/v2/effect/command.ts @@ -1,12 +1,12 @@ -import type { CommandV2Info } from "@opencode-ai/sdk/v2/types" +import type { CommandInfo } from "@opencode-ai/sdk/v2/types" import type { CommandApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" import type { Transform } from "./registration.js" export interface CommandDraft { - list(): readonly CommandV2Info[] - get(name: string): CommandV2Info | undefined - update(name: string, update: (command: CommandV2Info) => void): void + list(): readonly CommandInfo[] + get(name: string): CommandInfo | undefined + update(name: string, update: (command: CommandInfo) => void): void remove(name: string): void } diff --git a/packages/plugin/src/v2/effect/skill.ts b/packages/plugin/src/v2/effect/skill.ts index 4437e846e3..32daf0fb0a 100644 --- a/packages/plugin/src/v2/effect/skill.ts +++ b/packages/plugin/src/v2/effect/skill.ts @@ -1,11 +1,11 @@ -import type { SkillV2Source } from "@opencode-ai/sdk/v2/types" +import type { SkillSource } from "@opencode-ai/sdk/v2/types" import type { SkillApi } from "@opencode-ai/client/effect/api" import type { Effect } from "effect" import type { Transform } from "./registration.js" export interface SkillDraft { - source(source: SkillV2Source): void - list(): readonly SkillV2Source[] + source(source: SkillSource): void + list(): readonly SkillSource[] } export interface SkillDomain extends SkillApi { diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index a21d3d86d2..d6d1482f30 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import { Skill } from "@opencode-ai/schema/skill" export class InvalidRequestError extends Schema.TaggedErrorClass()( "InvalidRequestError", @@ -83,7 +84,7 @@ export class MessageNotFoundError extends Schema.TaggedErrorClass()( "SkillNotFoundError", { - skill: Schema.String, + skill: Skill.ID, message: Schema.String, }, { httpApiStatus: 404 }, diff --git a/packages/protocol/src/groups/message.ts b/packages/protocol/src/groups/message.ts index cab0d8bb0f..5b47146316 100644 --- a/packages/protocol/src/groups/message.ts +++ b/packages/protocol/src/groups/message.ts @@ -27,7 +27,7 @@ export const MessageGroup = HttpApiGroup.make("server.message") params: { sessionID: Session.ID }, query: SessionMessagesQuery, success: Schema.Struct({ - data: Schema.Array(SessionMessage.Message), + data: Schema.Array(SessionMessage.Info), cursor: Schema.Struct({ previous: Schema.String.pipe(Schema.optional), next: Schema.String.pipe(Schema.optional), diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index eac40a4c22..e595716873 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -23,9 +23,9 @@ import { UnknownError, } from "../errors.js" import { Agent } from "@opencode-ai/schema/agent" +import { Skill } from "@opencode-ai/schema/skill" import { Model } from "@opencode-ai/schema/model" import { Location } from "@opencode-ai/schema/location" -import { Revert } from "@opencode-ai/schema/revert" import { SessionEvent } from "@opencode-ai/schema/session-event" import { EventLog } from "@opencode-ai/schema/event-log" @@ -316,7 +316,7 @@ export const makeSessionGroup = (sessionLo id: SessionMessage.ID.pipe(Schema.optional), command: Schema.String, arguments: Schema.String.pipe(Schema.optional), - agent: Schema.String.pipe(Schema.optional), + agent: Agent.ID.pipe(Schema.optional), model: Model.Ref.pipe(Schema.optional), files: PromptInput.Prompt.fields.files, agents: PromptInput.Prompt.fields.agents, @@ -341,7 +341,7 @@ export const makeSessionGroup = (sessionLo params: { sessionID: Session.ID }, payload: Schema.Struct({ id: SessionMessage.ID.pipe(Schema.optional), - skill: Schema.String, + skill: Skill.ID, resume: Schema.Boolean.pipe(Schema.optional), }), success: HttpApiSchema.NoContent, @@ -432,7 +432,7 @@ export const makeSessionGroup = (sessionLo HttpApiEndpoint.post("session.revert.stage", "/api/session/:sessionID/revert/stage", { params: { sessionID: Session.ID }, payload: Schema.Struct({ messageID: SessionMessage.ID, files: Schema.Boolean.pipe(Schema.optional) }), - success: Schema.Struct({ data: Revert.State }), + success: Schema.Struct({ data: Session.Revert }), error: [MessageNotFoundError, SessionNotFoundError, SessionBusyError, UnknownError], }) .middleware(sessionLocationMiddleware) @@ -467,7 +467,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", { params: { sessionID: Session.ID }, - success: Schema.Struct({ data: Schema.Array(SessionMessage.Message) }), + success: Schema.Struct({ data: Schema.Array(SessionMessage.Info) }), error: [SessionNotFoundError, UnknownError], }) .middleware(sessionLocationMiddleware) @@ -583,7 +583,7 @@ export const makeSessionGroup = (sessionLo .add( HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", { params: { sessionID: Session.ID, messageID: SessionMessage.ID }, - success: Schema.Struct({ data: SessionMessage.Message }), + success: Schema.Struct({ data: SessionMessage.Info }), error: [SessionNotFoundError, MessageNotFoundError], }) .middleware(sessionLocationMiddleware) diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index eb84cf500b..81bdfe8c98 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -10,9 +10,12 @@ import { PositiveInt, statics } from "./schema.js" const Updated = ephemeral({ type: "agent.updated", schema: {} }) -export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) +export const ID = Schema.String.pipe(Schema.brand("Agent.ID")) export type ID = typeof ID.Type +export const Name = Schema.String.pipe(Schema.brand("Agent.Name")) +export type Name = typeof Name.Type + export const Color = Schema.Union([ Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), @@ -22,6 +25,7 @@ export type Color = typeof Color.Type export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, + name: Name, model: Model.Ref.pipe(optional), request: Provider.Request, system: Schema.String.pipe(optional), @@ -32,12 +36,13 @@ export const Info = Schema.Struct({ steps: PositiveInt.pipe(optional), permissions: Permission.Ruleset, }) - .annotate({ identifier: "AgentV2.Info" }) + .annotate({ identifier: "Agent.Info" }) .pipe( statics((schema) => ({ empty: (id: ID) => schema.make({ id, + name: Name.make(id), request: { settings: {}, headers: {}, body: {} }, mode: "all", hidden: false, diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index 81e37157c6..ef32acb821 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -4,6 +4,7 @@ import { Schema } from "effect" import { ephemeral, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" +import { Agent } from "./agent.js" const Updated = ephemeral({ type: "command.updated", schema: {} }) @@ -12,10 +13,10 @@ export const Info = Schema.Struct({ name: Schema.String, template: Schema.String, description: Schema.String.pipe(optional), - agent: Schema.String.pipe(optional), + agent: Agent.ID.pipe(optional), model: Model.Ref.pipe(optional), subtask: Schema.Boolean.pipe(optional), -}).annotate({ identifier: "CommandV2.Info" }) +}).annotate({ identifier: "Command.Info" }) export const Event = { Updated, diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 34e4495807..e4f2f9b813 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -1,6 +1,6 @@ export * as Event from "./event.js" -import { Schema } from "effect" +import { Schema, SchemaTransformation } from "effect" import { optional } from "./schema.js" import { ascending } from "./identifier.js" import { Location } from "./location.js" @@ -72,6 +72,7 @@ export type Payload = D extends DurableDefini type Input>>> = { readonly type: Type + readonly identifier?: string readonly durable?: { readonly version: number readonly aggregate: string @@ -84,16 +85,29 @@ export function durable< const Fields extends Readonly>>, >(input: Input & { readonly durable: NonNullable["durable"]> }) { const data = Schema.Struct(input.schema) + const durable = Schema.Struct({ + aggregateID: DurableEnvelope.fields.aggregateID, + seq: DurableEnvelope.fields.seq, + version: Schema.Literal(input.durable.version).pipe( + Schema.decodeTo( + Schema.toType(Version), + SchemaTransformation.transform({ + decode: () => Version.make(input.durable.version), + encode: () => input.durable.version, + }), + ), + ), + }) return Schema.Struct({ id: ID, created: DateTimeUtcFromMillis, metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), type: Schema.Literal(input.type), - durable: DurableEnvelope, + durable, location: optional(Location.Ref), data, }) - .annotate({ identifier: input.type }) + .annotate({ identifier: input.identifier ?? input.type }) .pipe( statics(() => ({ type: input.type, @@ -117,7 +131,7 @@ export function ephemeral< location: optional(Location.Ref), data, }) - .annotate({ identifier: input.type }) + .annotate({ identifier: input.identifier ?? input.type }) .pipe( statics(() => ({ type: input.type, diff --git a/packages/schema/src/file-diff.ts b/packages/schema/src/file-diff.ts index 9847a10574..ff467c58e4 100644 --- a/packages/schema/src/file-diff.ts +++ b/packages/schema/src/file-diff.ts @@ -1,13 +1,23 @@ export * as FileDiff from "./file-diff.js" import { Schema } from "effect" -import { optional } from "./schema.js" +import { NonNegativeInt, optional } from "./schema.js" export const Info = Schema.Struct({ - file: optional(Schema.String), - patch: optional(Schema.String), + file: Schema.String, + patch: Schema.String, + additions: NonNegativeInt, + deletions: NonNegativeInt, + status: Schema.Literals(["added", "deleted", "modified"]), +}).annotate({ identifier: "FileDiff.Info" }) +export interface Info extends Schema.Schema.Type {} + +/** V1 snapshot and persisted session diff shape. */ +export const LegacyInfo = Schema.Struct({ + file: Schema.String.pipe(optional), + patch: Schema.String.pipe(optional), additions: Schema.Finite, deletions: Schema.Finite, - status: optional(Schema.Literals(["added", "deleted", "modified"])), -}).annotate({ identifier: "SnapshotFileDiff" }) -export interface Info extends Schema.Schema.Type {} + status: Schema.Literals(["added", "deleted", "modified"]).pipe(optional), +}).annotate({ identifier: "FileDiff.LegacyInfo" }) +export interface LegacyInfo extends Schema.Schema.Type {} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index 4e77cc4151..56bf6fa3c5 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -11,20 +11,22 @@ export { LLM } from "./llm.js" export { Location } from "./location.js" export { Mcp } from "./mcp.js" export { Model } from "./model.js" +export { Money } from "./money.js" export { Permission } from "./permission.js" export { PermissionSaved } from "./permission-saved.js" export { Project } from "./project.js" export { ProjectCopy } from "./project-copy.js" export { Provider } from "./provider.js" export { Reference } from "./reference.js" -export { Revert } from "./revert.js" export { Session } from "./session.js" export { Vcs } from "./vcs.js" export { SessionInput } from "./session-input.js" export { SessionError } from "./session-error.js" export { SessionMessage } from "./session-message.js" +export { Snapshot } from "./snapshot.js" export { Shell } from "./shell.js" export { Skill } from "./skill.js" +export { TokenUsage } from "./token-usage.js" export { Pty } from "./pty.js" export { PtyTicket } from "./pty-ticket.js" export { Question } from "./question.js" diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index fc6b64712f..3539c87f77 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -3,11 +3,12 @@ export * as Model from "./model.js" import { Schema } from "effect" import { optional, statics } from "./schema.js" import { Provider } from "./provider.js" +import { Money } from "./money.js" -export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) +export const ID = Schema.String.pipe(Schema.brand("Model.ID")) export type ID = typeof ID.Type -export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export const VariantID = Schema.String.pipe(Schema.brand("Model.VariantID")) export type VariantID = typeof VariantID.Type export const Ref = Schema.Struct({ @@ -17,7 +18,7 @@ export const Ref = Schema.Struct({ }).annotate({ identifier: "Model.Ref" }) export interface Ref extends Schema.Schema.Type {} -export const Family = Schema.String.pipe(Schema.brand("Family")) +export const Family = Schema.String.pipe(Schema.brand("Model.Family")) export type Family = typeof Family.Type export interface Capabilities extends Schema.Schema.Type {} @@ -30,14 +31,14 @@ export const Capabilities = Schema.Struct({ export interface Cost extends Schema.Schema.Type {} export const Cost = Schema.Struct({ tier: Schema.Struct({ - type: Schema.Literal("context"), + type: Schema.tag("context"), size: Schema.Int, }).pipe(optional), - input: Schema.Finite, - output: Schema.Finite, + input: Money.USDPerMillionTokens, + output: Money.USDPerMillionTokens, cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, + read: Money.USDPerMillionTokens, + write: Money.USDPerMillionTokens, }), }).annotate({ identifier: "Model.Cost" }) @@ -70,7 +71,7 @@ export const Info = Schema.Struct({ output: Schema.Int, }), }) - .annotate({ identifier: "ModelV2.Info" }) + .annotate({ identifier: "Model.Info" }) .pipe( statics((schema) => ({ empty: (providerID: Provider.ID, id: ID) => diff --git a/packages/schema/src/money.ts b/packages/schema/src/money.ts new file mode 100644 index 0000000000..f21a390be8 --- /dev/null +++ b/packages/schema/src/money.ts @@ -0,0 +1,18 @@ +export * as Money from "./money.js" + +import { Schema } from "effect" +import { statics } from "./schema.js" + +export const USD = Schema.Finite.pipe( + Schema.brand("Money.USD"), + Schema.annotate({ identifier: "Money.USD" }), + statics((schema) => ({ zero: schema.make(0) })), +) +export type USD = typeof USD.Type + +export const USDPerMillionTokens = Schema.Finite.pipe( + Schema.brand("Money.USDPerMillionTokens"), + Schema.annotate({ identifier: "Money.USDPerMillionTokens" }), + statics((schema) => ({ zero: schema.make(0) })), +) +export type USDPerMillionTokens = typeof USDPerMillionTokens.Type diff --git a/packages/schema/src/revert.ts b/packages/schema/src/revert.ts deleted file mode 100644 index ab211a60f3..0000000000 --- a/packages/schema/src/revert.ts +++ /dev/null @@ -1,24 +0,0 @@ -export * as Revert from "./revert.js" - -import { Schema } from "effect" -import { optional } from "./schema.js" -import { NonNegativeInt, RelativePath } from "./schema.js" -import { SessionMessage } from "./session-message.js" - -export const FileDiff = Schema.Struct({ - path: RelativePath, - status: Schema.Literals(["added", "modified", "deleted"]), - additions: NonNegativeInt, - deletions: NonNegativeInt, - patch: Schema.String, -}).annotate({ identifier: "File.Diff" }) -export interface FileDiff extends Schema.Schema.Type {} - -export const State = Schema.Struct({ - messageID: SessionMessage.ID, - partID: Schema.String.pipe(optional), - snapshot: Schema.String.pipe(optional), - diff: Schema.String.pipe(optional), - files: Schema.Array(FileDiff).pipe(optional), -}).annotate({ identifier: "Revert.State" }) -export interface State extends Schema.Schema.Type {} diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 9f98b35226..9c2417e053 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -12,9 +12,14 @@ import { FileAttachment, Prompt } from "./prompt.js" import { SessionID } from "./session-id.js" import { Location } from "./location.js" import { SessionMessage } from "./session-message.js" -import { Revert } from "./revert.js" +import { Revert } from "./session-revert.js" import { Shell as ShellSchema } from "./shell.js" import { SessionError } from "./session-error.js" +import { Agent } from "./agent.js" +import { Skill as SkillSchema } from "./skill.js" +import { Money } from "./money.js" +import { Snapshot } from "./snapshot.js" +import { TokenUsage } from "./token-usage.js" export { FileAttachment } @@ -23,22 +28,13 @@ export const Source = Schema.Struct({ end: NonNegativeInt, text: Schema.String, }).annotate({ - identifier: "session.event.source", + identifier: "Session.Event.Source", }) export interface Source extends Schema.Schema.Type {} const Base = { sessionID: SessionID, } -const Tokens = Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), -}) const PromptFields = { ...Base, inputID: SessionMessage.ID, @@ -57,7 +53,7 @@ export const AgentSelected = Event.durable({ ...options, schema: { ...Base, - agent: Schema.String, + agent: Agent.ID, }, }) export type AgentSelected = typeof AgentSelected.Type @@ -97,8 +93,8 @@ export const UsageUpdated = Event.ephemeral({ type: "session.usage.updated", schema: { ...Base, - cost: Schema.Finite, - tokens: Tokens, + cost: Money.USD, + tokens: TokenUsage.Info, }, }) export type UsageUpdated = typeof UsageUpdated.Type @@ -191,7 +187,8 @@ export namespace Skill { ...options, schema: { ...Base, - name: Schema.String, + id: SkillSchema.ID, + name: SkillSchema.Name, text: Schema.String, }, }) @@ -228,9 +225,9 @@ export namespace Step { schema: { ...Base, assistantMessageID: SessionMessage.ID, - agent: Schema.String, + agent: Agent.ID, model: Model.Ref, - snapshot: Schema.String.pipe(optional), + snapshot: Snapshot.ID.pipe(optional), }, }) export type Started = typeof Started.Type @@ -242,9 +239,9 @@ export namespace Step { ...Base, assistantMessageID: SessionMessage.ID, finish: FinishReason, - cost: Schema.Finite, - tokens: Tokens, - snapshot: Schema.String.pipe(optional), + cost: Money.USD, + tokens: TokenUsage.Info, + snapshot: Snapshot.ID.pipe(optional), files: Schema.Array(RelativePath).pipe(optional), }, }) @@ -257,8 +254,8 @@ export namespace Step { ...Base, assistantMessageID: SessionMessage.ID, error: SessionError.Error, - cost: Schema.Finite.pipe(optional), - tokens: Tokens.pipe(optional), + cost: Money.USD.pipe(optional), + tokens: TokenUsage.Info.pipe(optional), }, }) export type Failed = typeof Failed.Type @@ -413,7 +410,6 @@ export namespace Tool { ...ToolBase, structured: Schema.Record(Schema.String, Schema.Unknown), content: Schema.Array(ToolContent), - outputPaths: Schema.Array(Schema.String).pipe(optional), result: Schema.Unknown.pipe(optional), executed: Schema.Boolean, resultState: SessionMessage.ProviderState.pipe(optional), @@ -464,7 +460,9 @@ export namespace Compaction { ...options, schema: { ...Base, - reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), + reason: Schema.Literals(["auto", "manual"]), + recent: Schema.String, + inputID: SessionMessage.ID.pipe(optional), }, }) export type Started = typeof Started.Type @@ -493,7 +491,12 @@ export namespace Compaction { export const Failed = Event.durable({ type: "session.compaction.failed", ...options, - schema: Base, + schema: { + ...Base, + reason: Started.data.fields.reason, + error: SessionError.Error, + inputID: SessionMessage.ID.pipe(optional), + }, }) export type Failed = typeof Failed.Type } @@ -502,7 +505,7 @@ export namespace RevertEvent { export const Staged = Event.durable({ type: "session.revert.staged", ...options, - schema: { ...Base, revert: Revert.State }, + schema: { ...Base, revert: Revert }, }) export const Cleared = Event.durable({ type: "session.revert.cleared", ...options, schema: Base }) export const Committed = Event.durable({ @@ -564,7 +567,7 @@ export const DurableDefinitions = Event.inventory( export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "SessionDurableEvent" }) + .annotate({ identifier: "Session.Event.Durable" }) export type DurableEvent = typeof Durable.Type export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts index a59c00b40e..d1837ba08f 100644 --- a/packages/schema/src/session-input.ts +++ b/packages/schema/src/session-input.ts @@ -24,13 +24,13 @@ export const Admitted = Schema.Struct({ export interface PromptEntry extends Schema.Schema.Type {} export const PromptEntry = Schema.Struct({ - type: Schema.Literal("prompt"), + type: Schema.tag("prompt"), ...Admitted.fields, }).annotate({ identifier: "SessionInput.PromptEntry" }) export interface Compaction extends Schema.Schema.Type {} export const Compaction = Schema.Struct({ - type: Schema.Literal("compaction"), + type: Schema.tag("compaction"), admittedSeq: NonNegativeInt, id: SessionMessage.ID, sessionID: SessionID, @@ -38,5 +38,8 @@ export const Compaction = Schema.Struct({ handledSeq: NonNegativeInt.pipe(optional), }).annotate({ identifier: "SessionInput.Compaction" }) -export const Entry = Schema.Union([PromptEntry, Compaction]).pipe(Schema.toTaggedUnion("type")) -export type Entry = typeof Entry.Type +export const Info = Schema.Union([PromptEntry, Compaction]).pipe( + Schema.toTaggedUnion("type"), + Schema.annotate({ identifier: "SessionInput.Info" }), +) +export type Info = typeof Info.Type diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 62e1a1038d..3d45e283e6 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -4,14 +4,18 @@ import { Schema } from "effect" import { optional } from "./schema.js" import { ToolContent } from "./llm.js" import { Model } from "./model.js" -import { FileAttachment, Prompt } from "./prompt.js" +import { Prompt } from "./prompt.js" import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js" -import { SessionID } from "./session-id.js" import { ascending } from "./identifier.js" import { Event } from "./event.js" import { Shell as ShellSchema } from "./shell.js" import { FinishReason } from "./llm.js" import { SessionError } from "./session-error.js" +import { Agent } from "./agent.js" +import { Skill as SkillSchema } from "./skill.js" +import { Money } from "./money.js" +import { Snapshot } from "./snapshot.js" +import { TokenUsage } from "./token-usage.js" export const ID = Schema.String.check(Schema.isStartsWith("msg_")).pipe( Schema.brand("Session.Message.ID"), @@ -36,14 +40,14 @@ export type ProviderState = typeof ProviderState.Type export interface AgentSelected extends Schema.Schema.Type {} export const AgentSelected = Schema.Struct({ ...Base, - type: Schema.Literal("agent-switched"), - agent: Schema.String, + type: Schema.tag("agent-switched"), + agent: Agent.ID, }).annotate({ identifier: "Session.Message.AgentSelected" }) export interface ModelSelected extends Schema.Schema.Type {} export const ModelSelected = Schema.Struct({ ...Base, - type: Schema.Literal("model-switched"), + type: Schema.tag("model-switched"), model: Model.Ref, previous: Model.Ref.pipe(optional), }).annotate({ identifier: "Session.Message.ModelSelected" }) @@ -54,38 +58,41 @@ export const User = Schema.Struct({ text: Prompt.fields.text, files: Prompt.fields.files, agents: Prompt.fields.agents, - type: Schema.Literal("user"), + type: Schema.tag("user"), }).annotate({ identifier: "Session.Message.User" }) export interface Synthetic extends Schema.Schema.Type {} export const Synthetic = Schema.Struct({ ...Base, - sessionID: SessionID, text: Schema.String, description: Schema.String.pipe(optional), - type: Schema.Literal("synthetic"), + type: Schema.tag("synthetic"), }).annotate({ identifier: "Session.Message.Synthetic" }) export interface System extends Schema.Schema.Type {} export const System = Schema.Struct({ ...Base, - type: Schema.Literal("system"), + type: Schema.tag("system"), text: Schema.String, }).annotate({ identifier: "Session.Message.System" }) export interface Skill extends Schema.Schema.Type {} export const Skill = Schema.Struct({ ...Base, - type: Schema.Literal("skill"), - name: Schema.String, + type: Schema.tag("skill"), + skill: SkillSchema.ID, + name: SkillSchema.Name, text: Schema.String, }).annotate({ identifier: "Session.Message.Skill" }) export interface Shell extends Schema.Schema.Type {} export const Shell = Schema.Struct({ ...Base, - type: Schema.Literal("shell"), - shell: ShellSchema.Info, + type: Schema.tag("shell"), + shellID: ShellSchema.ID, + command: Schema.String, + status: ShellSchema.Status, + exit: Schema.Number.pipe(optional), output: ShellSchema.Output.pipe(optional), time: Schema.Struct({ created: DateTimeUtcFromMillis, @@ -93,15 +100,15 @@ export const Shell = Schema.Struct({ }), }).annotate({ identifier: "Session.Message.Shell" }) -export interface ToolStatePending extends Schema.Schema.Type {} -export const ToolStatePending = Schema.Struct({ - status: Schema.Literal("pending"), +export interface ToolStateStreaming extends Schema.Schema.Type {} +export const ToolStateStreaming = Schema.Struct({ + status: Schema.tag("streaming"), input: Schema.String, -}).annotate({ identifier: "Session.Message.ToolState.Pending" }) +}).annotate({ identifier: "Session.Message.ToolState.Streaming" }) export interface ToolStateRunning extends Schema.Schema.Type {} export const ToolStateRunning = Schema.Struct({ - status: Schema.Literal("running"), + status: Schema.tag("running"), input: Schema.Record(Schema.String, Schema.Unknown), structured: Schema.Record(Schema.String, Schema.Unknown), content: ToolContent.pipe(Schema.Array), @@ -109,18 +116,16 @@ export const ToolStateRunning = Schema.Struct({ export interface ToolStateCompleted extends Schema.Schema.Type {} export const ToolStateCompleted = Schema.Struct({ - status: Schema.Literal("completed"), + status: Schema.tag("completed"), input: Schema.Record(Schema.String, Schema.Unknown), - attachments: FileAttachment.pipe(Schema.Array, optional), content: ToolContent.pipe(Schema.Array), - outputPaths: Schema.Array(Schema.String).pipe(optional), structured: Schema.Record(Schema.String, Schema.Unknown), result: Schema.Unknown.pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Completed" }) export interface ToolStateError extends Schema.Schema.Type {} export const ToolStateError = Schema.Struct({ - status: Schema.Literal("error"), + status: Schema.tag("error"), input: Schema.Record(Schema.String, Schema.Unknown), content: ToolContent.pipe(Schema.Array), structured: Schema.Record(Schema.String, Schema.Unknown), @@ -128,14 +133,14 @@ export const ToolStateError = Schema.Struct({ result: Schema.Unknown.pipe(optional), }).annotate({ identifier: "Session.Message.ToolState.Error" }) -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( +export const ToolState = Schema.Union([ToolStateStreaming, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( Schema.toTaggedUnion("status"), ) -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError +export type ToolState = ToolStateStreaming | ToolStateRunning | ToolStateCompleted | ToolStateError export interface AssistantTool extends Schema.Schema.Type {} export const AssistantTool = Schema.Struct({ - type: Schema.Literal("tool"), + type: Schema.tag("tool"), id: Schema.String, name: Schema.String, executed: Schema.Boolean.pipe(optional), @@ -146,19 +151,18 @@ export const AssistantTool = Schema.Struct({ created: DateTimeUtcFromMillis, ran: DateTimeUtcFromMillis.pipe(optional), completed: DateTimeUtcFromMillis.pipe(optional), - pruned: DateTimeUtcFromMillis.pipe(optional), }), }).annotate({ identifier: "Session.Message.Assistant.Tool" }) export interface AssistantText extends Schema.Schema.Type {} export const AssistantText = Schema.Struct({ - type: Schema.Literal("text"), + type: Schema.tag("text"), text: Schema.String, }).annotate({ identifier: "Session.Message.Assistant.Text" }) export interface AssistantReasoning extends Schema.Schema.Type {} export const AssistantReasoning = Schema.Struct({ - type: Schema.Literal("reasoning"), + type: Schema.tag("reasoning"), text: Schema.String, state: ProviderState.pipe(optional), time: Schema.Struct({ @@ -182,23 +186,18 @@ export const AssistantRetry = Schema.Struct({ export interface Assistant extends Schema.Schema.Type {} export const Assistant = Schema.Struct({ ...Base, - type: Schema.Literal("assistant"), - agent: Schema.String, + type: Schema.tag("assistant"), + agent: Agent.ID, model: Model.Ref, content: AssistantContent.pipe(Schema.Array), snapshot: Schema.Struct({ - start: Schema.String.pipe(optional), - end: Schema.String.pipe(optional), + start: Snapshot.ID.pipe(optional), + end: Snapshot.ID.pipe(optional), files: Schema.Array(RelativePath).pipe(optional), }).pipe(optional), finish: FinishReason.pipe(optional), - cost: Schema.Finite.pipe(optional), - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ read: Schema.Finite, write: Schema.Finite }), - }).pipe(optional), + cost: Money.USD.pipe(optional), + tokens: TokenUsage.Info.pipe(optional), error: SessionError.Error.pipe(optional), retry: AssistantRetry.pipe(optional), time: Schema.Struct({ @@ -207,17 +206,41 @@ export const Assistant = Schema.Struct({ }), }).annotate({ identifier: "Session.Message.Assistant" }) -export interface Compaction extends Schema.Schema.Type {} -export const Compaction = Schema.Struct({ - type: Schema.Literal("compaction"), - status: Schema.Literals(["queued", "running", "completed", "failed"]), +const CompactionBase = { type: Schema.tag("compaction"), ...Base } + +export interface CompactionRunning extends Schema.Schema.Type {} +export const CompactionRunning = Schema.Struct({ + ...CompactionBase, + status: Schema.tag("running"), reason: Schema.Literals(["auto", "manual"]), summary: Schema.String, recent: Schema.String, - ...Base, -}).annotate({ identifier: "Session.Message.Compaction" }) +}).annotate({ identifier: "Session.Message.Compaction.Running" }) -export const Message = Schema.Union([ +export interface CompactionCompleted extends Schema.Schema.Type {} +export const CompactionCompleted = Schema.Struct({ + ...CompactionBase, + status: Schema.tag("completed"), + reason: Schema.Literals(["auto", "manual"]), + summary: Schema.String, + recent: Schema.String, +}).annotate({ identifier: "Session.Message.Compaction.Completed" }) + +export interface CompactionFailed extends Schema.Schema.Type {} +export const CompactionFailed = Schema.Struct({ + ...CompactionBase, + status: Schema.tag("failed"), + reason: Schema.Literals(["auto", "manual"]), + error: SessionError.Error, +}).annotate({ identifier: "Session.Message.Compaction.Failed" }) + +export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted, CompactionFailed]).pipe( + Schema.toTaggedUnion("status"), + Schema.annotate({ identifier: "Session.Message.Compaction" }), +) +export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed + +export const Info = Schema.Union([ AgentSelected, ModelSelected, User, @@ -229,6 +252,6 @@ export const Message = Schema.Union([ Compaction, ]) .pipe(Schema.toTaggedUnion("type")) - .annotate({ identifier: "Session.Message" }) -export type Message = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction -export type Type = Message["type"] + .annotate({ identifier: "Session.Message.Info" }) +export type Info = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction +export type Type = Info["type"] diff --git a/packages/schema/src/session-revert.ts b/packages/schema/src/session-revert.ts new file mode 100644 index 0000000000..bbbc3b0f9b --- /dev/null +++ b/packages/schema/src/session-revert.ts @@ -0,0 +1,86 @@ +import { Schema, SchemaTransformation } from "effect" +import { FileDiff } from "./file-diff.js" +import { optional } from "./schema.js" +import { SessionMessage } from "./session-message.js" +import { Snapshot } from "./snapshot.js" + +export interface Revert extends Schema.Schema.Type {} +export const Revert = Schema.Struct({ + messageID: SessionMessage.ID, + /** Legacy V1 compatibility state. */ + partID: Schema.String.pipe(optional), + snapshot: Snapshot.ID.pipe(optional), + files: Schema.Array(FileDiff.Info).pipe(optional), +}).annotate({ identifier: "Session.Revert" }) + +const FileDiffV1 = Schema.Struct({ + path: Schema.String, + status: Schema.Literals(["added", "modified", "deleted"]), + additions: Schema.Finite, + deletions: Schema.Finite, + patch: Schema.String, +}) + +export interface RevertV1 extends Schema.Schema.Type {} +export const RevertV1 = Schema.Struct({ + messageID: SessionMessage.ID, + partID: Schema.String.pipe(optional), + snapshot: Schema.String.pipe(optional), + diff: Schema.String.pipe(optional), + files: Schema.Array(FileDiffV1).pipe(optional), +}).annotate({ identifier: "Session.RevertV1" }) + +const PersistedCurrent = Revert.pipe( + Schema.decodeTo( + Schema.Struct({ source: Schema.tag("current"), revert: Schema.toType(Revert) }), + SchemaTransformation.transform({ + decode: (revert): { readonly source: "current"; readonly revert: Revert } => ({ + source: "current", + revert, + }), + encode: (value) => value.revert, + }), + ), +) +const PersistedLegacy = RevertV1.pipe( + Schema.decodeTo( + Schema.Struct({ source: Schema.tag("legacy"), revert: Schema.toType(RevertV1) }), + SchemaTransformation.transform({ + decode: (revert): { readonly source: "legacy"; readonly revert: RevertV1 } => ({ + source: "legacy", + revert, + }), + encode: (value) => value.revert, + }), + ), +) + +/** Storage decoder for revert state written before FileDiff became canonical. */ +export const PersistedRevert = Schema.Union([PersistedCurrent, PersistedLegacy]).pipe( + Schema.toTaggedUnion("source"), + Schema.decodeTo( + Schema.toType(Revert), + SchemaTransformation.transform({ + decode: (persisted): Revert => { + if (persisted.source === "current") return persisted.revert + return Revert.make({ + messageID: persisted.revert.messageID, + partID: persisted.revert.partID, + snapshot: persisted.revert.snapshot ? Snapshot.ID.make(persisted.revert.snapshot) : undefined, + files: persisted.revert.files?.map((file) => ({ + file: file.path, + status: file.status, + additions: file.additions, + deletions: file.deletions, + patch: file.patch, + })), + }) + }, + encode: (revert): { readonly source: "current"; readonly revert: Revert } => ({ + source: "current", + revert, + }), + }), + ), + Schema.annotate({ identifier: "Session.Revert.Persisted" }), +) diff --git a/packages/schema/src/session.ts b/packages/schema/src/session.ts index 1d5a5ffef0..fffb4b7b3d 100644 --- a/packages/schema/src/session.ts +++ b/packages/schema/src/session.ts @@ -9,34 +9,31 @@ import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js" import { SessionEvent } from "./session-event.js" import { SessionID } from "./session-id.js" import { SessionMessage } from "./session-message.js" -import { Revert } from "./revert.js" +import { Money } from "./money.js" +import { TokenUsage } from "./token-usage.js" +import { Revert } from "./session-revert.js" export const ID = SessionID export type ID = SessionID export const Event = SessionEvent +export { Revert } + export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ id: ID, parentID: ID.pipe(optional), fork: Schema.Struct({ sessionID: ID, + /** Messages before this exclusive boundary are copied into the fork. */ messageID: SessionMessage.ID.pipe(optional), }).pipe(optional), projectID: Project.ID, agent: Agent.ID.pipe(optional), model: Model.Ref.pipe(optional), - cost: Schema.Finite, - tokens: Schema.Struct({ - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), + cost: Money.USD, + tokens: TokenUsage.Info, time: Schema.Struct({ created: DateTimeUtcFromMillis, updated: DateTimeUtcFromMillis, @@ -45,8 +42,8 @@ export const Info = Schema.Struct({ title: Schema.String, location: Location.Ref, subpath: RelativePath.pipe(optional), - revert: Revert.State.pipe(optional), -}).annotate({ identifier: "SessionV2.Info" }) + revert: Revert.pipe(optional), +}).annotate({ identifier: "Session.Info" }) export const ListAnchor = Schema.Struct({ id: ID, diff --git a/packages/schema/src/shell.ts b/packages/schema/src/shell.ts index 1826752d37..59ac25decf 100644 --- a/packages/schema/src/shell.ts +++ b/packages/schema/src/shell.ts @@ -6,7 +6,7 @@ import { ephemeral, inventory } from "./event.js" import { ascending } from "./identifier.js" import { NonNegativeInt, statics } from "./schema.js" -const IDSchema = Schema.String.check(Schema.isStartsWith("sh_")).pipe(Schema.brand("ShellID")) +const IDSchema = Schema.String.check(Schema.isStartsWith("sh_")).pipe(Schema.brand("Shell.ID")) export const ID = IDSchema.pipe( statics((schema: typeof IDSchema) => { diff --git a/packages/schema/src/skill.ts b/packages/schema/src/skill.ts index 184266f2ad..69819eee26 100644 --- a/packages/schema/src/skill.ts +++ b/packages/schema/src/skill.ts @@ -5,49 +5,56 @@ import { optional } from "./schema.js" import { AbsolutePath } from "./schema.js" import { ephemeral, inventory } from "./event.js" +export const ID = Schema.String.pipe(Schema.brand("Skill.ID")) +export type ID = typeof ID.Type + +export const Name = Schema.String.pipe(Schema.brand("Skill.Name")) +export type Name = typeof Name.Type + export interface DirectorySource extends Schema.Schema.Type {} export const DirectorySource = Schema.Struct({ - type: Schema.Literal("directory"), + type: Schema.tag("directory"), path: AbsolutePath, -}).annotate({ identifier: "SkillV2.DirectorySource" }) +}).annotate({ identifier: "Skill.DirectorySource" }) export interface UrlSource extends Schema.Schema.Type {} export const UrlSource = Schema.Struct({ - type: Schema.Literal("url"), + type: Schema.tag("url"), url: Schema.String, -}).annotate({ identifier: "SkillV2.UrlSource" }) +}).annotate({ identifier: "Skill.UrlSource" }) export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ - name: Schema.String, + id: ID, + name: Name, description: Schema.String.pipe(optional), slash: Schema.Boolean.pipe(optional), autoinvoke: Schema.Boolean.pipe(optional), location: AbsolutePath, content: Schema.String, -}).annotate({ identifier: "SkillV2.Info" }) +}).annotate({ identifier: "Skill.Info" }) const Updated = ephemeral({ type: "skill.updated", schema: {} }) export const Event = { Updated, Definitions: inventory(Updated) } export interface EmbeddedSource extends Schema.Schema.Type {} export const EmbeddedSource = Schema.Struct({ - type: Schema.Literal("embedded"), + type: Schema.tag("embedded"), skill: Schema.suspend(() => Info), -}).annotate({ identifier: "SkillV2.EmbeddedSource" }) +}).annotate({ identifier: "Skill.EmbeddedSource" }) export type Source = DirectorySource | UrlSource | EmbeddedSource export const Source = Object.assign( Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe( Schema.toTaggedUnion("type"), - Schema.annotate({ identifier: "SkillV2.Source" }), + Schema.annotate({ identifier: "Skill.Source" }), ), { equals: (a: Source, b: Source) => { if (a.type !== b.type) return false if (a.type === "directory" && b.type === "directory") return a.path === b.path if (a.type === "url" && b.type === "url") return a.url === b.url - if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name + if (a.type === "embedded" && b.type === "embedded") return a.skill.id === b.skill.id return false }, key: (source: Source) => @@ -55,6 +62,6 @@ export const Source = Object.assign( ? `directory:${source.path}` : source.type === "url" ? `url:${source.url}` - : `embedded:${source.skill.name}`, + : `embedded:${source.skill.id}`, }, ) diff --git a/packages/schema/src/snapshot.ts b/packages/schema/src/snapshot.ts new file mode 100644 index 0000000000..375981b415 --- /dev/null +++ b/packages/schema/src/snapshot.ts @@ -0,0 +1,6 @@ +export * as Snapshot from "./snapshot.js" + +import { Schema } from "effect" + +export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID")) +export type ID = typeof ID.Type diff --git a/packages/schema/src/token-usage.ts b/packages/schema/src/token-usage.ts new file mode 100644 index 0000000000..6f08add0cc --- /dev/null +++ b/packages/schema/src/token-usage.ts @@ -0,0 +1,14 @@ +export * as TokenUsage from "./token-usage.js" + +import { Schema } from "effect" + +export interface Info extends Schema.Schema.Type {} +export const Info = Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}).annotate({ identifier: "TokenUsage.Info" }) diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 1c2827f0d8..3932cae88a 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -2,7 +2,6 @@ export * as SessionV1 from "./session.js" import { Effect, Schema, Types } from "effect" import { durable, ephemeral, inventory } from "../event.js" -import { FileDiff } from "../file-diff.js" import { Project } from "../project.js" import { Provider } from "../provider.js" import { Model } from "../model.js" @@ -11,6 +10,7 @@ import { ascending } from "../identifier.js" import { SessionID } from "../session-id.js" import { WorkspaceID } from "../workspace-id.js" import { PermissionV1 } from "./permission.js" +import { FileDiff } from "../file-diff.js" const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)) @@ -340,7 +340,7 @@ export const User = Schema.Struct({ Schema.Struct({ title: Schema.optional(Schema.String), body: Schema.optional(Schema.String), - diffs: Schema.Array(FileDiff.Info), + diffs: Schema.Array(FileDiff.LegacyInfo), }), ), agent: Schema.String, @@ -510,7 +510,7 @@ const SessionSummary = Schema.Struct({ additions: Schema.Finite, deletions: Schema.Finite, files: Schema.Finite, - diffs: optional(Schema.Array(FileDiff.Info)), + diffs: optional(Schema.Array(FileDiff.LegacyInfo)), }) const SessionTokens = Schema.Struct({ @@ -565,7 +565,7 @@ export const SessionInfo = Schema.Struct({ }), permission: optional(PermissionV1.Ruleset), revert: optional(SessionRevert), -}).annotate({ identifier: "Session" }) +}).annotate({ identifier: "SessionV1.Info" }) export type SessionInfo = typeof SessionInfo.Type const events = { @@ -644,7 +644,7 @@ export const Diff = ephemeral({ type: "session.diff", schema: { sessionID: SessionID, - diff: Schema.Array(FileDiff.Info), + diff: Schema.Array(FileDiff.LegacyInfo), }, }) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index 0cc1c71974..f5f310d8d7 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -10,10 +10,30 @@ import { Pty } from "../src/pty.js" import { Question } from "../src/question.js" import { Session } from "../src/session.js" import { SessionMessage } from "../src/session-message.js" +import { SessionInput } from "../src/session-input.js" +import { FileDiff } from "../src/file-diff.js" +import { Money } from "../src/money.js" +import { Skill } from "../src/skill.js" +import { Shell } from "../src/shell.js" +import { PersistedRevert } from "../src/session-revert.js" import { SessionTodo } from "../src/session-todo.js" import { optional } from "../src/schema.js" describe("contract hygiene", () => { + test("keeps absolute costs distinct from model rates", () => { + const usd = Money.USD.make(1) + const rate = Money.USDPerMillionTokens.make(1) + // @ts-expect-error Model rates are not absolute costs. + const invalidUSD: Money.USD = rate + // @ts-expect-error Absolute costs are not model rates. + const invalidRate: Money.USDPerMillionTokens = usd + + expect(invalidUSD).toBe(Money.USD.make(1)) + expect(invalidRate).toBe(Money.USDPerMillionTokens.make(1)) + expect(Money.USD.zero).toBe(Money.USD.make(0)) + expect(Money.USDPerMillionTokens.zero).toBe(Money.USDPerMillionTokens.make(0)) + }) + test("optional properties preserve transformations and omit undefined while encoding", () => { const Value = Schema.Struct({ value: optional(Schema.FiniteFromString) }) expect(Schema.decodeUnknownSync(Value)({ value: "1" })).toEqual({ value: 1 }) @@ -71,6 +91,7 @@ describe("contract hygiene", () => { Project.Info, Pty.Info, Session.ListAnchor, + Session.Revert, ].map((schema) => schema.ast.annotations?.identifier) expect(identifiers.every((identifier) => typeof identifier === "string")).toBe(true) @@ -104,9 +125,76 @@ describe("contract hygiene", () => { name: "search", executed: true, providerState: { itemId: "item_1" }, - state: { status: "pending", input: "" }, + state: { status: "streaming", input: "" }, time: { created: DateTime.makeUnsafe(0) }, }), ).not.toHaveProperty("provider") }) + + test("reviewed session contracts use their canonical current shapes", () => { + expect(SessionMessage.Info.ast.annotations?.identifier).toBe("Session.Message.Info") + expect(SessionInput.Info.ast.annotations?.identifier).toBe("SessionInput.Info") + expect(Money.USD).not.toBe(Money.USDPerMillionTokens) + expect( + FileDiff.Info.make({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }), + ).toEqual({ file: "src/index.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }) + expect( + SessionMessage.Shell.make({ + id: SessionMessage.ID.make("msg_shell"), + type: "shell", + shellID: Shell.ID.make("sh_test"), + command: "pwd", + status: "exited", + exit: 0, + time: { created: DateTime.makeUnsafe(0) }, + }), + ).not.toHaveProperty("shell") + expect( + SessionMessage.Skill.make({ + id: SessionMessage.ID.make("msg_skill"), + type: "skill", + skill: Skill.ID.make("effect"), + name: Skill.Name.make("Effect"), + text: "Use Effect", + time: { created: DateTime.makeUnsafe(0) }, + }), + ).toMatchObject({ skill: "effect", name: "Effect" }) + expect( + SessionMessage.CompactionFailed.make({ + id: SessionMessage.ID.make("msg_compaction"), + type: "compaction", + status: "failed", + reason: "manual", + error: { type: "compaction.failed", message: "failed" }, + time: { created: DateTime.makeUnsafe(0) }, + }), + ).not.toHaveProperty("summary") + }) + + test("keeps shared persisted revert compatibility", () => { + expect( + Schema.decodeUnknownSync(Session.Revert)({ + messageID: "msg_legacy", + snapshot: "tree", + diff: "legacy patch", + }), + ).not.toHaveProperty("diff") + + const revert = Schema.decodeUnknownSync(PersistedRevert)({ + messageID: "msg_legacy", + snapshot: "tree", + diff: "legacy patch", + files: [{ path: "src/index.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }], + }) + expect(String(revert.messageID)).toBe("msg_legacy") + expect(String(revert.snapshot)).toBe("tree") + expect(revert.files).toEqual([ + { file: "src/index.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }, + ]) + expect(Schema.encodeSync(PersistedRevert)(revert)).toEqual({ + messageID: "msg_legacy", + snapshot: "tree", + files: [{ file: "src/index.ts", status: "modified", additions: 1, deletions: 0, patch: "@@" }], + }) + }) }) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 8f8f112176..971ac68f24 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -145,10 +145,17 @@ describe("public event manifest", () => { SessionEvent.Definitions.filter((definition) => definition.durability === "durable"), ) expect(SessionEvent.UsageUpdated.durability).toBe("ephemeral") + expect(SessionEvent.Compaction.Delta.durability).toBe("ephemeral") + expect(EventManifest.Durable.has("session.compaction.delta.1")).toBe(false) expect(EventManifest.ServerDefinitions).toContain(SessionEvent.UsageUpdated) expect(EventManifest.Definitions.every((definition) => definition.durability !== undefined)).toBe(true) }) + test("uses the current Session skill event as durable version 1", () => { + expect(EventManifest.Durable.get("session.skill.activated.1")).toBe(SessionEvent.Skill.Activated) + expect(EventManifest.Latest.get("session.skill.activated")).toBe(SessionEvent.Skill.Activated) + }) + test("keeps simplified session fragment and tool payloads on durable version 1", () => { const sessionID = SessionID.make("ses_test") const assistantMessageID = SessionMessage.ID.make("msg_test") diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index c1956cffe0..bd8f984d7f 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -1,5 +1,9 @@ export * from "./gen/types.gen.js" export type { FileSystemEntry as LocationFileSystemEntry } from "./gen/types.gen.js" +import type { UserMessage } from "./gen/types.gen.js" + +/** @deprecated V1 snapshot compatibility. Use FileDiffInfo for current API responses. */ +export type SnapshotFileDiff = NonNullable["diffs"]>[number] import { createClient } from "./gen/client/client.gen.js" import { type Config } from "./gen/client/types.gen.js" diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a88ad8e008..03d76cc5de 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -5185,86 +5185,6 @@ export class Plugin extends HeyApiClient { } } -export class Switch extends HeyApiClient { - /** - * Switch session agent - * - * Switch the agent used by subsequent provider turns. - */ - public agent( - parameters: { - sessionID: string - agent?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "body", key: "agent" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - V2SessionSwitchAgentResponses, - V2SessionSwitchAgentErrors, - ThrowOnError - >({ - url: "/api/session/{sessionID}/agent", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * Switch session model - * - * Switch the model used by subsequent provider turns. - */ - public model( - parameters: { - sessionID: string - model?: ModelRef - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "sessionID" }, - { in: "body", key: "model" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - V2SessionSwitchModelResponses, - V2SessionSwitchModelErrors, - ThrowOnError - >({ - url: "/api/session/{sessionID}/model", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - export class Revert extends HeyApiClient { /** * Stage session revert @@ -6068,6 +5988,84 @@ export class Session3 extends HeyApiClient { }) } + /** + * Switch session agent + * + * Switch the agent used by subsequent provider turns. + */ + public switchAgent( + parameters: { + sessionID: string + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchAgentResponses, + V2SessionSwitchAgentErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/agent", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Switch session model + * + * Switch the model used by subsequent provider turns. + */ + public switchModel( + parameters: { + sessionID: string + model?: ModelRef + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchModelResponses, + V2SessionSwitchModelErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/model", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Rename session * @@ -6528,11 +6526,6 @@ export class Session3 extends HeyApiClient { }) } - private _switch?: Switch - get switch(): Switch { - return (this._switch ??= new Switch({ client: this.client })) - } - private _revert?: Revert get revert(): Revert { return (this._revert ??= new Revert({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 59afb28c06..0edaac8a7c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -171,14 +171,6 @@ export type MoveSessionError = { } } -export type SnapshotFileDiff = { - file?: string - patch?: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} - export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -189,59 +181,6 @@ export type PermissionRule = { export type PermissionRuleset = Array -export type Session = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - export type OutputFormatText = { type: "text" } @@ -269,7 +208,7 @@ export type UserMessage = { summary?: { title?: string body?: string - diffs: Array + diffs: Array } agent: string model: { @@ -812,7 +751,7 @@ export type GlobalEvent = { type: "session.created" properties: { sessionID: string - info: Session + info: SessionV1Info } } | { @@ -820,7 +759,7 @@ export type GlobalEvent = { type: "session.updated" properties: { sessionID: string - info: Session + info: SessionV1Info } } | { @@ -902,16 +841,8 @@ export type GlobalEvent = { type: "session.usage.updated" properties: { sessionID: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo } } | { @@ -996,6 +927,7 @@ export type GlobalEvent = { type: "session.skill.activated" properties: { sessionID: string + id: string name: string text: string } @@ -1040,16 +972,8 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -1061,16 +985,8 @@ export type GlobalEvent = { sessionID: string assistantMessageID: string error: SessionStructuredError - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost?: MoneyUsd + tokens?: TokenUsageInfo } } | { @@ -1201,7 +1117,6 @@ export type GlobalEvent = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -1245,6 +1160,8 @@ export type GlobalEvent = { properties: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } | { @@ -1270,6 +1187,9 @@ export type GlobalEvent = { type: "session.compaction.failed" properties: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } | { @@ -1277,7 +1197,7 @@ export type GlobalEvent = { type: "session.revert.staged" properties: { sessionID: string - revert: RevertState + revert: SessionRevert } } | { @@ -1311,7 +1231,7 @@ export type GlobalEvent = { type: "session.diff" properties: { sessionID: string - diff: Array + diff: Array } } | { @@ -2394,7 +2314,7 @@ export type GlobalSession = { additions: number deletions: number files: number - diffs?: Array + diffs?: Array } cost?: number tokens?: { @@ -2730,6 +2650,59 @@ export type ProviderAuthError1 = { } } +export type Session = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type NotFoundError = { name: "NotFoundError" data: { @@ -2874,7 +2847,7 @@ export type UnauthorizedError = { } export type SessionsResponse = { - data: Array + data: Array cursor: { previous?: string next?: string @@ -2963,52 +2936,12 @@ export type Shell1 = { } } -export type SessionDurableEvent = - | SessionAgentSelected - | SessionModelSelected - | SessionMoved - | SessionRenamed - | SessionDeleted - | SessionForked - | SessionPromptPromoted - | SessionPromptAdmitted - | SessionExecutionStarted - | SessionExecutionSucceeded - | SessionExecutionFailed - | SessionExecutionInterrupted - | SessionInstructionsUpdated - | SessionSynthetic - | SessionSkillActivated - | SessionShellStarted - | SessionShellEnded - | SessionStepStarted - | SessionStepEnded - | SessionStepFailed - | SessionTextStarted - | SessionTextEnded - | SessionReasoningStarted - | SessionReasoningEnded - | SessionToolInputStarted - | SessionToolInputEnded - | SessionToolCalled - | SessionToolProgress - | SessionToolSuccess - | SessionToolFailed - | SessionRetryScheduled - | SessionCompactionAdmitted - | SessionCompactionStarted - | SessionCompactionEnded - | SessionCompactionFailed - | SessionRevertStaged - | SessionRevertCleared - | SessionRevertCommitted - -export type SessionLogItem = SessionDurableEvent | EventLogSynced +export type SessionLogItem = SessionEventDurable | EventLogSynced export type SessionLogItemStream = string export type SessionMessagesResponse = { - data: Array + data: Array cursor: { previous?: string next?: string @@ -3337,12 +3270,73 @@ export type IntegrationRef = { name: string } -export type SkillV2Source = SkillV2DirectorySource | SkillV2UrlSource | SkillV2EmbeddedSource +export type SkillSource = SkillDirectorySource | SkillUrlSource | SkillEmbeddedSource export type MoveSessionDestination = { directory: string } +export type FileDiffLegacyInfo = { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +export type SessionV1Info = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type ModelRef = { id: string providerID: string @@ -3354,6 +3348,18 @@ export type LocationRef = { workspaceID?: string } +export type MoneyUsd = number + +export type TokenUsageInfo = { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } +} + export type PromptBase64 = string export type PromptFileSource = @@ -3408,20 +3414,19 @@ export type ToolFileContent = { export type LlmToolContent = ToolTextContent | ToolFileContent -export type FileDiff = { - path: string - status: "added" | "modified" | "deleted" +export type FileDiffInfo = { + file: string + patch: string additions: number deletions: number - patch: string + status: "added" | "deleted" | "modified" } -export type RevertState = { +export type SessionRevert = { messageID: string partID?: string snapshot?: string - diff?: string - files?: Array + files?: Array } export type PermissionV2Source = { @@ -3621,7 +3626,7 @@ export type SyncEventSessionCreated = { aggregateID: string data: { sessionID: string - info: Session + info: SessionV1Info } } } @@ -3636,7 +3641,7 @@ export type SyncEventSessionUpdated = { aggregateID: string data: { sessionID: string - info: Session + info: SessionV1Info } } } @@ -3928,6 +3933,7 @@ export type SyncEventSessionSkillActivated = { aggregateID: string data: { sessionID: string + id: string name: string text: string } @@ -4000,16 +4006,8 @@ export type SyncEventSessionStepEnded = { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -4028,16 +4026,8 @@ export type SyncEventSessionStepFailed = { sessionID: string assistantMessageID: string error: SessionStructuredError - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost?: MoneyUsd + tokens?: TokenUsageInfo } } } @@ -4201,7 +4191,6 @@ export type SyncEventSessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -4273,6 +4262,8 @@ export type SyncEventSessionCompactionStarted = { data: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } } @@ -4304,6 +4295,9 @@ export type SyncEventSessionCompactionFailed = { aggregateID: string data: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } } @@ -4318,7 +4312,7 @@ export type SyncEventSessionRevertStaged = { aggregateID: string data: { sessionID: string - revert: RevertState + revert: SessionRevert } } } @@ -4417,8 +4411,9 @@ export type PermissionV2Rule = { export type PermissionV2Ruleset = Array -export type AgentV2Info = { +export type AgentInfo = { id: string + name: string model?: ModelRef request: ProviderRequest system?: string @@ -4434,7 +4429,7 @@ export type PluginInfo = { id: string } -export type SessionV2Info = { +export type SessionInfo = { id: string parentID?: string fork?: { @@ -4444,16 +4439,8 @@ export type SessionV2Info = { projectID: string agent?: string model?: ModelRef - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo time: { created: number updated: number @@ -4462,7 +4449,7 @@ export type SessionV2Info = { title: string location: LocationRef subpath?: string - revert?: RevertState + revert?: SessionRevert } export type PromptInputFileAttachment = { @@ -4538,7 +4525,6 @@ export type SessionMessageSynthetic = { time: { created: number } - sessionID: string text: string description?: string type: "synthetic" @@ -4565,6 +4551,7 @@ export type SessionMessageSkill = { created: number } type: "skill" + skill: string name: string text: string } @@ -4579,7 +4566,10 @@ export type SessionMessageShell = { completed?: number } type: "shell" - shell: Shell + shellID: string + command: string + status: "running" | "exited" | "timeout" | "killed" + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" output?: { output: string cursor: number @@ -4603,8 +4593,8 @@ export type SessionMessageAssistantReasoning = { } } -export type SessionMessageToolStatePending = { - status: "pending" +export type SessionMessageToolStateStreaming = { + status: "streaming" input: string } @@ -4624,9 +4614,7 @@ export type SessionMessageToolStateCompleted = { input: { [key: string]: unknown } - attachments?: Array content: Array - outputPaths?: Array structured: { [key: string]: unknown } @@ -4654,7 +4642,7 @@ export type SessionMessageAssistantTool = { providerState?: SessionMessageProviderState providerResultState?: SessionMessageProviderState state: - | SessionMessageToolStatePending + | SessionMessageToolStateStreaming | SessionMessageToolStateRunning | SessionMessageToolStateCompleted | SessionMessageToolStateError @@ -4662,7 +4650,6 @@ export type SessionMessageAssistantTool = { created: number ran?: number completed?: number - pruned?: number } } @@ -4691,26 +4678,14 @@ export type SessionMessageAssistant = { files?: Array } finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost?: MoneyUsd + tokens?: TokenUsageInfo error?: SessionStructuredError retry?: SessionMessageAssistantRetry } -export type SessionMessageCompaction = { +export type SessionMessageCompactionRunning = { type: "compaction" - status: "queued" | "running" | "completed" | "failed" - reason: "auto" | "manual" - summary: string - recent: string id: string metadata?: { [key: string]: unknown @@ -4718,9 +4693,47 @@ export type SessionMessageCompaction = { time: { created: number } + status: "running" + reason: "auto" | "manual" + summary: string + recent: string } -export type SessionMessage = +export type SessionMessageCompactionCompleted = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "completed" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionFailed = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "failed" + reason: "auto" | "manual" + error: SessionStructuredError +} + +export type SessionMessageCompaction = + | SessionMessageCompactionRunning + | SessionMessageCompactionCompleted + | SessionMessageCompactionFailed + +export type SessionMessageInfo = | SessionMessageAgentSelected | SessionMessageModelSelected | SessionMessageUser @@ -4748,7 +4761,7 @@ export type SessionAgentSelected = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4767,7 +4780,7 @@ export type SessionModelSelected = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4786,7 +4799,7 @@ export type SessionMoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4806,7 +4819,7 @@ export type SessionRenamed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4825,7 +4838,7 @@ export type SessionDeleted = { durable: { aggregateID: string seq: number - version: number + version: 2 } location?: LocationRef data: { @@ -4843,7 +4856,7 @@ export type SessionForked = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4863,7 +4876,7 @@ export type SessionPromptPromoted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4882,7 +4895,7 @@ export type SessionPromptAdmitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4903,7 +4916,7 @@ export type SessionExecutionStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4921,7 +4934,7 @@ export type SessionExecutionSucceeded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4939,7 +4952,7 @@ export type SessionExecutionFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4958,7 +4971,7 @@ export type SessionExecutionInterrupted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4977,7 +4990,7 @@ export type SessionInstructionsUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -4996,7 +5009,7 @@ export type SessionSynthetic = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5019,11 +5032,12 @@ export type SessionSkillActivated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string + id: string name: string text: string } @@ -5039,7 +5053,7 @@ export type SessionShellStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5058,7 +5072,7 @@ export type SessionShellEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5083,7 +5097,7 @@ export type SessionStepStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5105,23 +5119,15 @@ export type SessionStepEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -5137,23 +5143,15 @@ export type SessionStepFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string assistantMessageID: string error: SessionStructuredError - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost?: MoneyUsd + tokens?: TokenUsageInfo } } @@ -5167,7 +5165,7 @@ export type SessionTextStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5187,7 +5185,7 @@ export type SessionTextEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5208,7 +5206,7 @@ export type SessionReasoningStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5229,7 +5227,7 @@ export type SessionReasoningEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5251,7 +5249,7 @@ export type SessionToolInputStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5272,7 +5270,7 @@ export type SessionToolInputEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5293,7 +5291,7 @@ export type SessionToolCalled = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5318,7 +5316,7 @@ export type SessionToolProgress = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5342,7 +5340,7 @@ export type SessionToolSuccess = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5353,7 +5351,6 @@ export type SessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -5370,7 +5367,7 @@ export type SessionToolFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5394,7 +5391,7 @@ export type SessionRetryScheduled = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5416,7 +5413,7 @@ export type SessionCompactionAdmitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5435,12 +5432,14 @@ export type SessionCompactionStarted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } @@ -5454,7 +5453,7 @@ export type SessionCompactionEnded = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5475,11 +5474,14 @@ export type SessionCompactionFailed = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } @@ -5493,12 +5495,12 @@ export type SessionRevertStaged = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - revert: RevertState + revert: SessionRevert } } @@ -5512,7 +5514,7 @@ export type SessionRevertCleared = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5530,7 +5532,7 @@ export type SessionRevertCommitted = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5539,6 +5541,46 @@ export type SessionRevertCommitted = { } } +export type SessionEventDurable = + | SessionAgentSelected + | SessionModelSelected + | SessionMoved + | SessionRenamed + | SessionDeleted + | SessionForked + | SessionPromptPromoted + | SessionPromptAdmitted + | SessionExecutionStarted + | SessionExecutionSucceeded + | SessionExecutionFailed + | SessionExecutionInterrupted + | SessionInstructionsUpdated + | SessionSynthetic + | SessionSkillActivated + | SessionShellStarted + | SessionShellEnded + | SessionStepStarted + | SessionStepEnded + | SessionStepFailed + | SessionTextStarted + | SessionTextEnded + | SessionReasoningStarted + | SessionReasoningEnded + | SessionToolInputStarted + | SessionToolInputEnded + | SessionToolCalled + | SessionToolProgress + | SessionToolSuccess + | SessionToolFailed + | SessionRetryScheduled + | SessionCompactionAdmitted + | SessionCompactionStarted + | SessionCompactionEnded + | SessionCompactionFailed + | SessionRevertStaged + | SessionRevertCleared + | SessionRevertCommitted + export type EventLogSynced = { type: "log.synced" aggregateID: string @@ -5564,20 +5606,22 @@ export type ModelVariant = { } } +export type MoneyUsdPerMillionTokens = number + export type ModelCost = { tier?: { type: "context" size: number } - input: number - output: number + input: MoneyUsdPerMillionTokens + output: MoneyUsdPerMillionTokens cache: { - read: number - write: number + read: MoneyUsdPerMillionTokens + write: MoneyUsdPerMillionTokens } } -export type ModelV2Info = { +export type ModelInfo = { id: string modelID: string providerID: string @@ -5835,7 +5879,7 @@ export type FileSystemEntry = { type: "file" | "directory" } -export type CommandV2Info = { +export type CommandInfo = { name: string template: string description?: string @@ -5844,7 +5888,8 @@ export type CommandV2Info = { subtask?: boolean } -export type SkillV2Info = { +export type SkillInfo = { + id: string name: string description?: string slash?: boolean @@ -5928,12 +5973,12 @@ export type SessionCreated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - info: Session + info: SessionV1Info } } @@ -5947,12 +5992,12 @@ export type SessionUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { sessionID: string - info: Session + info: SessionV1Info } } @@ -5966,7 +6011,7 @@ export type MessageUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -5985,7 +6030,7 @@ export type MessageRemoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -6004,7 +6049,7 @@ export type MessagePartUpdated = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -6024,7 +6069,7 @@ export type MessagePartRemoved = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRef data: { @@ -6044,16 +6089,8 @@ export type SessionUsageUpdated = { location?: LocationRef data: { sessionID: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo } } @@ -6146,7 +6183,7 @@ export type SessionDiff = { location?: LocationRef data: { sessionID: string - diff: Array + diff: Array } } @@ -7029,7 +7066,7 @@ export type EventSessionCreated = { type: "session.created" properties: { sessionID: string - info: Session + info: SessionV1Info } } @@ -7038,7 +7075,7 @@ export type EventSessionUpdated = { type: "session.updated" properties: { sessionID: string - info: Session + info: SessionV1Info } } @@ -7130,16 +7167,8 @@ export type EventSessionUsageUpdated = { type: "session.usage.updated" properties: { sessionID: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo } } @@ -7234,6 +7263,7 @@ export type EventSessionSkillActivated = { type: "session.skill.activated" properties: { sessionID: string + id: string name: string text: string } @@ -7282,16 +7312,8 @@ export type EventSessionStepEnded = { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -7304,16 +7326,8 @@ export type EventSessionStepFailed = { sessionID: string assistantMessageID: string error: SessionStructuredError - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost?: MoneyUsd + tokens?: TokenUsageInfo } } @@ -7456,7 +7470,6 @@ export type EventSessionToolSuccess = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState @@ -7504,6 +7517,8 @@ export type EventSessionCompactionStarted = { properties: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } @@ -7532,6 +7547,9 @@ export type EventSessionCompactionFailed = { type: "session.compaction.failed" properties: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } @@ -7540,7 +7558,7 @@ export type EventSessionRevertStaged = { type: "session.revert.staged" properties: { sessionID: string - revert: RevertState + revert: SessionRevert } } @@ -7578,7 +7596,7 @@ export type EventSessionDiff = { type: "session.diff" properties: { sessionID: string - diff: Array + diff: Array } } @@ -8096,19 +8114,19 @@ export type CredentialKey = { } } -export type SkillV2DirectorySource = { +export type SkillDirectorySource = { type: "directory" path: string } -export type SkillV2UrlSource = { +export type SkillUrlSource = { type: "url" url: string } -export type SkillV2EmbeddedSource = { +export type SkillEmbeddedSource = { type: "embedded" - skill: SkillV2Info + skill: SkillInfo } export type BadRequestError = { @@ -8127,7 +8145,7 @@ export type InvalidRequestErrorV2 = { } export type SessionsResponseV2 = { - data: Array + data: Array cursor: { previous?: string | null next?: string | null @@ -8167,77 +8185,24 @@ export type ShellV2 = { shell: string file: string pid?: number - exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + exit?: number | "NaN" | "Infinity" | "-Infinity" metadata: { [key: string]: unknown } time: { - started: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - completed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + started: number | "NaN" | "Infinity" | "-Infinity" + completed?: number | "NaN" | "Infinity" | "-Infinity" } } export type SessionMessagesResponseV2 = { - data: Array + data: Array cursor: { previous?: string | null next?: string | null } } -export type SessionV2 = { - id: string - slug: string - projectID: string - workspaceID?: string - directory: string - path?: string - parentID?: string - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - share?: { - url: string - } - title: string - agent?: string - model?: { - id: string - providerID: string - variant?: string - } - version: string - metadata?: { - [key: string]: unknown - } - time: { - created: number - updated: number - compacting?: number - archived?: number - } - permission?: PermissionRuleset - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } -} - export type OutputFormatV2 = | { type: "text" @@ -8259,7 +8224,7 @@ export type UserMessageV2 = { summary?: { title?: string | null body?: string | null - diffs: Array + diffs: Array } | null agent: string model: { @@ -8777,8 +8742,9 @@ export type LocationInfoV2 = { export type AgentColorV2 = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" -export type AgentV2InfoV2 = { +export type AgentInfoV2 = { id: string + name: string model?: ModelRef request: ProviderRequest system?: string @@ -8795,23 +8761,22 @@ export type LocationRefV2 = { workspaceID?: string } -export type FileDiffV2 = { - path: string - status: "added" | "modified" | "deleted" +export type FileDiffInfoV2 = { + file: string + patch: string additions: number deletions: number - patch: string + status: "added" | "deleted" | "modified" } -export type RevertStateV2 = { +export type SessionRevertV2 = { messageID: string partID?: string snapshot?: string - diff?: string - files?: Array + files?: Array } -export type SessionV2InfoV2 = { +export type SessionInfoV2 = { id: string parentID?: string fork?: { @@ -8821,16 +8786,8 @@ export type SessionV2InfoV2 = { projectID: string agent?: string model?: ModelRef - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo time: { created: number updated: number @@ -8839,7 +8796,7 @@ export type SessionV2InfoV2 = { title: string location: LocationRefV2 subpath?: string - revert?: RevertStateV2 + revert?: SessionRevertV2 } export type PromptBase64V2 = string @@ -8910,7 +8867,6 @@ export type SessionMessageSyntheticV2 = { time: { created: number } - sessionID: string text: string description?: string type: "synthetic" @@ -8937,6 +8893,7 @@ export type SessionMessageSkillV2 = { created: number } type: "skill" + skill: string name: string text: string } @@ -8951,7 +8908,10 @@ export type SessionMessageShellV2 = { completed?: number } type: "shell" - shell: ShellV2 + shellID: string + command: string + status: "running" | "exited" | "timeout" | "killed" + exit?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" output?: { output: string cursor: number @@ -8985,26 +8945,14 @@ export type SessionMessageAssistantV2 = { files?: Array } finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost?: MoneyUsd + tokens?: TokenUsageInfo error?: SessionStructuredError retry?: SessionMessageAssistantRetryV2 } -export type SessionMessageCompactionV2 = { +export type SessionMessageCompactionRunningV2 = { type: "compaction" - status: "queued" | "running" | "completed" | "failed" - reason: "auto" | "manual" - summary: string - recent: string id: string metadata?: { [key: string]: unknown @@ -9012,6 +8960,39 @@ export type SessionMessageCompactionV2 = { time: { created: number } + status: "running" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionCompletedV2 = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "completed" + reason: "auto" | "manual" + summary: string + recent: string +} + +export type SessionMessageCompactionFailedV2 = { + type: "compaction" + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + status: "failed" + reason: "auto" | "manual" + error: SessionStructuredError } /** @@ -9029,7 +9010,7 @@ export type SessionAgentSelectedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9048,7 +9029,7 @@ export type SessionModelSelectedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9067,7 +9048,7 @@ export type SessionMovedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9087,7 +9068,7 @@ export type SessionRenamedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9106,7 +9087,7 @@ export type SessionDeletedV2 = { durable: { aggregateID: string seq: number - version: number + version: 2 } location?: LocationRefV2 data: { @@ -9124,7 +9105,7 @@ export type SessionForkedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9144,7 +9125,7 @@ export type SessionPromptPromotedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9163,7 +9144,7 @@ export type SessionPromptAdmittedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9184,7 +9165,7 @@ export type SessionExecutionStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9202,7 +9183,7 @@ export type SessionExecutionSucceededV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9220,7 +9201,7 @@ export type SessionExecutionFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9239,7 +9220,7 @@ export type SessionExecutionInterruptedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9258,7 +9239,7 @@ export type SessionInstructionsUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9277,7 +9258,7 @@ export type SessionSyntheticV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9300,11 +9281,12 @@ export type SessionSkillActivatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string + id: string name: string text: string } @@ -9320,7 +9302,7 @@ export type SessionShellStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9339,7 +9321,7 @@ export type SessionShellEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9364,7 +9346,7 @@ export type SessionStepStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9386,23 +9368,15 @@ export type SessionStepEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string assistantMessageID: string finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo snapshot?: string files?: Array } @@ -9418,23 +9392,15 @@ export type SessionStepFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string assistantMessageID: string error: SessionStructuredError - cost?: number - tokens?: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost?: MoneyUsd + tokens?: TokenUsageInfo } } @@ -9448,7 +9414,7 @@ export type SessionTextStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9468,7 +9434,7 @@ export type SessionTextEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9493,7 +9459,7 @@ export type SessionReasoningStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9518,7 +9484,7 @@ export type SessionReasoningEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9540,7 +9506,7 @@ export type SessionToolInputStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9561,7 +9527,7 @@ export type SessionToolInputEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9586,7 +9552,7 @@ export type SessionToolCalledV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9611,7 +9577,7 @@ export type SessionToolProgressV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9639,7 +9605,7 @@ export type SessionToolSuccessV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9650,7 +9616,6 @@ export type SessionToolSuccessV2 = { [key: string]: unknown } content: Array - outputPaths?: Array result?: unknown executed: boolean resultState?: SessionMessageProviderState6 @@ -9671,7 +9636,7 @@ export type SessionToolFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9695,7 +9660,7 @@ export type SessionRetryScheduledV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9717,7 +9682,7 @@ export type SessionCompactionAdmittedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9736,12 +9701,14 @@ export type SessionCompactionStartedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string reason: "auto" | "manual" + recent: string + inputID?: string } } @@ -9755,7 +9722,7 @@ export type SessionCompactionEndedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9776,11 +9743,14 @@ export type SessionCompactionFailedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string + reason: "auto" | "manual" + error: SessionStructuredError + inputID?: string } } @@ -9794,12 +9764,12 @@ export type SessionRevertStagedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - revert: RevertStateV2 + revert: SessionRevertV2 } } @@ -9813,7 +9783,7 @@ export type SessionRevertClearedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -9831,7 +9801,7 @@ export type SessionRevertCommittedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -10037,6 +10007,59 @@ export type AgentUpdatedV2 = { | Array } +export type SessionV1InfoV2 = { + id: string + slug: string + projectID: string + workspaceID?: string + directory: string + path?: string + parentID?: string + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + share?: { + url: string + } + title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + version: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + updated: number + compacting?: number + archived?: number + } + permission?: PermissionRuleset + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } +} + export type SessionCreatedV2 = { id: string created: number @@ -10047,12 +10070,12 @@ export type SessionCreatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - info: SessionV2 + info: SessionV1InfoV2 } } @@ -10066,12 +10089,12 @@ export type SessionUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - info: SessionV2 + info: SessionV1InfoV2 } } @@ -10085,12 +10108,12 @@ export type SessionDeleted1 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { sessionID: string - info: SessionV2 + info: SessionV1InfoV2 } } @@ -10104,7 +10127,7 @@ export type MessageUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -10123,7 +10146,7 @@ export type MessageRemovedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -10142,7 +10165,7 @@ export type MessagePartUpdatedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -10162,7 +10185,7 @@ export type MessagePartRemovedV2 = { durable: { aggregateID: string seq: number - version: number + version: 1 } location?: LocationRefV2 data: { @@ -10182,16 +10205,8 @@ export type SessionUsageUpdatedV2 = { location?: LocationRefV2 data: { sessionID: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } + cost: MoneyUsd + tokens: TokenUsageInfo } } @@ -13626,7 +13641,7 @@ export type SessionDiffResponses = { /** * Successfully retrieved diff */ - 200: Array + 200: Array } export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] @@ -15204,7 +15219,7 @@ export type V2AgentListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -15322,7 +15337,7 @@ export type V2SessionCreateResponses = { * Success */ 200: { - data: SessionV2InfoV2 + data: SessionInfoV2 } } @@ -15427,7 +15442,7 @@ export type V2SessionGetResponses = { * Success */ 200: { - data: SessionV2InfoV2 + data: SessionInfoV2 } } @@ -15466,7 +15481,7 @@ export type V2SessionForkResponses = { * Success */ 200: { - data: SessionV2InfoV2 + data: SessionInfoV2 } } @@ -15967,7 +15982,7 @@ export type V2SessionRevertStageResponses = { * Success */ 200: { - data: RevertStateV2 + data: SessionRevertV2 } } @@ -16090,7 +16105,7 @@ export type V2SessionContextResponses = { * Success */ 200: { - data: Array + data: Array } } @@ -16357,7 +16372,7 @@ export type V2SessionMessageResponses = { * Success */ 200: { - data: SessionMessage + data: SessionMessageInfo } } @@ -16447,7 +16462,7 @@ export type V2ModelListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -16488,7 +16503,7 @@ export type V2ModelDefaultResponses = { */ 200: { location: LocationInfoV2 - data: ModelV2Info | null + data: ModelInfo | null } } @@ -17829,7 +17844,7 @@ export type V2CommandListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -17866,7 +17881,7 @@ export type V2SkillListResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } @@ -18833,7 +18848,7 @@ export type V2VcsDiffResponses = { */ 200: { location: LocationInfoV2 - data: Array + data: Array } } diff --git a/packages/server/src/handlers/message.ts b/packages/server/src/handlers/message.ts index 93734c628d..40e2ba1077 100644 --- a/packages/server/src/handlers/message.ts +++ b/packages/server/src/handlers/message.ts @@ -16,7 +16,7 @@ const Cursor = Schema.Struct({ const decodeCursor = Schema.decodeUnknownSync(Cursor) const cursor = { - encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") { + encode(message: SessionMessage.Info, order: "asc" | "desc", direction: "previous" | "next") { return Buffer.from(JSON.stringify({ id: message.id, order, direction })).toString("base64url") }, decode(input: string) { diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 48e8eee310..6fccfc305d 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -1,6 +1,6 @@ import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parsePatch } from "diff" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" type LegacyDiff = { file: string @@ -12,8 +12,7 @@ type LegacyDiff = { status?: "added" | "deleted" | "modified" } -type SnapshotDiff = SnapshotFileDiff & { file: string } -type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff | LegacyDiff export type DiffSource = Pick export type ViewDiff = { diff --git a/packages/session-ui/src/components/session-review.tsx b/packages/session-ui/src/components/session-review.tsx index 8db21f025b..ec94af2f2d 100644 --- a/packages/session-ui/src/components/session-review.tsx +++ b/packages/session-ui/src/components/session-review.tsx @@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { checksum } from "@opencode-ai/core/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" +import { type FileContent, type FileDiffInfo, type VcsFileDiff } from "@opencode-ai/sdk/v2" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" @@ -62,14 +62,12 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { +type RawReviewDiff = (FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { +type ReviewDiff = (FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } - function diff(value: unknown): value is ReviewDiff { if (!value || typeof value !== "object" || Array.isArray(value)) return false if (!("file" in value) || typeof value.file !== "string") return false @@ -185,7 +183,7 @@ export const SessionReview = (props: SessionReviewProps) => { const itemsMap = createMemo(() => Object.fromEntries(list(props.diffs).map((diff) => [diff.file, { ...normalize(diff), preloaded: diff.preloaded }])), ) - const files = createMemo(() => props.diffs.map((diff) => diff.file!)) + const files = createMemo(() => props.diffs.map((diff) => diff.file)) const grouped = createMemo(() => { const next = new Map() for (const comment of props.comments ?? []) { diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 4ebf6459b1..cda95c0e8e 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -1,8 +1,9 @@ import { AssistantMessage, - type SnapshotFileDiff, + type FileDiffInfo, Message as MessageType, Part as PartType, + type UserMessage, } from "@opencode-ai/sdk/v2/client" import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" @@ -90,10 +91,17 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -type SummaryDiff = SnapshotFileDiff & { file: string } +type SummaryDiffInput = NonNullable["diffs"]>[number] +type SummaryDiff = FileDiffInfo -function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { - return typeof value.file === "string" +function summaryDiff(value: SummaryDiffInput): value is SummaryDiff { + return ( + typeof value.file === "string" && + typeof value.patch === "string" && + typeof value.additions === "number" && + typeof value.deletions === "number" && + value.status !== undefined + ) } const hidden = new Set(["todowrite"]) diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 999ff510d5..d505249931 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,4 +1,4 @@ -import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, Message, Part, Provider, Session, SessionStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -21,7 +21,7 @@ type Data = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index 3e7bd83051..c05fe3a745 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -6,7 +6,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file" import { useI18n } from "@opencode-ai/ui/context/i18n" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" -import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileContent, FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -27,7 +27,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import "./session-review-v2.css" -type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = FileDiffInfo | VcsFileDiff export type SessionReviewFilePreviewV2Props = { file: string diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 72de26d278..025c0968b8 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -1,6 +1,6 @@ import { createMemo, createResource, createSignal, onMount } from "solid-js" import path from "path" -import type { SessionV2Info } from "@opencode-ai/sdk/v2" +import type { SessionInfo } from "@opencode-ai/sdk/v2" import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" @@ -44,7 +44,7 @@ export function DialogSessionList() { workspace: location.workspaceID, }) // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; session list UI reuses legacy mutable session types. - return { query, sessions: structuredClone(response.data) as SessionV2Info[] } + return { query, sessions: structuredClone(response.data) as SessionInfo[] } }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) @@ -77,7 +77,7 @@ export function DialogSessionList() { const pinnedSet = new Set(pinned) const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1])) - const option = (session: SessionV2Info, category: string) => { + const option = (session: SessionInfo, category: string) => { const directory = session.location.directory const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : "" const slot = slotByID.get(session.id) @@ -88,12 +88,11 @@ export function DialogSessionList() { category, footer, bg: deleting ? theme.error : undefined, - gutter: - data.session.family(session.id).some((id) => data.session.status(id) === "running") - ? () => - : slot === undefined - ? undefined - : () => {slot}, + gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running") + ? () => + : slot === undefined + ? undefined + : () => {slot}, } } @@ -135,16 +134,14 @@ export function DialogSessionList() { setToDelete(option.value) return } - void sdk.client.v2.session - .remove({ sessionID: option.value }, { throwOnError: true }) - .catch((error) => { - setToDelete(undefined) - toast.show({ - message: `Failed to delete session: ${errorMessage(error)}`, - variant: "error", - duration: 5000, - }) + void sdk.client.v2.session.remove({ sessionID: option.value }, { throwOnError: true }).catch((error) => { + setToDelete(undefined) + toast.show({ + message: `Failed to delete session: ${errorMessage(error)}`, + variant: "error", + duration: 5000, }) + }) }, }, { diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index 61c173a384..bb54dd8713 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -45,10 +45,10 @@ export function DialogSkill(props: DialogSkillProps) { return list.map((skill) => ({ title: skill.name.padEnd(maxWidth), description: skill.description?.replace(/\s+/g, " ").trim(), - value: skill.name, + value: skill.id, category: "Skills", onSelect: () => { - props.onSelect(skill.name) + props.onSelect(skill.id) dialog.clear() }, })) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 65bff40838..1b86dbe582 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -448,12 +448,12 @@ export function Autocomplete(props: { for (const skill of data.location.skill .list(location()) - ?.filter((skill) => skill.slash === true && !commandNames.has(skill.name)) ?? []) { + ?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) { results.push({ - display: "/" + skill.name, + display: "/" + skill.id, description: skill.description, onSelect: () => { - const newText = "/" + skill.name + " " + const newText = "/" + skill.id + " " const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 14b34f93c2..0fcbfa96d5 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" +import type { SessionInfo, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { createColors, createFrames } from "../../ui/spinner" @@ -165,9 +165,9 @@ export function Prompt(props: PromptProps) { const status = createMemo(() => data.session.status(props.sessionID ?? "")) const activeSubagents = createMemo(() => { if (!props.sessionID) return 0 - return data.session.family(props.sessionID).filter( - (id) => id !== props.sessionID && data.session.status(id) === "running", - ).length + return data.session + .family(props.sessionID) + .filter((id) => id !== props.sessionID && data.session.status(id) === "running").length }) const runningShells = createMemo( () => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length, @@ -1028,7 +1028,7 @@ export function Prompt(props: PromptProps) { sessionID = created.id // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; prompt state still uses legacy mutable session types. - session = structuredClone(created) as SessionV2Info + session = structuredClone(created) as SessionInfo } const inputText = expandTrackedPastedText( @@ -1099,7 +1099,7 @@ export function Prompt(props: PromptProps) { } else if ( inputText.startsWith("/") && (data.location.skill.list(currentLocation()) ?? []).some( - (skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1), + (skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1), ) ) { move.startSubmit() diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 22f505682d..da44a0f8e9 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,24 +1,24 @@ import type { - AgentV2Info, - CommandV2Info, + AgentInfo, + CommandInfo, FormFormInfo, FormUrlInfo, IntegrationInfo, LocationRef, McpServer, - ModelV2Info, + ModelInfo, PermissionSavedInfo, PermissionV2Request, ProviderV2Info, ReferenceInfo, - SessionMessage, + SessionMessageInfo, SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, SessionMessageAssistantTool, - SessionV2Info, + SessionInfo, Shell, - SkillV2Info, + SkillInfo, V2Event, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" @@ -33,30 +33,28 @@ const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") export type FormInfo = FormFormInfo | FormUrlInfo type LocationData = { - agent?: AgentV2Info[] - command?: CommandV2Info[] + agent?: AgentInfo[] + command?: CommandInfo[] integration?: IntegrationInfo[] mcp?: McpServer[] - model?: ModelV2Info[] + model?: ModelInfo[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] // Currently running shell commands for this location, keyed by shell id. Entries are removed // once the command exits or is deleted, so this only ever holds in-flight shells. shell?: Record - skill?: SkillV2Info[] + skill?: SkillInfo[] } type Data = { session: { - info: Record + info: Record // Family index keyed by a family's root (or furthest-known-ancestor when the // true root is not yet loaded). The value is a flat deduplicated list of every // session ID in that family, including the key itself once its info arrives. family: Record status: Record - compaction: Partial> - compactionReason: Partial> - message: Record + message: Record input: Record permission: Record // Pending forms keyed by session ID. @@ -92,8 +90,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ info: {}, family: {}, status: {}, - compaction: {}, - compactionReason: {}, message: {}, input: {}, permission: {}, @@ -112,7 +108,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const messageIndex = new Map>() const sessionRefreshGeneration = new Map() const sessionRefreshApplied = new Map() - const sessionUsage = new Map() + const sessionUsage = new Map() let connectionGeneration = 0 let statusChanges: Set | undefined let bootstrapping: Promise | undefined @@ -134,14 +130,14 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return true } - function updateSessionUsage(sessionID: string, cost: number, tokens: SessionV2Info["tokens"]) { + function updateSessionUsage(sessionID: string, cost: number, tokens: SessionInfo["tokens"]) { sessionUsage.set(sessionID, { generation: (sessionUsage.get(sessionID)?.generation ?? 0) + 1, cost, tokens }) if (!store.session.info[sessionID]) return setStore("session", "info", sessionID, { cost, tokens }) } const message = { - update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => void) { + update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { setStore( "session", "message", @@ -150,28 +146,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ) }, - append(messages: SessionMessage[], index: Map, item: SessionMessage) { + append(messages: SessionMessageInfo[], index: Map, item: SessionMessageInfo) { if (index.has(item.id)) return index.set(item.id, messages.length) messages.push(item) }, - activeAssistant(messages: SessionMessage[]) { + activeAssistant(messages: SessionMessageInfo[]) { const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed) return item?.type === "assistant" ? item : undefined }, - assistant(messages: SessionMessage[], index: Map, messageID: string) { + assistant(messages: SessionMessageInfo[], index: Map, messageID: string) { const position = index.get(messageID) const item = position === undefined ? undefined : messages[position] return item?.type === "assistant" ? item : undefined }, - shell(messages: SessionMessage[], shellID: string) { - const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID) + shell(messages: SessionMessageInfo[], shellID: string) { + const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID) return item?.type === "shell" ? item : undefined }, - compaction(messages: SessionMessage[]) { - const item = messages.findLast( - (item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"), - ) + compaction(messages: SessionMessageInfo[]) { + const item = messages.findLast((item) => item.type === "compaction" && item.status === "running") return item?.type === "compaction" ? item : undefined }, latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { @@ -251,7 +245,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ produce((draft) => { delete draft.info[sessionID] delete draft.status[sessionID] - delete draft.compaction[sessionID] delete draft.message[sessionID] delete draft.input[sessionID] delete draft.permission[sessionID] @@ -381,6 +374,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: messageIDFromEvent(event.id), type: "system", text: event.data.text, + metadata: event.metadata, time: { created: event.created }, }) }) @@ -390,9 +384,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.append(draft, index, { id: messageIDFromEvent(event.id), type: "synthetic", - sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, + metadata: event.data.metadata, time: { created: event.created }, }) }) @@ -402,7 +396,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.append(draft, index, { id: messageIDFromEvent(event.id), type: "shell", - shell: event.data.shell, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, + exit: event.data.shell.exit, + metadata: event.metadata, time: { created: event.created }, }) }) @@ -411,7 +409,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.update(event.data.sessionID, (draft) => { const match = message.shell(draft, event.data.shell.id) if (!match) return - match.shell = event.data.shell + match.status = event.data.shell.status + match.exit = event.data.shell.exit match.output = event.data.output match.time.completed = event.created }) @@ -440,6 +439,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ type: "assistant", agent: event.data.agent, model: event.data.model, + metadata: event.metadata, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, time: { created: event.created }, @@ -500,7 +500,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: event.data.callID, name: event.data.name, time: { created: event.created }, - state: { status: "pending", input: "" }, + state: { status: "streaming", input: "" }, }) }) break @@ -510,7 +510,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "pending") match.state.input += event.data.delta + if (match?.state.status === "streaming") match.state.input += event.data.delta }) break case "session.tool.input.ended": @@ -519,7 +519,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "pending") match.state.input = event.data.text + if (match?.state.status === "streaming") match.state.input = event.data.text }) break case "session.tool.called": @@ -571,7 +571,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return + if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return match.state = { status: "error", error: event.data.error, @@ -626,36 +626,24 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setSessionStatus(event.data.sessionID, "running") break case "session.compaction.admitted": + break + case "session.compaction.started": message.update(event.data.sessionID, (draft, index) => { - if (message.compaction(draft)) return message.append(draft, index, { - id: event.data.inputID, + id: event.data.inputID ?? messageIDFromEvent(event.id), type: "compaction", - status: "queued", - reason: "manual", + status: "running", + reason: event.data.reason, summary: "", - recent: "", + recent: event.data.recent ?? "", time: { created: event.created }, }) }) break - case "session.compaction.started": - setStore("session", "compaction", event.data.sessionID, "") - setStore("session", "compactionReason", event.data.sessionID, event.data.reason) - if (event.data.reason === "manual") - message.update(event.data.sessionID, (draft) => { - const current = message.compaction(draft) - if (current) current.status = "running" - }) - break case "session.execution.succeeded": case "session.execution.failed": case "session.execution.interrupted": setSessionStatus(event.data.sessionID, "idle") - if (store.session.compaction[event.data.sessionID] !== undefined) - setStore("session", "compaction", event.data.sessionID, undefined) - if (store.session.compactionReason[event.data.sessionID] !== undefined) - setStore("session", "compactionReason", event.data.sessionID, undefined) message.update(event.data.sessionID, (draft) => { const currentAssistant = message.activeAssistant(draft) if (currentAssistant) currentAssistant.retry = undefined @@ -686,23 +674,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.delta": - setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text) - if (store.session.compactionReason[event.data.sessionID] === "manual") - message.update(event.data.sessionID, (draft) => { - const current = message.compaction(draft) - if (current) current.summary += event.data.text - }) + message.update(event.data.sessionID, (draft) => { + const current = message.compaction(draft) + if (current?.status === "running") current.summary += event.data.text + }) break case "session.compaction.ended": - setStore("session", "compaction", event.data.sessionID, undefined) - setStore("session", "compactionReason", event.data.sessionID, undefined) message.update(event.data.sessionID, (draft, index) => { - const current = event.data.reason === "manual" ? message.compaction(draft) : undefined - if (current) { - current.status = "completed" - current.reason = event.data.reason - current.summary = event.data.text - current.recent = event.data.recent + const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") + const current = draft[position] + if (current?.type === "compaction") { + draft[position] = { + id: current.id, + type: "compaction", + status: "completed", + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + metadata: current.metadata, + time: current.time, + } return } message.append(draft, index, { @@ -717,11 +708,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.failed": - setStore("session", "compaction", event.data.sessionID, undefined) - setStore("session", "compactionReason", event.data.sessionID, undefined) - message.update(event.data.sessionID, (draft) => { - const current = message.compaction(draft) - if (current) current.status = "failed" + message.update(event.data.sessionID, (draft, index) => { + const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") + const current = draft[position] + const failed: Extract = { + id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id), + type: "compaction", + status: "failed", + reason: event.data.reason ?? "manual", + error: event.data.error ?? { + type: "compaction.failed", + message: "Compaction failed before recording an error", + }, + metadata: current?.type === "compaction" ? current.metadata : event.metadata, + time: current?.type === "compaction" ? current.time : { created: event.created }, + } + if (current?.type === "compaction") { + draft[position] = failed + return + } + message.append(draft, index, failed) }) break case "permission.v2.asked": @@ -839,9 +845,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.input[sessionID]?.includes(inputID) ?? false }, }, - compaction(sessionID: string) { - return store.session.compaction[sessionID] - }, async refresh(sessionID: string) { const generation = nextSessionRefresh(sessionID) const usageGeneration = sessionUsage.get(sessionID)?.generation ?? 0 @@ -886,14 +889,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ].toSorted((a, b) => a.time.created - b.time.created) messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) setStore("session", "message", sessionID, messages) - const running = messages.find((message) => message.type === "compaction" && message.status === "running") - setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined) - setStore( - "session", - "compactionReason", - sessionID, - running?.type === "compaction" ? running.reason : undefined, - ) }, }, permission: { diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index b218660e9f..40eac011ae 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -15,7 +15,7 @@ import type { ProviderListResponse, QuestionRequest, Session, - SnapshotFileDiff, + FileDiffInfo, Todo, VcsInfo, } from "@opencode-ai/sdk/v2" @@ -49,7 +49,7 @@ export const { question: Record config: Config session: Session[] - session_diff: Record + session_diff: Record todo: Record message: Record part: Record diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index ed88a1107f..1bee45dedc 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -1,6 +1,6 @@ /** @jsxImportSource @opentui/solid */ import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui" -import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import { TextAttributes, type BorderSides, @@ -56,7 +56,7 @@ type DiffFile = { readonly status: "added" | "deleted" | "modified" } -const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] => +const normalizeDiffs = (diffs: readonly (VcsFileDiff | FileDiffInfo | SnapshotFileDiff)[]): DiffFile[] => diffs.flatMap((item) => item.file ? [ diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 3743abed05..e37fae9249 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -26,14 +26,14 @@ import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../con import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { - ModelV2Info, - SessionMessage, + ModelInfo, + SessionMessageInfo, SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, SessionMessageAssistantTool, SessionMessageUser, - SessionV2Info, + SessionInfo, } from "@opencode-ai/sdk/v2" import { useLocal } from "../../context/local" import { Locale } from "../../util/locale" @@ -130,7 +130,7 @@ const context = createContext<{ showGenericToolOutput: () => boolean groupExploration: () => boolean diffWrapMode: () => "word" | "none" - models: () => ModelV2Info[] + models: () => ModelInfo[] tui: ReturnType }>() @@ -172,16 +172,6 @@ export function Session() { }) onCleanup(() => setEpilogue()) const messages = sessionMessages - const transientCompaction = createMemo(() => { - if ( - messages().some( - (message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"), - ) - ) - return - const text = data.session.compaction(route.sessionID) - return text === undefined ? undefined : { text } - }) const descendantSessionIDs = createMemo(() => { if (session()?.parentID) return [] return data.session.family(route.sessionID).filter((id) => id !== route.sessionID) @@ -936,9 +926,6 @@ export function Session() { /> )} - - {(compaction) => } - SessionMessage | undefined }) { +function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) { return ( @@ -1062,7 +1049,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) = ) } -function BackgroundToolHint(props: { messages: SessionMessage[] }) { +function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { const { theme } = useTheme() const shortcut = useCommandShortcut("session.background") const visible = createMemo(() => { @@ -1090,14 +1077,14 @@ function BackgroundToolHint(props: { messages: SessionMessage[] }) { ) } -function SessionMessageView(props: { message: SessionMessage }) { +function SessionMessageView(props: { message: SessionMessageInfo }) { return ( - } /> + } /> @@ -1106,17 +1093,17 @@ function SessionMessageView(props: { message: SessionMessage }) { when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"} > }> - } /> + } /> - } /> + } /> ) } -function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessage | undefined }) { +function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) { const message = createMemo(() => props.message(props.partRef.messageID)) const part = createMemo(() => { const item = message() @@ -1150,7 +1137,7 @@ function SessionGroupView(props: { refs: PartRef[] pending: PartRef[] completed: boolean - message: (messageID: string) => SessionMessage | undefined + message: (messageID: string) => SessionMessageInfo | undefined }) { const { theme } = useTheme() const ctx = use() @@ -1260,7 +1247,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { ) } -function SessionSwitchMessageV2(props: { message: SessionMessage }) { +function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) { const ctx = use() const { theme } = useTheme() const text = () => { @@ -1276,7 +1263,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) { ) } -function SessionNoticeMessageV2(props: { message: SessionMessage }) { +function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) { const { theme } = useTheme() const text = () => { if (props.message.type === "system") return "Instructions updated" @@ -1290,7 +1277,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) { ) } -function SessionSkillMessage(props: { message: Extract }) { +function SessionSkillMessage(props: { message: Extract }) { const { theme } = useTheme() return ( @@ -1300,7 +1287,7 @@ function SessionSkillMessage(props: { message: Extract + message?: Extract status?: "running" text?: string }) { @@ -1308,9 +1295,10 @@ function CompactionMessage(props: { const kv = useKV() const { theme, syntax } = useTheme() const status = () => props.message?.status ?? props.status - const text = () => props.message?.summary ?? props.text ?? "" + const text = () => + props.message?.status === "failed" ? props.message.error.message : (props.message?.summary ?? props.text ?? "") const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted) - const border = () => (status() === "queued" ? theme.border : color()) + const border = color return ( @@ -1328,11 +1316,8 @@ function CompactionMessage(props: { - - - - {status() === "queued" ? "Compaction queued" : "Compaction"} + Compaction @@ -1363,7 +1348,7 @@ function statusLabel(status: "added" | "modified" | "deleted") { function RevertMessage(props: { count: number files: ReadonlyArray<{ - readonly path: string + readonly file: string readonly status: "added" | "modified" | "deleted" readonly additions: number readonly deletions: number @@ -1412,7 +1397,7 @@ function RevertMessage(props: { {statusLabel(file.status)} - {Locale.truncateLeft(file.path, 60)} + {Locale.truncateLeft(file.file, 60)} 0}> +{file.additions} @@ -1433,7 +1418,7 @@ function RevertMessage(props: { ) } -function ShellMessage(props: { message: Extract }) { +function ShellMessage(props: { message: Extract }) { const { theme } = useTheme() const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? "")) @@ -1448,7 +1433,7 @@ function ShellMessage(props: { message: Extract - $ {props.message.shell.command} + $ {props.message.command} {output()} @@ -1560,7 +1545,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole .map((part) => part.type === "tool" && ["read", "glob", "grep"].includes(toolDisplay(part.name)) && - part.state.status !== "pending" + part.state.status !== "streaming" ? part : undefined, ) @@ -1832,7 +1817,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { const data = useData() const display = createMemo(() => toolDisplay(props.part.name)) const activeBackgroundWork = createMemo(() => { - if (props.part.state.status === "pending") return false + if (props.part.state.status === "streaming") return false if (display() === "shell") { const shellID = stringValue(props.part.state.structured.shellID) return Boolean(shellID && data.shell.get(shellID)) @@ -1856,13 +1841,13 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { const toolprops = { get metadata() { - return props.part.state.status === "pending" ? {} : props.part.state.structured + return props.part.state.status === "streaming" ? {} : props.part.state.structured }, get input() { return typeof props.part.state.input === "string" ? {} : props.part.state.input }, get output() { - if (props.part.state.status === "pending") return undefined + if (props.part.state.status === "streaming") return undefined return props.part.state.content .flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri])) .join("\n") @@ -2187,7 +2172,7 @@ function Shell(props: ToolProps) { const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning()) const command = createMemo(() => stringValue(props.input.command)) const output = createMemo(() => { - if (props.part.state.status === "pending") return "" + if (props.part.state.status === "streaming") return "" if (shellID()) return "" const content = props.part.state.content[0] return stripAnsi(content?.type === "text" ? content.text.trim() : "") @@ -2209,7 +2194,7 @@ function Shell(props: ToolProps) { Writing command... ) : ( Writing command... @@ -2418,7 +2403,7 @@ function executeCalls(value: unknown): ExecuteCall[] { function Execute(props: ToolProps) { const ctx = use() const { theme } = useTheme() - const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running") + const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running") const calls = createMemo(() => executeCalls(props.metadata.toolCalls)) const output = createMemo(() => stripAnsi(props.output?.trim() ?? "")) const hasRuntimeError = createMemo(() => props.metadata.error === true) @@ -2514,7 +2499,7 @@ function Edit(props: ToolProps) { : "# Preparing edit..." } part={props.part} - spinner={props.part.state.status === "pending"} + spinner={props.part.state.status === "streaming"} /> @@ -2606,7 +2591,7 @@ function ApplyPatch(props: ToolProps) { : "# Preparing patch..." } part={props.part} - spinner={props.part.state.status === "pending"} + spinner={props.part.state.status === "streaming"} /> @@ -2756,15 +2741,15 @@ function recordValue(value: unknown): Record | undefined { } function formatSessionTranscript( - session: SessionV2Info, - messages: SessionMessage[], + session: SessionInfo, + messages: SessionMessageInfo[], thinking: boolean, toolDetails: boolean, ) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] if (message.type === "shell") - return [`## Shell\n\n\`\`\`\n$ ${message.shell.command}\n${message.output?.output ?? ""}\n\`\`\``] + return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``] if (message.type !== "assistant") return [] const content = message.content.flatMap((item) => { if (item.type === "text") return [item.text] @@ -2774,7 +2759,7 @@ function formatSessionTranscript( const output = item.state.status === "error" ? item.state.error.message - : item.state.status === "pending" + : item.state.status === "streaming" ? "" : item.state.content .flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri])) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index a137d6272a..a0ca4775c3 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -148,7 +148,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const message = data.session.message.get(props.request.sessionID, tool.messageID) if (message?.type !== "assistant") return {} const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID) - if (part?.type === "tool" && part.state.status !== "pending") return part.state.input + if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input return {} }) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 5d2a361452..c8764f3030 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,4 +1,4 @@ -import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" import { createEffect, on, onCleanup, type Accessor } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useData } from "../../context/data" @@ -88,7 +88,7 @@ export function createSessionRows(sessionID: Accessor) { { id: message.id, created: message.time.created, - input: message.status === "queued" || message.status === "running", + input: message.status === "running", }, ] : [], @@ -157,7 +157,7 @@ export function createSessionRows(sessionID: Accessor) { const isPending = (messageID: string) => { const message = data.session.message.get(sessionID(), messageID) if (message?.type === "user") return data.session.input.has(sessionID(), messageID) - return message?.type === "compaction" && (message.status === "queued" || message.status === "running") + return message?.type === "compaction" && message.status === "running" } const queuedStart = (rows: SessionRow[]) => { @@ -173,7 +173,7 @@ export function createSessionRows(sessionID: Accessor) { } const subscriptions = [ data.on("session.prompt.admitted", input), - data.on("session.compaction.admitted", input), + data.on("session.compaction.started", message), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { if (event.data.sessionID === sessionID() && event.data.description?.trim()) @@ -224,11 +224,9 @@ export function createSessionRows(sessionID: Accessor) { return rows } -export function reduceSessionRows(messages: SessionMessage[], inputs = new Set()) { - const isInput = (message: SessionMessage) => inputs.has(message.id) - const pendingCompactions = messages.filter( - (message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"), - ) +export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { + const isInput = (message: SessionMessageInfo) => inputs.has(message.id) + const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) return [ ...messages.filter((message) => !pending.has(message.id)), diff --git a/packages/tui/src/util/session.ts b/packages/tui/src/util/session.ts index 3a49ea1a9c..41f583b1c6 100644 --- a/packages/tui/src/util/session.ts +++ b/packages/tui/src/util/session.ts @@ -1,17 +1,14 @@ -import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" export function isDefaultTitle(title: string) { return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) } -export function lastAssistantWithUsage(messages: ReadonlyArray, boundary?: string) { +export function lastAssistantWithUsage(messages: ReadonlyArray, boundary?: string) { const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1 if (boundary && boundaryIndex === -1) return undefined return messages.findLast( - ( - message, - index, - ): message is SessionMessageAssistant & { tokens: NonNullable } => + (message, index): message is SessionMessageAssistant & { tokens: NonNullable } => message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex), ) } diff --git a/packages/tui/src/util/tool-display.ts b/packages/tui/src/util/tool-display.ts index 18e256bd34..c9d92434e2 100644 --- a/packages/tui/src/util/tool-display.ts +++ b/packages/tui/src/util/tool-display.ts @@ -6,7 +6,7 @@ export function webSearchProviderLabel(provider: unknown) { export function toolDisplayMetadata(state: unknown): Record { if (!state || typeof state !== "object" || Array.isArray(state)) return {} - if (!("status" in state) || state.status === "pending") return {} + if (!("status" in state) || state.status === "streaming") return {} if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {} if (Array.isArray(state.structured)) return {} return state.structured as Record diff --git a/packages/tui/test/cli/cmd/tui/notifications.test.ts b/packages/tui/test/cli/cmd/tui/notifications.test.ts index c08b557eee..ac3abc9581 100644 --- a/packages/tui/test/cli/cmd/tui/notifications.test.ts +++ b/packages/tui/test/cli/cmd/tui/notifications.test.ts @@ -93,7 +93,7 @@ function permission(id: string, sessionID = "session"): PermissionRequest { } } -function durable(sessionID: string) { +function durable(sessionID: string): { aggregateID: string; seq: number; version: 1 } { return { aggregateID: sessionID, seq: 0, version: 1 } } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index fdd5980fb5..c116f43178 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -24,6 +24,12 @@ function emitEvent(events: ReturnType, event: V2Event) events.emit({ ...event, location: { directory } }) } +function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 } +function durable( + sessionID: string, + seq: number, + version: Version, +): { aggregateID: string; seq: number; version: Version } function durable(sessionID: string, seq = 0, version = 1) { return { aggregateID: sessionID, seq, version } } @@ -265,7 +271,7 @@ test("applies absolute usage events without losing full session updates", async id: "evt_usage_deleted", created: 9, type: "session.deleted", - durable: durable(sessionID, 9), + durable: durable(sessionID, 9, 2), data: { sessionID }, }) await Bun.sleep(20) @@ -513,7 +519,11 @@ test("restores running manual compaction before applying live deltas", async () try { await data.session.message.refresh("session-compaction") - expect(data.session.compaction("session-compaction")).toBe("Existing ") + expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({ + type: "compaction", + status: "running", + summary: "Existing ", + }) emitEvent(events, { id: "evt_compaction_delta", @@ -524,7 +534,7 @@ test("restores running manual compaction before applying live deltas", async () await wait(() => { const message = data.session.message.get("session-compaction", "message-compaction") - return message?.type === "compaction" && message.summary === "Existing summary" + return message?.type === "compaction" && message.status === "running" && message.summary === "Existing summary" }) } finally { app.renderer.destroy() @@ -904,7 +914,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_step_ended", created: 0, type: "session.step.ended", - durable: durable("session-live", 1, 2), + durable: durable("session-live", 1), data: { sessionID: "session-live", assistantMessageID: "message-live", @@ -938,7 +948,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_execution_succeeded", created: 0, type: "session.execution.succeeded", - durable: durable("session-live", 1, 3), + durable: durable("session-live", 1), data: { sessionID: "session-live" }, }) await wait(() => data.session.status("session-live") === "idle") @@ -969,7 +979,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_step_failed", created: 0, type: "session.step.failed", - durable: durable("session-failed", 1, 2), + durable: durable("session-failed", 1), data: { sessionID: "session-failed", assistantMessageID: "message-failed", @@ -1009,7 +1019,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_failed_execution_failed", created: 0, type: "session.execution.failed", - durable: durable("session-failed", 1, 3), + durable: durable("session-failed", 1), data: { sessionID: "session-failed", error: { type: "provider.content-filter", message: "Provider blocked the response" }, @@ -1028,7 +1038,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_step_started", created: 0, type: "session.step.started", - durable: durable("session-retry", 1, 2), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", assistantMessageID: "message-retry", @@ -1040,7 +1050,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_scheduled", created: 0, type: "session.retry.scheduled", - durable: durable("session-retry", 1, 3), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", assistantMessageID: "message-retry", @@ -1058,7 +1068,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_next_step", created: 2_000, type: "session.step.started", - durable: durable("session-retry", 1, 4), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", assistantMessageID: "message-retry", @@ -1076,7 +1086,7 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_scheduled_again", created: 2_000, type: "session.retry.scheduled", - durable: durable("session-retry", 1, 5), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", assistantMessageID: "message-retry", @@ -1093,29 +1103,18 @@ test("tracks session status from active sessions and execution events", async () id: "evt_retry_interrupted", created: 2_000, type: "session.execution.interrupted", - durable: durable("session-retry", 1, 6), + durable: durable("session-retry", 1), data: { sessionID: "session-retry", reason: "shutdown" }, }) await wait(() => data.session.status("session-retry") === "idle") expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry") - emitEvent(events, { - id: "evt_compaction_admitted", - created: 0, - type: "session.compaction.admitted", - durable: durable("session-manual", 1), - data: { sessionID: "session-manual", inputID: "message-compaction" }, - }) - await wait(() => { - const message = data.session.message.get("session-manual", "message-compaction") - return message?.type === "compaction" && message.status === "queued" - }) emitEvent(events, { id: "evt_manual_compaction_started", created: 1, type: "session.compaction.started", durable: durable("session-manual", 2), - data: { sessionID: "session-manual", reason: "manual" }, + data: { sessionID: "session-manual", reason: "manual", recent: "", inputID: "message-compaction" }, }) emitEvent(events, { id: "evt_manual_compaction_delta", @@ -1125,13 +1124,13 @@ test("tracks session status from active sessions and execution events", async () }) await wait(() => { const message = data.session.message.get("session-manual", "message-compaction") - return message?.type === "compaction" && message.summary === "Streamed summary" + return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary" }) emitEvent(events, { id: "evt_manual_compaction_ended", created: 3, type: "session.compaction.ended", - durable: durable("session-manual", 3), + durable: durable("session-manual", 4), data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" }, }) await wait(() => { @@ -1147,7 +1146,7 @@ test("tracks session status from active sessions and execution events", async () created: 0, type: "session.compaction.started", durable: durable("session-live", 2), - data: { sessionID: "session-live", reason: "auto" }, + data: { sessionID: "session-live", reason: "auto", recent: "" }, }) emitEvent(events, { id: "evt_compaction_delta_1", @@ -1161,18 +1160,25 @@ test("tracks session status from active sessions and execution events", async () type: "session.compaction.delta", data: { sessionID: "session-live", text: "summary" }, }) - await wait(() => data.session.compaction("session-live") === "Live summary") + await wait(() => { + const message = data.session.message.get("session-live", "msg_compaction_started") + return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary" + }) emitEvent(events, { id: "evt_compaction_ended", created: 0, type: "session.compaction.ended", - durable: durable("session-live", 3), + durable: durable("session-live", 5), data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" }, }) - await wait(() => data.session.compaction("session-live") === undefined) - expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({ + await wait(() => { + const message = data.session.message.get("session-live", "msg_compaction_started") + return message?.type === "compaction" && message.status === "completed" + }) + expect(data.session.message.get("session-live", "msg_compaction_started")).toMatchObject({ type: "compaction", + status: "completed", summary: "Live summary", }) } finally { diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 362ab40369..c43a6faf9d 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test" -import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2" +import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2" import { reduceSessionRows } from "../../../src/routes/session/rows" test("groups exploration parts across assistant messages until a delimiter", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, assistant("assistant-1", [ { type: "text", text: "Looking" }, @@ -35,7 +35,7 @@ test("groups exploration parts across assistant messages until a delimiter", () }) test("keeps non-exploration tools as individual part rows", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }, { type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } }, @@ -63,7 +63,7 @@ test("keeps non-exploration tools as individual part rows", () => { }) test("assigns stable kind ordinals within an assistant message", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ { type: "text", text: "First" }, { type: "reasoning", text: "Think" }, @@ -81,7 +81,7 @@ test("assigns stable kind ordinals within an assistant message", () => { }) test("groups across empty assistant reasoning parts", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ { type: "reasoning", text: "Looking" }, { type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } }, @@ -112,7 +112,7 @@ test("completes exploration groups when another row follows", () => { { type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }, ]) finished.finish = "stop" - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "user", id: "user-1", text: "Continue", time: { created: 2 } }, finished, @@ -139,12 +139,11 @@ test("completes exploration groups when another row follows", () => { }) test("hides synthetic messages without descriptions", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "synthetic", id: "synthetic-1", - sessionID: "session-1", text: "internal context", time: { created: 2 }, }, @@ -166,12 +165,11 @@ test("hides synthetic messages without descriptions", () => { }) test("renders synthetic messages with descriptions", () => { - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]), { type: "synthetic", id: "synthetic-1", - sessionID: "session-1", text: "internal context", description: "Explicit notice", time: { created: 2 }, @@ -209,19 +207,19 @@ test("renders a footer for a pre-output retry assistant after replay", () => { expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }]) }) -test("places a pending compaction barrier before every queued user message", () => { - const queued = (id: string, text: string, created: number): SessionMessage => ({ +test("places a running compaction barrier before every queued user message", () => { + const queued = (id: string, text: string, created: number): SessionMessageInfo => ({ type: "user", id, text, time: { created }, }) - const messages: SessionMessage[] = [ + const messages: SessionMessageInfo[] = [ queued("user-before", "Before", 1), { type: "compaction", id: "compaction", - status: "queued", + status: "running", reason: "manual", summary: "", recent: "", @@ -249,5 +247,5 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses } function pending() { - return { status: "pending" as const, input: "" } + return { status: "streaming" as const, input: "" } } diff --git a/packages/tui/test/util/session.test.ts b/packages/tui/test/util/session.test.ts index 752f490247..38ec243770 100644 --- a/packages/tui/test/util/session.test.ts +++ b/packages/tui/test/util/session.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { SessionMessage } from "@opencode-ai/sdk/v2" +import type { SessionMessageInfo } from "@opencode-ai/sdk/v2" import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session" describe("util.session", () => { @@ -10,7 +10,7 @@ describe("util.session", () => { }) test("tracks usage across undo and redo boundaries", () => { - const assistant = (id: string, input: number): SessionMessage => ({ + const assistant = (id: string, input: number): SessionMessageInfo => ({ id, type: "assistant", agent: "build", diff --git a/packages/tui/test/util/tool-display.test.ts b/packages/tui/test/util/tool-display.test.ts index f77f54cbab..ca08e7d974 100644 --- a/packages/tui/test/util/tool-display.test.ts +++ b/packages/tui/test/util/tool-display.test.ts @@ -31,7 +31,7 @@ describe("toolDisplayMetadata", () => { }) test("does not expose pending or malformed metadata", () => { - expect(toolDisplayMetadata({ status: "pending", structured: { provider: "exa" } })).toEqual({}) + expect(toolDisplayMetadata({ status: "streaming", structured: { provider: "exa" } })).toEqual({}) expect(toolDisplayMetadata({ status: "completed" })).toEqual({}) expect(toolDisplayMetadata({ status: "completed", structured: null })).toEqual({}) expect(toolDisplayMetadata({ status: "completed", structured: [] })).toEqual({}) From e96c24ce2e2239b36914d95d2a3406afcffb1d17 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:22:12 -0400 Subject: [PATCH 30/34] test(core): fix mutable plugin fixture (#35837) --- packages/core/test/config/plugin.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/test/config/plugin.test.ts b/packages/core/test/config/plugin.test.ts index dc1fbc72e7..2bfe1d6c74 100644 --- a/packages/core/test/config/plugin.test.ts +++ b/packages/core/test/config/plugin.test.ts @@ -273,9 +273,9 @@ function withLocation( function mutablePlugin(description: string) { const plugin = pathToFileURL(path.join(import.meta.dir, "../../../plugin/src/v2/promise/index.ts")).href return ` -import { define } from ${JSON.stringify(plugin)} +import { Plugin } from ${JSON.stringify(plugin)} -export default EffectPlugin.define({ +export default Plugin.define({ id: "mutable-plugin", setup: async (ctx) => { await ctx.agent.transform((agents) => { From ed4f833813b6b88917be6611c468593bbcfb86fd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:45:58 -0500 Subject: [PATCH 31/34] fix(core): preserve provider metadata namespaces (#35817) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline --- packages/core/src/aisdk.ts | 5 +- packages/core/src/session/runner/llm.ts | 5 +- .../src/session/runner/publish-llm-event.ts | 12 ++-- .../core/src/session/runner/to-llm-message.ts | 21 ++++--- packages/core/test/aisdk.test.ts | 53 +++++++++++++++- .../core/test/session-runner-message.test.ts | 28 +++++++++ .../core/test/session-runner-model.test.ts | 2 + .../test/session-runner-tool-events.test.ts | 63 +++++++++++++++---- packages/core/test/session-runner.test.ts | 43 ++++++++----- .../llm/src/protocols/anthropic-messages.ts | 1 + .../llm/src/protocols/bedrock-converse.ts | 1 + packages/llm/src/protocols/gemini.ts | 1 + packages/llm/src/protocols/openai-chat.ts | 1 + .../src/protocols/openai-compatible-chat.ts | 1 + .../llm/src/protocols/openai-responses.ts | 2 + packages/llm/src/route/client.ts | 8 +++ 16 files changed, 201 insertions(+), 46 deletions(-) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index aecf7a6482..3330c77f4a 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -305,6 +305,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { const route: AnyRoute = { id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`, provider: ProviderID.make(info.providerID), + providerMetadataKey: optionKey, protocol: "ai-sdk", endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }), auth: Auth.none, @@ -417,7 +418,7 @@ function assistantPart(part: ContentPart): AssistantContent { case "media": return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }] case "reasoning": - return [{ type: "reasoning", text: part.text }] + return [{ type: "reasoning", text: part.text, providerOptions: providerOptions(part.providerMetadata) }] case "tool-call": return [ { @@ -426,6 +427,7 @@ function assistantPart(part: ContentPart): AssistantContent { toolName: part.name, input: part.input, providerExecuted: part.providerExecuted, + providerOptions: providerOptions(part.providerMetadata), }, ] case "tool-result": @@ -441,6 +443,7 @@ function toolResultPart(part: ContentPart): ToolResultContent[] { toolCallId: part.id, toolName: part.name, output: toolOutput(part.result), + providerOptions: providerOptions(part.providerMetadata), }, ] } diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index dea133ebb9..d757b19484 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -233,6 +233,7 @@ const layer = Layer.effect( } const resolved = yield* models.resolve(session) const model = resolved.model + const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq) const context = entries.map((entry) => entry.message) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps @@ -250,7 +251,7 @@ const layer = Layer.effect( .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: [ - ...toLLMMessages(context, resolved.ref), + ...toLLMMessages(context, resolved.ref, providerMetadataKey), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []), ], tools: toolMaterialization?.definitions ?? [], @@ -293,7 +294,7 @@ const layer = Layer.effect( // The selected catalog identity, not model.id: route-level ids are provider API // model ids (for example gpt-5.5-fast resolves to api id gpt-5.5). model: resolved.ref, - provider: model.provider, + providerMetadataKey, snapshot: startSnapshot, assistantMessageID, }) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index e7f5454c39..dfd0dca499 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -14,7 +14,7 @@ type Input = { readonly sessionID: SessionSchema.ID readonly agent: AgentV2.ID readonly model: ModelV2.Ref - readonly provider: string + readonly providerMetadataKey: string readonly snapshot?: Snapshot.ID readonly assistantMessageID?: SessionMessage.ID } @@ -87,7 +87,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantMessageID ??= SessionMessage.ID.create() stepStarted = true yield* events.publish(SessionEvent.Step.Started, { - ...input, + sessionID: input.sessionID, + agent: input.agent, + model: input.model, assistantMessageID, snapshot: input.snapshot, }) @@ -97,8 +99,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) assistantMessageID === undefined ? Effect.die(new Error("Tool event before assistant step start")) : Effect.succeed(assistantMessageID) - const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider] - + const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey] const fragments = ( name: string, ended: (id: string, value: string, ordinal: number, state?: Record) => Effect.Effect, @@ -352,14 +353,13 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`)) tool.called = true tool.providerExecuted = event.providerExecuted === true - const state = providerState(event.providerMetadata) yield* events.publish(SessionEvent.Tool.Called, { sessionID: input.sessionID, assistantMessageID: tool.assistantMessageID, callID: event.id, input: record(event.input), executed: tool.providerExecuted, - state, + state: providerState(event.providerMetadata), }) return } diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 048374f519..bd75094878 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -113,7 +113,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid } } -const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { +const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => { const sameModel = String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id) const reuseProviderMetadata = sameModel && message.error === undefined @@ -125,7 +125,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { { type: "reasoning", text: item.text, - providerMetadata: providerMetadata(model.providerID, item.state), + providerMetadata: providerMetadata(providerMetadataKey, item.state), }, ] : item.text.length > 0 @@ -133,13 +133,13 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { : [] const call = toolCall( item, - reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined, + reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined, ) if (item.executed !== true) return [call] const result = toolResult( item, reuseProviderMetadata - ? providerMetadata(model.providerID, item.providerResultState ?? item.providerState) + ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState) : undefined, ) return result ? [call, result] : [call] @@ -155,7 +155,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { toolResult( item, reuseProviderMetadata - ? providerMetadata(model.providerID, item.providerResultState ?? item.providerState) + ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState) : undefined, ), ) @@ -168,7 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => { ] } -function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref): Message[] { +function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] { switch (message.type) { case "agent-switched": case "model-switched": @@ -207,7 +207,7 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref): Message }), ] case "assistant": - return assistant(message, model) + return assistant(message, model, providerMetadataKey) case "compaction": if (message.status !== "completed") return [] return [ @@ -232,5 +232,8 @@ ${message.recent} } /** Translate projected V2 Session history into canonical @opencode-ai/llm context. */ -export const toLLMMessages = (messages: readonly SessionMessage.Info[], model: ModelV2.Ref) => - messages.flatMap((message) => toLLMMessage(message, model)) +export const toLLMMessages = ( + messages: readonly SessionMessage.Info[], + model: ModelV2.Ref, + providerMetadataKey: string = model.providerID, +) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey)) diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 4037949427..0a4b7dadce 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -2,7 +2,7 @@ import type { LanguageModelV3CallOptions } from "@ai-sdk/provider" import { AISDK } from "@opencode-ai/core/aisdk" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { LLM } from "@opencode-ai/llm" +import { LLM, Message } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { expect } from "bun:test" import { Effect } from "effect" @@ -67,3 +67,54 @@ it.effect("projects request settings, headers, and body overlays", () => expect(body).toEqual({ safety_setting: "strict" }) }), ) + +it.effect("projects replay metadata onto AI SDK prompt parts", () => + Effect.gen(function* () { + const aisdk = yield* AISDK.Service + yield* aisdk.hook.sdk((event) => { + event.sdk = { languageModel: () => ({ provider: event.model.providerID }) } + }) + + const resolved = yield* aisdk.model(model("@ai-sdk/anthropic")) + expect(resolved.route.providerMetadataKey).toBe("anthropic") + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: resolved, + messages: [ + Message.assistant([ + { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } }, + { + type: "tool-call", + id: "hosted", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: { anthropic: { blockType: "server_tool_use" } }, + }, + ]), + ], + }), + ) + + expect(prepared.body.prompt).toEqual([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Think", + providerOptions: { anthropic: { signature: "signed" } }, + }, + { + type: "tool-call", + toolCallId: "hosted", + toolName: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerOptions: { anthropic: { blockType: "server_tool_use" } }, + }, + ], + }, + ]) + }), +) diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 5910c4e4fb..c593749f37 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -454,6 +454,34 @@ Recent work ]) }) + test("replays flat state under an OpenCode hosted model's route key", () => { + const opencode = ModelV2.Ref.make({ id: ModelV2.ID.make("claude-fable-5"), providerID: ProviderV2.ID.opencode }) + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-opencode-reasoning"), + type: "assistant", + agent: build, + model: opencode, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + text: "Think", + state: { signature: "signed" }, + }), + ], + time: { created, completed: created }, + }), + ], + opencode, + "anthropic", + ) + + expect(messages[0]?.content).toEqual([ + { type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "signed" } } }, + ]) + }) + test("lowers failed assistant reasoning to text", () => { const messages = toLLMMessages( [ diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index c2e2dd2557..f3c666b4a9 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -53,6 +53,7 @@ describe("SessionRunnerModel", () => { expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" }) expect(resolved.route).toMatchObject({ id: "openai-responses", + providerMetadataKey: "openai", endpoint: { baseURL: "https://openai.example/v1" }, defaults: { headers: { "x-test": "header" }, @@ -264,6 +265,7 @@ describe("SessionRunnerModel", () => { expect(resolved.route).toMatchObject({ id: "anthropic-messages", + providerMetadataKey: "anthropic", endpoint: { baseURL: "https://anthropic.example/v1" }, }) }), diff --git a/packages/core/test/session-runner-tool-events.test.ts b/packages/core/test/session-runner-tool-events.test.ts index 4b53ae4729..179818d852 100644 --- a/packages/core/test/session-runner-tool-events.test.ts +++ b/packages/core/test/session-runner-tool-events.test.ts @@ -14,7 +14,7 @@ import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publis const sessionID = SessionV2.ID.make("ses_tool_event_test") const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" -const capture = () => { +const capture = (providerMetadataKey = "anthropic") => { const published: Array<{ readonly type: string; readonly data: unknown }> = [] const events = EventV2.Service.of({ publish: (definition, data) => @@ -45,9 +45,9 @@ const capture = () => { agent: AgentV2.ID.make("build"), model: { id: ModelV2.ID.make("model"), - providerID: ProviderV2.ID.make("provider"), + providerID: ProviderV2.ID.opencode, }, - provider: "openai", + providerMetadataKey, }), } } @@ -99,35 +99,76 @@ test("provider-executed success retains its raw provider result", async () => { expect(success?.data).toHaveProperty("result") }) -test("provider state uses the route provider instead of the catalog provider", async () => { +test("provider metadata is flattened using the route key", async () => { const { published, publisher } = capture() await Effect.runPromise( publisher.publish( - LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }), + LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { signature: "signed" } } }), ), ) expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({ - state: { itemId: "reasoning" }, + state: { signature: "signed" }, }) }) -test("reasoning state from an empty delta is retained at reasoning end", async () => { +test("reasoning state from start, empty delta, and end is merged", async () => { const { published, publisher } = capture() - await Effect.runPromise(publisher.publish(LLMEvent.reasoningStart({ id: "reasoning" }))) + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { anthropic: { blockType: "thinking" } } }), + ), + ) await Effect.runPromise( publisher.publish( LLMEvent.reasoningDelta({ id: "reasoning", text: "", - providerMetadata: { openai: { signature: "signed" } }, + providerMetadata: { anthropic: { signature: "signed" }, gateway: { traceID: "trace" } }, }), ), ) - await Effect.runPromise(publisher.publish(LLMEvent.reasoningEnd({ id: "reasoning" }))) + await Effect.runPromise( + publisher.publish( + LLMEvent.reasoningEnd({ id: "reasoning", providerMetadata: { anthropic: { stopReason: "tool_use" } } }), + ), + ) expect(published.find((event) => event.type === "session.reasoning.ended.1")?.data).toMatchObject({ - state: { signature: "signed" }, + state: { blockType: "thinking", signature: "signed", stopReason: "tool_use" }, + }) +}) + +test("provider-executed tool metadata is flattened using the route key", async () => { + const { published, publisher } = capture("openai") + await Effect.runPromise( + publisher.publish( + LLMEvent.toolCall({ + id: "hosted", + name: "web_search", + input: { query: "Effect" }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "call" } }, + }), + ), + ) + await Effect.runPromise( + publisher.publish( + LLMEvent.toolResult({ + id: "hosted", + name: "web_search", + result: { type: "json", value: { found: true } }, + providerExecuted: true, + providerMetadata: { openai: { itemId: "result" } }, + }), + ), + ) + + expect(published.find((event) => event.type === "session.tool.called.1")?.data).toMatchObject({ + state: { itemId: "call" }, + }) + expect(published.find((event) => event.type === "session.tool.success.1")?.data).toMatchObject({ + resultState: { itemId: "result" }, }) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index fd34a1954e..ef2d24445a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -1674,7 +1674,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "hello" }, providerExecuted: true, - providerMetadata: { fake: { source: "provider" } }, + providerMetadata: { openai: { source: "provider" } }, }), LLMEvent.toolResult({ id: "call-provider", @@ -1687,7 +1687,7 @@ describe("SessionRunnerLLM", () => { ], }, providerExecuted: true, - providerMetadata: { fake: { source: "provider" } }, + providerMetadata: { openai: { source: "provider" } }, }), LLMEvent.stepFinish({ index: 0, @@ -1834,21 +1834,21 @@ describe("SessionRunnerLLM", () => { LLMEvent.reasoningDelta({ id: "reasoning-anthropic", text: "Signed thought" }), LLMEvent.reasoningEnd({ id: "reasoning-anthropic", - providerMetadata: { fake: { signature: "sig_1" }, anthropic: { ignored: true } }, + providerMetadata: { openai: { signature: "sig_1" }, anthropic: { ignored: true } }, }), LLMEvent.reasoningStart({ id: "reasoning-openai", providerMetadata: { - fake: { itemId: "rs_1", reasoningEncryptedContent: null }, - openai: { ignored: true }, + openai: { itemId: "rs_1", reasoningEncryptedContent: null }, + anthropic: { ignored: true }, }, }), LLMEvent.reasoningDelta({ id: "reasoning-openai", text: "Encrypted thought" }), LLMEvent.reasoningEnd({ id: "reasoning-openai", providerMetadata: { - fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, - openai: { ignored: true }, + openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" }, + anthropic: { ignored: true }, }, }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), @@ -1862,7 +1862,11 @@ describe("SessionRunnerLLM", () => { { type: "assistant", content: [ - { type: "reasoning", text: "Signed thought", state: { signature: "sig_1" } }, + { + type: "reasoning", + text: "Signed thought", + state: { signature: "sig_1" }, + }, { type: "reasoning", text: "Encrypted thought", @@ -1877,11 +1881,15 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests[1]?.messages[1]?.content).toEqual([ - { type: "reasoning", text: "Signed thought", providerMetadata: { fake: { signature: "sig_1" } } }, + { + type: "reasoning", + text: "Signed thought", + providerMetadata: { openai: { signature: "sig_1" } }, + }, { type: "reasoning", text: "Encrypted thought", - providerMetadata: { fake: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, + providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, }, ]) }), @@ -1899,14 +1907,14 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { fake: { itemId: "hosted-search" }, openai: { ignored: true } }, + providerMetadata: { openai: { itemId: "hosted-search" }, fake: { ignored: true } }, }), LLMEvent.toolResult({ id: "hosted-search", name: "web_search", result: { type: "json", value: [{ title: "Effect" }] }, providerExecuted: true, - providerMetadata: { fake: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } }, + providerMetadata: { openai: { blockType: "web_search_tool_result" }, anthropic: { ignored: true } }, }), LLMEvent.stepFinish({ index: 0, reason: "stop" }), LLMEvent.finish({ reason: "stop" }), @@ -1926,7 +1934,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", input: { query: "Effect" }, providerExecuted: true, - providerMetadata: { fake: { itemId: "hosted-search" } }, + providerMetadata: { openai: { itemId: "hosted-search" } }, }, { type: "tool-result", @@ -1934,7 +1942,7 @@ describe("SessionRunnerLLM", () => { name: "web_search", result: { type: "json", value: [{ title: "Effect" }] }, providerExecuted: true, - providerMetadata: { fake: { blockType: "web_search_tool_result" } }, + providerMetadata: { openai: { blockType: "web_search_tool_result" } }, }, ]) }), @@ -2469,7 +2477,7 @@ describe("SessionRunnerLLM", () => { type: "tool-call", id: "call-hosted-interrupted", providerExecuted: true, - providerMetadata: { fake: { itemId: "call-hosted-interrupted" } }, + providerMetadata: { openai: { itemId: "call-hosted-interrupted" } }, }, { type: "tool-result", id: "call-hosted-interrupted", providerExecuted: true, result: { type: "error" } }, ]) @@ -2670,7 +2678,10 @@ describe("SessionRunnerLLM", () => { { type: "tool", id: "call-missing", - state: { status: "error", error: { message: "Unknown tool: missing" } }, + state: { + status: "error", + error: { type: "tool.unknown", message: "Unknown tool: missing" }, + }, }, ], }, diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index f626827292..6117cd5c20 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -876,6 +876,7 @@ export const protocol = Protocol.make({ export const route = Route.make({ id: ADAPTER, provider: "anthropic", + providerMetadataKey: "anthropic", protocol, endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), auth: Auth.none, diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 0c6b0598ff..4984e32365 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -657,6 +657,7 @@ export const protocol = Protocol.make({ export const route = Route.make({ id: ADAPTER, provider: "bedrock", + providerMetadataKey: "bedrock", protocol, // Bedrock's URL embeds the region in the route endpoint host and the // validated modelId in the path. We read the validated body so the URL diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index c4bb9476a4..82a69b059e 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -500,6 +500,7 @@ export const protocol = Protocol.make({ export const route = Route.make({ id: ADAPTER, provider: "google", + providerMetadataKey: "google", protocol, // Gemini's path embeds the model id and pins SSE framing at the URL level. endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, { diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index eb3b7141c6..cba67ce0a9 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -493,6 +493,7 @@ export const httpTransport = HttpTransport.sseJson.with() export const route = Route.make({ id: ADAPTER, provider: "openai", + providerMetadataKey: "openai", protocol, endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }), auth: Auth.none, diff --git a/packages/llm/src/protocols/openai-compatible-chat.ts b/packages/llm/src/protocols/openai-compatible-chat.ts index ce3f0a83d7..9ae9a53b43 100644 --- a/packages/llm/src/protocols/openai-compatible-chat.ts +++ b/packages/llm/src/protocols/openai-compatible-chat.ts @@ -16,6 +16,7 @@ export type OpenAICompatibleChatModelInput = RouteRoutedModelInput */ export const route = Route.make({ id: ADAPTER, + providerMetadataKey: "openai", protocol: OpenAIChat.protocol, endpoint: Endpoint.path("/chat/completions"), framing: Framing.sse, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index e0546f47ed..e2889d5b6d 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -990,6 +990,7 @@ export const httpTransport = HttpTransport.sseJson.with() export const route = Route.make({ id: ADAPTER, provider: "openai", + providerMetadataKey: "openai", protocol, endpoint, auth, @@ -1018,6 +1019,7 @@ export const webSocketTransport = WebSocketTransport.jsonTransport.with< export const webSocketRoute = Route.make({ id: `${ADAPTER}-websocket`, provider: "openai", + providerMetadataKey: "openai", protocol, endpoint, auth, diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index d3b41f5817..a258aae944 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -36,6 +36,8 @@ export interface RouteBody { export interface Route { readonly id: string readonly provider?: ProviderID + /** ProviderMetadata namespace emitted and consumed by this route. */ + readonly providerMetadataKey?: string readonly protocol: ProtocolID readonly endpoint: Endpoint readonly auth: AuthDef @@ -184,6 +186,8 @@ export interface MakeInput { readonly id: string /** Provider identity for route-owned model construction. */ readonly provider?: string | ProviderID + /** ProviderMetadata namespace emitted and consumed by this route. */ + readonly providerMetadataKey?: string /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Where the request is sent. */ @@ -203,6 +207,8 @@ export interface MakeTransportInput { readonly id: string /** Provider identity for route-owned model construction. */ readonly provider?: string | ProviderID + /** ProviderMetadata namespace emitted and consumed by this route. */ + readonly providerMetadataKey?: string /** Semantic API contract — owns body construction, body schema, and parsing. */ readonly protocol: Protocol /** Where the request is sent. */ @@ -248,6 +254,7 @@ function makeFromTransport( const route: Route = { id: routeInput.id, provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider), + providerMetadataKey: routeInput.providerMetadataKey, protocol: protocol.id, endpoint: routeInput.endpoint, auth: routeInput.auth ?? Auth.none, @@ -329,6 +336,7 @@ export function make( return makeFromTransport({ id: input.id, provider: input.provider, + providerMetadataKey: input.providerMetadataKey, protocol, endpoint: input.endpoint, auth: input.auth, From 8d00a5176668fabc4ec8db69d51071d93456b77e Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 8 Jul 2026 00:35:18 -0400 Subject: [PATCH 32/34] feat(simulation): support multiple tool calls (#35861) --- packages/simulation/src/backend/llm-exchange.ts | 8 +++++++- packages/simulation/src/backend/openai.ts | 6 +++++- packages/simulation/src/protocol/index.ts | 8 +++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/simulation/src/backend/llm-exchange.ts b/packages/simulation/src/backend/llm-exchange.ts index 97b7b71b1f..638f80c1d0 100644 --- a/packages/simulation/src/backend/llm-exchange.ts +++ b/packages/simulation/src/backend/llm-exchange.ts @@ -18,7 +18,13 @@ import { Effect, Queue } from "effect" export type Item = | { readonly type: "textDelta"; readonly text: string } | { readonly type: "reasoningDelta"; readonly text: string } - | { readonly type: "toolCall"; readonly id: string; readonly name: string; readonly input: unknown } + | { + readonly type: "toolCall" + readonly index: number + readonly id: string + readonly name: string + readonly input: unknown + } | { readonly type: "raw"; readonly chunk: unknown } export type FinishReason = "stop" | "tool-calls" | "length" | "content-filter" diff --git a/packages/simulation/src/backend/openai.ts b/packages/simulation/src/backend/openai.ts index c13e7772ef..fb58a44724 100644 --- a/packages/simulation/src/backend/openai.ts +++ b/packages/simulation/src/backend/openai.ts @@ -30,7 +30,11 @@ function chunkOf(item: SimulationLLMExchange.Item): OpenAIChatEvent | unknown { { delta: { tool_calls: [ - { index: 0, id: item.id, function: { name: item.name, arguments: JSON.stringify(item.input) } }, + { + index: item.index, + id: item.id, + function: { name: item.name, arguments: JSON.stringify(item.input) }, + }, ], }, }, diff --git a/packages/simulation/src/protocol/index.ts b/packages/simulation/src/protocol/index.ts index c4acb77188..73c43f4119 100644 --- a/packages/simulation/src/protocol/index.ts +++ b/packages/simulation/src/protocol/index.ts @@ -141,7 +141,13 @@ export namespace Backend { export const Item = Schema.Union([ Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }), Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }), - Schema.Struct({ type: Schema.Literal("toolCall"), id: Schema.String, name: Schema.String, input: Schema.Json }), + Schema.Struct({ + type: Schema.Literal("toolCall"), + index: Schema.Number, + id: Schema.String, + name: Schema.String, + input: Schema.Json, + }), Schema.Struct({ type: Schema.Literal("raw"), chunk: Schema.Json }), ]) export type Item = Schema.Schema.Type From 86e3828da6b2e5f34d64d9e12b507395787a1692 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 8 Jul 2026 00:38:37 -0400 Subject: [PATCH 33/34] fix(core): watch location only when vcs is present --- packages/core/src/filesystem/location-watcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts index 613b43b5f8..7765a396e8 100644 --- a/packages/core/src/filesystem/location-watcher.ts +++ b/packages/core/src/filesystem/location-watcher.ts @@ -46,7 +46,7 @@ const layer = Layer.effect( .flatMap((item) => item.info.watcher?.ignore ?? []) const home = path.resolve(location.directory) === path.resolve(os.homedir()) - if (!home) { + if (!home && location.vcs) { yield* watcher .subscribe({ path: location.directory, From cc29f86cdc1371ec8255d68f750a732eb1f6cfd5 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 8 Jul 2026 01:34:43 -0400 Subject: [PATCH 34/34] fix(tui): paginate session history --- packages/tui/src/context/data.tsx | 306 +++++++++--------- packages/tui/src/routes/session/index.tsx | 111 +++++-- packages/tui/src/routes/session/rows.ts | 126 +++----- packages/tui/test/cli/tui/data.test.tsx | 207 ++++++------ .../tui/test/cli/tui/session-rows.test.ts | 22 +- 5 files changed, 386 insertions(+), 386 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index da44a0f8e9..adefd3487f 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -1,60 +1,77 @@ +// Client data layer: apply server events and cache API reads into a Solid store. +// Prefer straightforward projection. Do not add generation counters, stale-response +// merges, live/history overlays, or other race machinery here—last write wins. +// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns. + import type { - AgentInfo, - CommandInfo, + AgentV2Info, + CommandV2Info, FormFormInfo, FormUrlInfo, IntegrationInfo, LocationRef, McpServer, - ModelInfo, + ModelV2Info, PermissionSavedInfo, PermissionV2Request, ProviderV2Info, ReferenceInfo, - SessionMessageInfo, + SessionMessage, SessionMessageAssistant, SessionMessageAssistantReasoning, SessionMessageAssistantText, SessionMessageAssistantTool, - SessionInfo, + SessionV2Info, Shell, - SkillInfo, + SkillV2Info, V2Event, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" -import { createSignal, onCleanup } from "solid-js" +import { batch, createSignal, onCleanup } from "solid-js" export type DataSessionStatus = "idle" | "running" const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") +const MESSAGE_PAGE_SIZE = 25 export type FormInfo = FormFormInfo | FormUrlInfo +// Per-session message timeline plus older-history paging. `items` is ascending; +// `cursor` is the opaque server cursor for the next older page after a desc first load. +type SessionMessages = { + items: SessionMessage[] + cursor?: string + complete: boolean + loading: boolean +} + type LocationData = { - agent?: AgentInfo[] - command?: CommandInfo[] + agent?: AgentV2Info[] + command?: CommandV2Info[] integration?: IntegrationInfo[] mcp?: McpServer[] - model?: ModelInfo[] + model?: ModelV2Info[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] // Currently running shell commands for this location, keyed by shell id. Entries are removed // once the command exits or is deleted, so this only ever holds in-flight shells. shell?: Record - skill?: SkillInfo[] + skill?: SkillV2Info[] } type Data = { session: { - info: Record + info: Record // Family index keyed by a family's root (or furthest-known-ancestor when the // true root is not yet loaded). The value is a flat deduplicated list of every // session ID in that family, including the key itself once its info arrives. family: Record status: Record - message: Record + compaction: Partial> + compactionReason: Partial> + message: Record input: Record permission: Record // Pending forms keyed by session ID. @@ -66,6 +83,10 @@ type Data = { location: Record } +function emptyMessages(): SessionMessages { + return { items: [], complete: false, loading: false } +} + function locationKey(location: LocationRef) { return JSON.stringify([location.directory, location.workspaceID]) } @@ -90,6 +111,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ info: {}, family: {}, status: {}, + compaction: {}, + compactionReason: {}, message: {}, input: {}, permission: {}, @@ -106,66 +129,44 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() - const sessionRefreshGeneration = new Map() - const sessionRefreshApplied = new Map() - const sessionUsage = new Map() - let connectionGeneration = 0 - let statusChanges: Set | undefined let bootstrapping: Promise | undefined function setSessionStatus(sessionID: string, status: DataSessionStatus) { - statusChanges?.add(sessionID) setStore("session", "status", sessionID, status) } - function nextSessionRefresh(sessionID: string) { - const generation = (sessionRefreshGeneration.get(sessionID) ?? 0) + 1 - sessionRefreshGeneration.set(sessionID, generation) - return generation - } - - function applySessionRefresh(sessionID: string, generation: number) { - if ((sessionRefreshApplied.get(sessionID) ?? 0) > generation) return false - sessionRefreshApplied.set(sessionID, generation) - return true - } - - function updateSessionUsage(sessionID: string, cost: number, tokens: SessionInfo["tokens"]) { - sessionUsage.set(sessionID, { generation: (sessionUsage.get(sessionID)?.generation ?? 0) + 1, cost, tokens }) - if (!store.session.info[sessionID]) return - setStore("session", "info", sessionID, { cost, tokens }) - } - const message = { - update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map) => void) { + update(sessionID: string, fn: (messages: SessionMessage[], index: Map) => void) { setStore( "session", "message", produce((draft) => { - fn((draft[sessionID] ??= []), index(sessionID)) + fn((draft[sessionID] ??= emptyMessages()).items, index(sessionID)) }), ) }, - append(messages: SessionMessageInfo[], index: Map, item: SessionMessageInfo) { + append(messages: SessionMessage[], index: Map, item: SessionMessage) { if (index.has(item.id)) return index.set(item.id, messages.length) messages.push(item) }, - activeAssistant(messages: SessionMessageInfo[]) { + activeAssistant(messages: SessionMessage[]) { const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed) return item?.type === "assistant" ? item : undefined }, - assistant(messages: SessionMessageInfo[], index: Map, messageID: string) { + assistant(messages: SessionMessage[], index: Map, messageID: string) { const position = index.get(messageID) const item = position === undefined ? undefined : messages[position] return item?.type === "assistant" ? item : undefined }, - shell(messages: SessionMessageInfo[], shellID: string) { - const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID) + shell(messages: SessionMessage[], shellID: string) { + const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID) return item?.type === "shell" ? item : undefined }, - compaction(messages: SessionMessageInfo[]) { - const item = messages.findLast((item) => item.type === "compaction" && item.status === "running") + compaction(messages: SessionMessage[]) { + const item = messages.findLast( + (item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"), + ) return item?.type === "compaction" ? item : undefined }, latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { @@ -237,14 +238,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function removeSession(sessionID: string) { - sessionRefreshApplied.set(sessionID, nextSessionRefresh(sessionID)) - sessionUsage.delete(sessionID) messageIndex.delete(sessionID) setStore( "session", produce((draft) => { delete draft.info[sessionID] delete draft.status[sessionID] + delete draft.compaction[sessionID] delete draft.message[sessionID] delete draft.input[sessionID] delete draft.permission[sessionID] @@ -267,7 +267,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ removeSession(event.data.sessionID) break case "session.usage.updated": - updateSessionUsage(event.data.sessionID, event.data.cost, event.data.tokens) + if (store.session.info[event.data.sessionID]) + setStore("session", "info", event.data.sessionID, { + cost: event.data.cost, + tokens: event.data.tokens, + }) break case "catalog.updated": void Promise.all([ @@ -374,7 +378,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: messageIDFromEvent(event.id), type: "system", text: event.data.text, - metadata: event.metadata, time: { created: event.created }, }) }) @@ -384,9 +387,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.append(draft, index, { id: messageIDFromEvent(event.id), type: "synthetic", + sessionID: event.data.sessionID, text: event.data.text, description: event.data.description, - metadata: event.data.metadata, time: { created: event.created }, }) }) @@ -396,11 +399,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.append(draft, index, { id: messageIDFromEvent(event.id), type: "shell", - shellID: event.data.shell.id, - command: event.data.shell.command, - status: event.data.shell.status, - exit: event.data.shell.exit, - metadata: event.metadata, + shell: event.data.shell, time: { created: event.created }, }) }) @@ -409,8 +408,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.update(event.data.sessionID, (draft) => { const match = message.shell(draft, event.data.shell.id) if (!match) return - match.status = event.data.shell.status - match.exit = event.data.shell.exit + match.shell = event.data.shell match.output = event.data.output match.time.completed = event.created }) @@ -439,7 +437,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ type: "assistant", agent: event.data.agent, model: event.data.model, - metadata: event.metadata, content: [], snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, time: { created: event.created }, @@ -500,7 +497,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ id: event.data.callID, name: event.data.name, time: { created: event.created }, - state: { status: "streaming", input: "" }, + state: { status: "pending", input: "" }, }) }) break @@ -510,7 +507,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "streaming") match.state.input += event.data.delta + if (match?.state.status === "pending") match.state.input += event.data.delta }) break case "session.tool.input.ended": @@ -519,7 +516,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (match?.state.status === "streaming") match.state.input = event.data.text + if (match?.state.status === "pending") match.state.input = event.data.text }) break case "session.tool.called": @@ -571,7 +568,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.assistant(draft, index, event.data.assistantMessageID), event.data.callID, ) - if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return + if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return match.state = { status: "error", error: event.data.error, @@ -626,9 +623,29 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setSessionStatus(event.data.sessionID, "running") break case "session.compaction.admitted": + message.update(event.data.sessionID, (draft, index) => { + if (message.compaction(draft)) return + message.append(draft, index, { + id: event.data.inputID, + type: "compaction", + status: "queued", + reason: "manual", + summary: "", + recent: "", + time: { created: event.created }, + }) + }) break case "session.compaction.started": + setStore("session", "compaction", event.data.sessionID, "") + setStore("session", "compactionReason", event.data.sessionID, event.data.reason) message.update(event.data.sessionID, (draft, index) => { + const current = message.compaction(draft) + if (current) { + current.status = "running" + current.reason = event.data.reason + return + } message.append(draft, index, { id: event.data.inputID ?? messageIDFromEvent(event.id), type: "compaction", @@ -644,6 +661,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.execution.failed": case "session.execution.interrupted": setSessionStatus(event.data.sessionID, "idle") + if (store.session.compaction[event.data.sessionID] !== undefined) + setStore("session", "compaction", event.data.sessionID, undefined) + if (store.session.compactionReason[event.data.sessionID] !== undefined) + setStore("session", "compactionReason", event.data.sessionID, undefined) message.update(event.data.sessionID, (draft) => { const currentAssistant = message.activeAssistant(draft) if (currentAssistant) currentAssistant.retry = undefined @@ -674,26 +695,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.delta": + setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text) message.update(event.data.sessionID, (draft) => { const current = message.compaction(draft) - if (current?.status === "running") current.summary += event.data.text + if (current) current.summary += event.data.text }) break case "session.compaction.ended": + setStore("session", "compaction", event.data.sessionID, undefined) + setStore("session", "compactionReason", event.data.sessionID, undefined) message.update(event.data.sessionID, (draft, index) => { - const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") - const current = draft[position] - if (current?.type === "compaction") { - draft[position] = { - id: current.id, - type: "compaction", - status: "completed", - reason: event.data.reason, - summary: event.data.text, - recent: event.data.recent, - metadata: current.metadata, - time: current.time, - } + const current = message.compaction(draft) + if (current) { + current.status = "completed" + current.reason = event.data.reason + current.summary = event.data.text + current.recent = event.data.recent return } message.append(draft, index, { @@ -708,26 +725,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }) break case "session.compaction.failed": - message.update(event.data.sessionID, (draft, index) => { - const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running") - const current = draft[position] - const failed: Extract = { - id: current?.id ?? event.data.inputID ?? messageIDFromEvent(event.id), - type: "compaction", - status: "failed", - reason: event.data.reason ?? "manual", - error: event.data.error ?? { - type: "compaction.failed", - message: "Compaction failed before recording an error", - }, - metadata: current?.type === "compaction" ? current.metadata : event.metadata, - time: current?.type === "compaction" ? current.time : { created: event.created }, - } - if (current?.type === "compaction") { - draft[position] = failed - return - } - message.append(draft, index, failed) + setStore("session", "compaction", event.data.sessionID, undefined) + setStore("session", "compactionReason", event.data.sessionID, undefined) + message.update(event.data.sessionID, (draft) => { + const current = message.compaction(draft) + if (current) current.status = "failed" }) break case "permission.v2.asked": @@ -845,50 +847,73 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.input[sessionID]?.includes(inputID) ?? false }, }, + compaction(sessionID: string) { + return store.session.compaction[sessionID] + }, async refresh(sessionID: string) { - const generation = nextSessionRefresh(sessionID) - const usageGeneration = sessionUsage.get(sessionID)?.generation ?? 0 - const info = mutable(await sdk.api.session.get({ sessionID })) - if (!applySessionRefresh(sessionID, generation)) return - const usage = sessionUsage.get(sessionID) - setStore( - "session", - "info", - sessionID, - usage && usage.generation !== usageGeneration ? { ...info, cost: usage.cost, tokens: usage.tokens } : info, - ) + setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) registerSession(sessionID) }, message: { ids(sessionID: string) { - return (store.session.message[sessionID] ?? []).map((message) => message.id) + return (store.session.message[sessionID]?.items ?? []).map((message) => message.id) }, list(sessionID: string) { - return store.session.message[sessionID] ?? [] + return store.session.message[sessionID]?.items ?? [] }, get(sessionID: string, messageID: string) { - const messages = store.session.message[sessionID] + const messages = store.session.message[sessionID]?.items const position = messageIndex.get(sessionID)?.get(messageID) return position === undefined ? undefined : messages?.[position] }, + cursor(sessionID: string) { + return store.session.message[sessionID]?.cursor + }, + complete(sessionID: string) { + return store.session.message[sessionID]?.complete ?? false + }, + loading(sessionID: string) { + return store.session.message[sessionID]?.loading ?? false + }, async refresh(sessionID: string) { - const live = [...(store.session.message[sessionID] ?? [])] - setStore("session", "message", sessionID, []) + setStore("session", "message", sessionID, { ...emptyMessages(), loading: true }) messageIndex.set(sessionID, new Map()) - const loaded = mutable( - (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data, - ).toReversed() - const loadedIDs = new Set(loaded.map((message) => message.id)) - const liveByID = new Map(live.map((message) => [message.id, message])) - const messages = [ - ...loaded.map((message) => { - if (message.type === "user") return message - return liveByID.get(message.id) ?? message - }), - ...live.filter((message) => !loadedIDs.has(message.id)), - ].toSorted((a, b) => a.time.created - b.time.created) - messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) - setStore("session", "message", sessionID, messages) + const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, order: "desc" }) + const items = mutable(response.data).toReversed() + messageIndex.set(sessionID, new Map(items.map((message, index) => [message.id, index]))) + setStore("session", "message", sessionID, { + items, + cursor: response.cursor.next ?? undefined, + complete: response.data.length < MESSAGE_PAGE_SIZE, + loading: false, + }) + const running = items.find((message) => message.type === "compaction" && message.status === "running") + setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined) + setStore( + "session", + "compactionReason", + sessionID, + running?.type === "compaction" ? running.reason : undefined, + ) + }, + async more(sessionID: string) { + const current = store.session.message[sessionID] + if (!current || current.loading || current.complete || !current.cursor) return + const cursor = current.cursor + setStore("session", "message", sessionID, "loading", true) + const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, cursor }) + const older = mutable(response.data).toReversed() + const prepend = older.filter((item) => !messageIndex.get(sessionID)?.has(item.id)) + const items = [...prepend, ...current.items] + messageIndex.set(sessionID, new Map(items.map((item, position) => [item.id, position]))) + batch(() => { + setStore("session", "message", sessionID, "items", items) + setStore("session", "message", sessionID, { + cursor: response.cursor.next ?? undefined, + complete: response.data.length < MESSAGE_PAGE_SIZE, + loading: false, + }) + }) }, }, permission: { @@ -1031,8 +1056,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async function bootstrap() { if (bootstrapping) return bootstrapping - const generation = new Map(sessionRefreshApplied) - const usageGeneration = new Map(Array.from(sessionUsage, ([id, usage]) => [id, usage.generation])) bootstrapping = Promise.allSettled([ sdk.api.session .list({ @@ -1046,15 +1069,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ "session", "info", produce((draft) => { - for (const session of response.data) { - if ((sessionRefreshApplied.get(session.id) ?? 0) !== (generation.get(session.id) ?? 0)) continue - const usage = sessionUsage.get(session.id) - draft[session.id] = mutable( - usage && usage.generation !== (usageGeneration.get(session.id) ?? 0) - ? { ...session, cost: usage.cost, tokens: usage.tokens } - : session, - ) - } + for (const session of response.data) draft[session.id] = mutable(session) }), ) for (const session of response.data) registerSession(session.id) @@ -1101,23 +1116,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } function refreshActive() { - const generation = ++connectionGeneration - const changed = new Set() - statusChanges = changed void sdk.api.session .active() .then((active) => { - if (generation !== connectionGeneration) return - const status: Record = Object.fromEntries( - Object.keys(active).map((sessionID) => [sessionID, "running" as const]), + setStore( + "session", + "status", + reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))), ) - for (const sessionID of changed) status[sessionID] = store.session.status[sessionID] - setStore("session", "status", reconcile(status)) }) .catch(() => undefined) - .finally(() => { - if (statusChanges === changed) statusChanges = undefined - }) } onCleanup( diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e37fae9249..1c02329d62 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -198,6 +198,16 @@ export function Session() { ?.id }) + // Admitted inputs and in-flight manual compaction sit after history, not in rows. + const pendingMessages = createMemo(() => { + const boundary = session()?.revert?.messageID + return messages().filter((message) => { + if (boundary && message.id >= boundary) return false + if (data.session.input.has(route.sessionID, message.id)) return true + return message.type === "compaction" && (message.status === "queued" || message.status === "running") + }) + }) + const lastAssistant = createMemo(() => { return messages().findLast((x) => x.type === "assistant") }) @@ -241,37 +251,44 @@ export function Session() { }), ) - createEffect(() => { - const sessionID = route.sessionID - void (async () => { - await Promise.all([ - data.session.refresh(sessionID), - data.session.permission.refresh(sessionID), - data.session.form.refresh(sessionID), - ]) - const info = data.session.get(sessionID) - if (!info) { - toast.show({ - message: `Session not found: ${sessionID}`, - variant: "error", - duration: 5000, + createEffect( + on( + () => route.sessionID, + (sessionID) => { + void (async () => { + if (data.session.message.list(sessionID).length === 0) { + await Promise.all([ + data.session.refresh(sessionID), + data.session.message.refresh(sessionID), + data.session.permission.refresh(sessionID), + data.session.form.refresh(sessionID), + ]) + } + const info = data.session.get(sessionID) + if (!info) { + toast.show({ + message: `Session not found: ${sessionID}`, + variant: "error", + duration: 5000, + }) + navigate({ type: "home" }) + return + } + project.workspace.set(info.location.workspaceID) + editor.reconnect(info.location.directory) + if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + })().catch((error) => { + if (route.sessionID !== sessionID) return + toast.show({ + message: errorMessage(error), + variant: "error", + duration: 5000, + }) + navigate({ type: "home" }) }) - navigate({ type: "home" }) - return - } - project.workspace.set(info.location.workspaceID) - editor.reconnect(info.location.directory) - if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) - })().catch((error) => { - if (route.sessionID !== sessionID) return - toast.show({ - message: errorMessage(error), - variant: "error", - duration: 5000, - }) - navigate({ type: "home" }) - }) - }) + }, + ), + ) let seeded = false let scroll: ScrollBoxRenderable @@ -327,12 +344,14 @@ export function Session() { if (!targetID) { scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height) + if (direction === "prev") loadOlder() dialog.clear() return } const child = scroll.getChildren().find((c) => c.id === targetID) if (child) scroll.scrollBy(child.y - scroll.y - 1) + if (direction === "prev") loadOlder() dialog.clear() } @@ -343,6 +362,24 @@ export function Session() { }, 50) } + let loadingOlder = false + function loadOlder() { + if (loadingOlder || scroll.scrollTop > 2) return + loadingOlder = true + const before = scroll.scrollHeight + void data.session.message.more(route.sessionID).then( + () => { + setTimeout(() => { + if (!scroll.isDestroyed) scroll.scrollBy(scroll.scrollHeight - before) + loadingOlder = false + }, 50) + }, + () => { + loadingOlder = false + }, + ) + } + const sessionCommandList = createMemo(() => [ { title: "Share session", @@ -548,6 +585,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 2) + loadOlder() dialog.clear() }, }, @@ -568,6 +606,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-1) + loadOlder() dialog.clear() }, }, @@ -588,6 +627,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollBy(-scroll.height / 4) + loadOlder() dialog.clear() }, }, @@ -608,6 +648,7 @@ export function Session() { hidden: true, run: () => { scroll.scrollTo(0) + loadOlder() dialog.clear() }, }, @@ -917,6 +958,9 @@ export function Session() { stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} + onMouseScroll={(event) => { + if (event.scroll?.direction === "up") void loadOlder() + }} > {(row) => ( @@ -926,6 +970,13 @@ export function Session() { /> )} + + {(message) => ( + + + + )} + ) { const [rows, setRows] = createStore([]) const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID + function pendingIDs() { + const inputs = data.session.input.list(sessionID()) + const pending = new Set(inputs) + for (const message of data.session.message.list(sessionID())) { + if (message.type === "compaction" && (message.status === "queued" || message.status === "running")) + pending.add(message.id) + } + return pending + } + function reduce() { const messages = data.session.message.list(sessionID()) - const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() - const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) + const visible = boundary ? messages.filter((message) => message.id < boundary) : messages + const pending = pendingIDs() + const rows = reduceSessionRows(visible.filter((message) => !pending.has(message.id))) partitionPending(rows, pendingPermissions()) return rows } @@ -52,48 +63,30 @@ export function createSessionRows(sessionID: Accessor) { }) createEffect( - on(sessionID, (id) => { - setRows(reconcile(reduce())) - void data.session.message.refresh(id).then( - () => { - if (sessionID() !== id) return - setRows(reconcile(reduce())) - }, - () => undefined, - ) + on(sessionID, () => { + setRows(reduce()) }), ) - // Re-reduce when the revert boundary changes (stage/clear/commit). createEffect( on(revertBoundary, () => { - setRows(reconcile(reduce())) + setRows(reduce()) }), ) + // Pending inputs and compaction leaving the pending set change history membership. createEffect( on( - () => - data.session.message.list(sessionID()).flatMap((message) => - message.type === "user" - ? [ - { - id: message.id, - created: message.time.created, - input: data.session.input.has(sessionID(), message.id), - }, - ] - : message.type === "compaction" - ? [ - { - id: message.id, - created: message.time.created, - input: message.status === "running", - }, - ] - : [], - ), - () => setRows(reconcile(reduce())), + () => { + const messages = data.session.message.list(sessionID()) + const pending = data.session.input.list(sessionID()).join("\0") + const compaction = messages + .filter((message) => message.type === "compaction") + .map((message) => `${message.id}:${message.status}`) + .join("\0") + return `${pending}\u0001${compaction}` + }, + () => setRows(reduce()), ), ) @@ -101,12 +94,9 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return - const pending = isPending(messageID) - const message = data.session.message.get(sessionID(), messageID) - const index = - message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft) - if (!pending) completePrevious(draft, index) - draft.splice(index, 0, { type: "message", messageID }) + if (pendingIDs().has(messageID)) return + completePrevious(draft) + draft.push({ type: "message", messageID }) }), ) @@ -114,15 +104,14 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (hasPart(draft, ref)) return - const index = queuedStart(draft) if (name && exploration(name)) { - const previous = draft[index - 1] + const previous = draft.at(-1) if (previous?.type === "group" && previous.kind === "exploration") { previous.refs.push(ref) return } - completePrevious(draft, index) - draft.splice(index, 0, { + completePrevious(draft) + draft.push({ type: "group", kind: "exploration", refs: [ref], @@ -131,8 +120,8 @@ export function createSessionRows(sessionID: Accessor) { }) return } - completePrevious(draft, index) - draft.splice(index, 0, { type: "part", ref }) + completePrevious(draft) + draft.push({ type: "part", ref }) }), ) @@ -140,9 +129,8 @@ export function createSessionRows(sessionID: Accessor) { setRows( produce((draft) => { if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return - const index = queuedStart(draft) - completePrevious(draft, index) - draft.splice(index, 0, { type: "assistant-footer", messageID }) + completePrevious(draft) + draft.push({ type: "assistant-footer", messageID }) }), ) @@ -154,26 +142,13 @@ export function createSessionRows(sessionID: Accessor) { }), ) - const isPending = (messageID: string) => { - const message = data.session.message.get(sessionID(), messageID) - if (message?.type === "user") return data.session.input.has(sessionID(), messageID) - return message?.type === "compaction" && message.status === "running" - } - - const queuedStart = (rows: SessionRow[]) => { - const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID)) - return index === -1 ? rows.length : index - } - const message = (event: { id: string; data: { sessionID: string } }) => { if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_")) } - const input = (event: { data: { sessionID: string; inputID: string } }) => { - if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) - } const subscriptions = [ - data.on("session.prompt.admitted", input), - data.on("session.compaction.started", message), + data.on("session.prompt.promoted", (event) => { + if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID) + }), data.on("session.instructions.updated", message), data.on("session.synthetic", (event) => { if (event.data.sessionID === sessionID() && event.data.description?.trim()) @@ -182,9 +157,7 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.shell.started", message), data.on("session.agent.selected", message), data.on("session.model.selected", message), - data.on("session.compaction.ended", (event) => { - if (event.data.reason !== "manual") message(event) - }), + data.on("session.text.delta", (event) => { if (event.data.sessionID === sessionID()) appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }) @@ -224,18 +197,11 @@ export function createSessionRows(sessionID: Accessor) { return rows } -export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { - const isInput = (message: SessionMessageInfo) => inputs.has(message.id) - const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") - const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) - return [ - ...messages.filter((message) => !pending.has(message.id)), - ...pendingCompactions, - ...messages.filter(isInput), - ].reduce((rows, message) => { +export function reduceSessionRows(messages: SessionMessage[]) { + return messages.reduce((rows, message) => { if (message.type !== "assistant") { if (message.type === "synthetic" && !message.description?.trim()) return rows - if (!pending.has(message.id)) completePrevious(rows) + completePrevious(rows) rows.push({ type: "message", messageID: message.id }) return rows } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index c116f43178..b17e6eea57 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -114,24 +114,31 @@ test("refreshes resources into reactive getters", async () => { } }) -test("applies absolute usage events without losing full session updates", async () => { +test("pages older messages through nested message state", async () => { const events = createEventStream() - const sessionID = "ses_usage_refresh" - let resolveSessions!: (response: Response) => void - const resolveSession: Array<(response: Response) => void> = [] - let sessionsRequested = false + const sessionID = "ses_message_page" + const pages: Array<{ limit?: string | null; order?: string | null; cursor?: string | null }> = [] + // Full first page (desc) so complete stays false until a short older page arrives. + const first = Array.from({ length: 50 }, (_, index) => { + const n = 51 - index + return { id: `msg_${n}`, type: "user" as const, text: String(n), time: { created: n } } + }) const calls = createFetch((url) => { - if (url.pathname === "/api/session") { - sessionsRequested = true - return new Promise((resolve) => { - resolveSessions = resolve + if (url.pathname !== `/api/session/${sessionID}/message`) return + pages.push({ + limit: url.searchParams.get("limit"), + order: url.searchParams.get("order"), + cursor: url.searchParams.get("cursor"), + }) + if (!url.searchParams.get("cursor")) + return json({ + data: first, + cursor: { next: "cursor-older" }, }) - } - if (url.pathname === `/api/session/${sessionID}`) { - return new Promise((resolve) => { - resolveSession.push(resolve) - }) - } + return json({ + data: [{ id: "msg_1", type: "user", text: "one", time: { created: 1 } }], + cursor: {}, + }) }, events) let data!: ReturnType @@ -153,7 +160,68 @@ test("applies absolute usage events without losing full session updates", async )) try { - await wait(() => sessionsRequested) + await data.session.message.refresh(sessionID) + expect(pages).toEqual([{ limit: "50", order: "desc", cursor: null }]) + expect(data.session.message.ids(sessionID)).toEqual(first.toReversed().map((message) => message.id)) + expect(data.session.message.cursor(sessionID)).toBe("cursor-older") + expect(data.session.message.complete(sessionID)).toBe(false) + expect(data.session.message.loading(sessionID)).toBe(false) + + await data.session.message.more(sessionID) + expect(pages).toEqual([ + { limit: "50", order: "desc", cursor: null }, + { limit: "50", order: null, cursor: "cursor-older" }, + ]) + expect(data.session.message.ids(sessionID)[0]).toBe("msg_1") + expect(data.session.message.ids(sessionID)).toHaveLength(51) + expect(data.session.message.cursor(sessionID)).toBeUndefined() + expect(data.session.message.complete(sessionID)).toBe(true) + + await data.session.message.more(sessionID) + expect(pages).toHaveLength(2) + } finally { + app.renderer.destroy() + } +}) + +test("applies absolute usage events to session info", async () => { + const events = createEventStream() + const sessionID = "ses_usage_refresh" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}`) + return json({ + data: { + id: sessionID, + projectID: "proj_test", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + title: "Usage", + location: { directory }, + }, + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await data.session.refresh(sessionID) emitEvent(events, { id: "evt_usage_2", created: 2, @@ -164,38 +232,6 @@ test("applies absolute usage events without losing full session updates", async tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, }, }) - const initialRefresh = data.session.refresh(sessionID) - await wait(() => resolveSession.length === 1) - resolveSessions( - json({ - data: [ - { - id: sessionID, - projectID: "proj_test", - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0, updated: 0 }, - title: "Stale usage", - location: { directory }, - }, - ], - cursor: {}, - }), - ) - resolveSession[0]( - json({ - data: { - id: sessionID, - projectID: "proj_test", - cost: 0.5, - tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }, - time: { created: 0, updated: 0 }, - title: "Current usage", - location: { directory }, - }, - }), - ) - await initialRefresh await wait(() => data.session.get(sessionID)?.cost === 0.5) expect(data.session.get(sessionID)?.tokens).toEqual({ input: 5, @@ -204,7 +240,6 @@ test("applies absolute usage events without losing full session updates", async cache: { read: 1, write: 1 }, }) - const fullRefresh = data.session.refresh(sessionID) emitEvent(events, { id: "evt_usage_3", created: 3, @@ -216,57 +251,8 @@ test("applies absolute usage events without losing full session updates", async }, }) await wait(() => data.session.get(sessionID)?.cost === 1) - resolveSession[1]( - json({ - data: { - id: sessionID, - projectID: "proj_test", - cost: 0.75, - tokens: { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } }, - time: { created: 0, updated: 0 }, - title: "Older usage", - location: { directory }, - }, - }), - ) - await fullRefresh - await Bun.sleep(20) - expect(data.session.get(sessionID)?.cost).toBe(1) - expect(data.session.get(sessionID)?.title).toBe("Older usage") + expect(data.session.get(sessionID)?.title).toBe("Usage") - emitEvent(events, { - id: "evt_usage_6", - created: 6, - type: "session.usage.updated", - data: { - sessionID, - cost: 1.25, - tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } }, - }, - }) - emitEvent(events, { - id: "evt_usage_7", - created: 7, - type: "session.usage.updated", - data: { - sessionID, - cost: 1.25, - tokens: { input: 12, output: 5, reasoning: 1, cache: { read: 1, write: 1 } }, - }, - }) - await wait(() => data.session.get(sessionID)?.cost === 1.25) - expect(data.session.get(sessionID)?.title).toBe("Older usage") - - emitEvent(events, { - id: "evt_usage_8", - created: 8, - type: "session.usage.updated", - data: { - sessionID, - cost: 1.5, - tokens: { input: 14, output: 6, reasoning: 1, cache: { read: 1, write: 1 } }, - }, - }) emitEvent(events, { id: "evt_usage_deleted", created: 9, @@ -274,8 +260,7 @@ test("applies absolute usage events without losing full session updates", async durable: durable(sessionID, 9, 2), data: { sessionID }, }) - await Bun.sleep(20) - expect(data.session.get(sessionID)).toBeUndefined() + await wait(() => data.session.get(sessionID) === undefined) } finally { app.renderer.destroy() } @@ -609,15 +594,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { expect(data.connection.error()).toBe("Event stream disconnected") await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000) - emitEvent(events, { - id: "evt_execution_started_after_reconnect", - created: 1, - type: "session.execution.started", - durable: durable("session-new"), - data: { sessionID: "session-new" }, - }) - await wait(() => data.session.status("session-new") === "running") - resolveActive(json({ data: {} })) + resolveActive(json({ data: { "session-new": { type: "running" } } })) await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000) await wait(() => data.session.status("session-stale") === "idle") @@ -631,16 +608,18 @@ test("reconnects the event stream and bootstraps fresh data", async () => { } }) -test("completes exploration when a queued prompt is promoted", async () => { +test("keeps pending prompts out of history rows until promoted", async () => { const events = createEventStream() const sessionID = "session-promotion" const calls = createFetch((url) => { if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) }, events) let rows!: ReturnType + let data!: ReturnType function Probe() { rows = createSessionRows(() => sessionID) + data = useData() return } @@ -695,7 +674,8 @@ test("completes exploration when a queued prompt is promoted", async () => { delivery: "steer", }, }) - await wait(() => rows.at(-1)?.type === "message") + await wait(() => data.session.input.has(sessionID, "message-user")) + expect(rows.some((row) => row.type === "message" && row.messageID === "message-user")).toBe(false) expect(rows.find((row) => row.type === "group")?.completed).toBe(false) emitEvent(events, { @@ -705,7 +685,8 @@ test("completes exploration when a queued prompt is promoted", async () => { durable: durable(sessionID, 3), data: { sessionID, inputID: "message-user" }, }) - await wait(() => rows.find((row) => row.type === "group")?.completed === true) + await wait(() => rows.some((row) => row.type === "message" && row.messageID === "message-user")) + expect(data.session.input.has(sessionID, "message-user")).toBe(false) expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" }) } finally { app.renderer.destroy() diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index c43a6faf9d..1def9caa55 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -207,31 +207,25 @@ test("renders a footer for a pre-output retry assistant after replay", () => { expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }]) }) -test("places a running compaction barrier before every queued user message", () => { - const queued = (id: string, text: string, created: number): SessionMessageInfo => ({ - type: "user", - id, - text, - time: { created }, - }) +test("history reduce keeps chronological order without pending reordering", () => { const messages: SessionMessageInfo[] = [ - queued("user-before", "Before", 1), + { type: "user", id: "user-1", text: "Before", time: { created: 1 } }, { type: "compaction", id: "compaction", - status: "running", + status: "completed", reason: "manual", - summary: "", + summary: "done", recent: "", time: { created: 2 }, }, - queued("user-after", "After", 3), + { type: "user", id: "user-2", text: "After", time: { created: 3 } }, ] - expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([ + expect(reduceSessionRows(messages)).toEqual([ + { type: "message", messageID: "user-1" }, { type: "message", messageID: "compaction" }, - { type: "message", messageID: "user-before" }, - { type: "message", messageID: "user-after" }, + { type: "message", messageID: "user-2" }, ]) })